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:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-08-04 22:52:34 +08:00
1 parent 7cc5c30e71
commit 89435bf62a
27 files changed
+1980 -638

No files matched your search

+54 -5
View File
@@ -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 {
+72
View File
@@ -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: [