feat(project): per-project new-chat defaults (agent, workspace, approval, thinking, model) (#191)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7cc5c30e71
commit
89435bf62a
27 files changed
+1980
-638
No files matched your search
@@ -20,6 +20,8 @@ import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_PROJECT_ID,
|
||||
installSkill,
|
||||
loadProjectConfig,
|
||||
saveProjectConfig,
|
||||
setVaultEntry,
|
||||
} from "../src/index.js";
|
||||
import { effectiveMaxContextLength, metaMaxTokens } from "../src/agent.js";
|
||||
@@ -298,6 +300,52 @@ describe("Agent.createSession thinking level (explicit option wins over the Agen
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// The full resolution chain (the single rule, comment-pinned at Agent.configuredThinkingLevel
|
||||
// and mirrored by the web draft picker's display): Agent explicit `model.thinking_level` >
|
||||
// Project `default_chat.thinking_level` > built-in "medium".
|
||||
it("falls back to the Project's default_chat.thinking_level when the Agent config has none", async () => {
|
||||
const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
cfg.default_chat = { thinking_level: "high" };
|
||||
await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg);
|
||||
const agent = await createAgent();
|
||||
// A hand-edited config without a level (the seeded default pins "medium").
|
||||
delete agent.state.systemConfig.model?.thinking_level;
|
||||
const ws = path.join(tmpRoot, "ws-thinking-project");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
const session = await agent.createSession({ workspaceDir: ws });
|
||||
try {
|
||||
expect(defaultLevelOf(session)).toBe("high");
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("Agent explicit level wins over the project default; built-in medium is the last resort", async () => {
|
||||
const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
cfg.default_chat = { thinking_level: "low" };
|
||||
await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg);
|
||||
const agent = await createAgent();
|
||||
const ws = path.join(tmpRoot, "ws-thinking-chain");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
// The seeded config's explicit "medium" beats the project's "low".
|
||||
const explicit = await agent.createSession({ workspaceDir: ws });
|
||||
try {
|
||||
expect(defaultLevelOf(explicit)).toBe("medium");
|
||||
} finally {
|
||||
explicit.dispose();
|
||||
}
|
||||
// No explicit level and no project default -> the built-in "medium".
|
||||
const bare = await createAgent();
|
||||
delete bare.projectConfig.default_chat;
|
||||
delete bare.state.systemConfig.model?.thinking_level;
|
||||
const builtin = await bare.createSession({ workspaceDir: ws });
|
||||
try {
|
||||
expect(defaultLevelOf(builtin)).toBe("medium");
|
||||
} finally {
|
||||
builtin.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("run_subagent spawning follows the PARENT session (never the Project default)", () => {
|
||||
@@ -407,15 +455,16 @@ describe("run_subagent spawning follows the PARENT session (never the Project de
|
||||
});
|
||||
|
||||
it("passes 'no thinking level' down when the parent has none (the child config never applies)", async () => {
|
||||
// helper_agent's own seeded config pins "medium"; the parent's optional key is removed
|
||||
// (hand-edited config). The tri-state null must reach the child: falling back to the
|
||||
// child's own config here was the fallback hole — the child must show no level at all.
|
||||
// helper_agent's own seeded config pins "medium". With the config chain always resolving
|
||||
// (Agent explicit > Project default_chat > built-in "medium"), a parent with NO level can
|
||||
// only come from the explicit tri-state null (an SDK caller suppressing the chain). That
|
||||
// null must reach the child as-is: falling back to the child's own config here was the
|
||||
// fallback hole — the child must show no level at all.
|
||||
await createAgent({ agentId: "helper_agent" });
|
||||
const agent = await createAgent();
|
||||
delete agent.state.systemConfig.model?.thinking_level;
|
||||
const ws = path.join(tmpRoot, "ws-inherit-none");
|
||||
await fs.mkdir(ws, { recursive: true });
|
||||
const parent = await agent.createSession({ workspaceDir: ws });
|
||||
const parent = await agent.createSession({ workspaceDir: ws, thinkingLevel: null });
|
||||
const parentLLM = capturedLLMConfigs.list.at(-1)!;
|
||||
const runner = lastSpawnedRunner();
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AGENT_ID_PLACEHOLDER,
|
||||
@@ -37,8 +38,10 @@ import {
|
||||
scratchpadDir,
|
||||
projectConfigPath,
|
||||
removeVaultEntry,
|
||||
renderProjectConfigToml,
|
||||
resolveModelRef,
|
||||
resolveRoot,
|
||||
saveProjectConfig,
|
||||
setDefaultModel,
|
||||
setVaultEntry,
|
||||
skillsDir,
|
||||
@@ -1254,6 +1257,75 @@ describe("project-config round trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("default_chat (new-chat defaults block)", () => {
|
||||
const block = {
|
||||
agent_id: "default_agent",
|
||||
workspace: "/tmp/some-ws",
|
||||
approval_mode: "always-ask",
|
||||
thinking_level: "high",
|
||||
} as const;
|
||||
|
||||
it("is absent from the default config (absent = the pre-existing behavior)", () => {
|
||||
expect("default_chat" in defaultProjectConfig()).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips through save/load — a known key the CLI's literal rebuild keeps", async () => {
|
||||
const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
cfg.default_chat = { ...block };
|
||||
await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg);
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(loaded.default_chat).toEqual(block);
|
||||
// A second unrelated save/load round trip (the load→save path that only keeps known
|
||||
// keys) must not drop the block: it is echoed in loadProjectConfig's return literal.
|
||||
await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, loaded);
|
||||
expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_chat).toEqual(block);
|
||||
});
|
||||
|
||||
it("loads tolerantly: an invalid value drops that key, never the load", async () => {
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(
|
||||
file,
|
||||
[
|
||||
"[default_chat]",
|
||||
'agent_id = "default_agent"',
|
||||
"workspace = 3", // wrong type -> dropped
|
||||
'approval_mode = "yolo"', // unknown enum -> dropped
|
||||
'thinking_level = "none"', // "none" is never a project default -> dropped
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
expect(loaded.default_chat).toEqual({ agent_id: "default_agent" });
|
||||
});
|
||||
|
||||
it("drops the block entirely when it is not a table or nothing valid remains", async () => {
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, 'default_chat = "high"\n', "utf8");
|
||||
expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_chat).toBeUndefined();
|
||||
await fs.writeFile(file, '[default_chat]\nthinking_level = "extreme"\n', "utf8");
|
||||
expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_chat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renderProjectConfigToml emits the [default_chat] table after every top-level line", () => {
|
||||
// Pathological insertion order (a read-modify-write can append scalars after the block):
|
||||
// if default_chat rendered first, the scalars below it would be parsed back as ITS
|
||||
// members. The renderer defers table blocks below all top-level `key = value` lines.
|
||||
const text = renderProjectConfigToml({
|
||||
default_chat: { thinking_level: "low" },
|
||||
name: "after-table",
|
||||
default_model: { provider: "p", model_id: "m" },
|
||||
models: [{ provider: "p", model_id: "m" }],
|
||||
});
|
||||
const parsed = parseToml(text) as Record<string, unknown>;
|
||||
expect(parsed.name).toBe("after-table");
|
||||
expect(parsed.default_model).toEqual({ provider: "p", model_id: "m" });
|
||||
expect(parsed.default_chat).toEqual({ thinking_level: "low" });
|
||||
expect(parsed.models).toEqual([{ provider: "p", model_id: "m" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelRef (validates a (provider, model_id) pair against the config)", () => {
|
||||
const cfg: ProjectConfig = {
|
||||
models: [
|
||||
|
||||
Reference in new issue
Block a user