Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,11 @@ export const SendMessageOptionsSchema = z.object({
* composer's concrete level and doubles as the fallback.
*/
autoThinkingLevel: z.boolean().optional(),
/**
* Auto choices to save for the agent instead of this send's own flags: a plan handoff sends
* with routing off but keeps the target agent's Auto choices for its later sends.
*/
savedAutoRouting: z.object({ model: z.boolean(), thinkingLevel: z.boolean() }).optional(),
/**
* When true, workspace-specific agent definitions are disabled.
* Only built-in and global agents are loaded. Useful for "unbricking" when
Expand Down
12 changes: 11 additions & 1 deletion src/common/orpc/schemas/workspaceAiSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export const WorkspaceAISettingsSchema = z.object({
}),
});

// Only sends persist the auto-routing flags, so the picker update routes keep the base schema.
export const WorkspaceAgentAISettingsSchema = WorkspaceAISettingsSchema.extend({
autoModelRouting: z.boolean().optional().meta({
description: "Composer model set to Auto on the last send. Absent = off.",
}),
autoThinkingLevel: z.boolean().optional().meta({
description: "Composer thinking level set to Auto on the last send. Absent = off.",
}),
});

/**
* Per-agent workspace AI overrides.
*
Expand All @@ -29,5 +39,5 @@ export const WorkspaceAISettingsSchema = z.object({
*/
export const WorkspaceAISettingsByAgentSchema = z.record(
z.string().min(1),
WorkspaceAISettingsSchema
WorkspaceAgentAISettingsSchema
);
34 changes: 34 additions & 0 deletions src/node/services/agentTaskReawakenAi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ describe("computeReawakenInputsKey", () => {
}
expect(key(makeConfig(), "other-context")).not.toBe(base);
});

it("ignores Auto flags, which planning does not read", () => {
const bucket = { model: MODEL_A, thinkingLevel: "medium" as const };
const flagged = { ...bucket, autoModelRouting: true, autoThinkingLevel: true };
for (const side of ["child", "parent"] as const) {
expect(key(makeConfig({ [side]: { aiSettingsByAgent: { exec: flagged } } }))).toBe(
key(makeConfig({ [side]: { aiSettingsByAgent: { exec: bucket } } }))
);
}
});
});

describe("planReawakenAi", () => {
Expand Down Expand Up @@ -220,4 +230,28 @@ describe("applyAgentTaskTurnAiSnapshot", () => {
expect(workspace.taskModelString).toBe(MODEL_B);
expect(workspace.taskThinkingLevel).toBe("high");
});

it("keeps the child's saved Auto choices", () => {
const workspace: Workspace = {
id: "child",
name: "child",
path: "/repo/child",
aiSettingsByAgent: {
exec: { model: MODEL_C, thinkingLevel: "low", autoModelRouting: true },
},
};
applyAgentTaskTurnAiSnapshot(workspace, {
agentId: "exec",
taskModelString: MODEL_B,
canonicalModel: MODEL_B,
thinkingLevel: "high",
reasoningMode: "standard",
});
expect(workspace.aiSettingsByAgent?.exec).toEqual({
model: MODEL_B,
thinkingLevel: "high",
reasoningMode: "standard",
autoModelRouting: true,
});
});
});
24 changes: 22 additions & 2 deletions src/node/services/agentTaskReawakenAi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ import {
} from "@/node/services/taskWorkspaceSeam";

type LoadedConfig = ReturnType<Config["loadConfigOrDefault"]>;
type AgentSettingsBucket = NonNullable<WorkspaceConfigEntry["aiSettingsByAgent"]>[string];

/** Planning never reads the composer's Auto flags, so a flag-only change must not refuse a commit. */
function withoutAutoRoutingFlags(bucket: AgentSettingsBucket | undefined) {
if (bucket == null) return null;
const { autoModelRouting, autoThinkingLevel, ...settings } = bucket;
return settings;
}

/** AI settings one reawakened execution runs with; mirrors creation's conventions. */
export interface AgentTaskTurnAiSnapshot {
Expand Down Expand Up @@ -131,7 +139,7 @@ export function computeReawakenInputsKey(
agentId,
parentWorkspaceId: child.parentWorkspaceId ?? null,
taskAiPins: child.taskAiPins ?? null,
bucket: child.aiSettingsByAgent?.[agentId] ?? null,
bucket: withoutAutoRoutingFlags(child.aiSettingsByAgent?.[agentId]),
aiSettings: child.aiSettings ?? null,
taskModelString: child.taskModelString ?? null,
taskThinkingLevel: child.taskThinkingLevel ?? null,
Expand All @@ -141,7 +149,15 @@ export function computeReawakenInputsKey(
? {
agentId: parent.agentId ?? null,
agentType: parent.agentType ?? null,
aiSettingsByAgent: parent.aiSettingsByAgent ?? null,
aiSettingsByAgent:
parent.aiSettingsByAgent != null
? Object.fromEntries(
Object.entries(parent.aiSettingsByAgent).map(([id, bucket]) => [
id,
withoutAutoRoutingFlags(bucket),
])
)
: null,
aiSettings: parent.aiSettings ?? null,
}
: null,
Expand Down Expand Up @@ -310,12 +326,16 @@ export function applyAgentTaskTurnAiSnapshot(
thinkingLevel: snapshot.thinkingLevel,
reasoningMode: snapshot.reasoningMode,
};
const previous = workspace.aiSettingsByAgent?.[snapshot.agentId];
workspace.aiSettingsByAgent = {
...(workspace.aiSettingsByAgent ?? {}),
[snapshot.agentId]: {
model: bucketModel,
thinkingLevel: snapshot.thinkingLevel,
reasoningMode: snapshot.reasoningMode,
// The snapshot has no Auto choices; the child's composer keeps them for its next sends.
...(previous?.autoModelRouting === true ? { autoModelRouting: true } : {}),
...(previous?.autoThinkingLevel === true ? { autoThinkingLevel: true } : {}),
},
};
workspace.taskModelString = snapshot.taskModelString;
Expand Down
68 changes: 67 additions & 1 deletion src/node/services/workspaceService.aiSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ describe("WorkspaceService sendMessage AI settings persistence", () => {
agentId,
model: "openai:gpt-5.2",
thinkingLevel: "high",
autoModelRouting: true,
autoThinkingLevel: true,
...(skip ? { skipAiSettingsPersistence: true } : {}),
},
synthetic ? { synthetic: true } : undefined
Expand Down Expand Up @@ -114,7 +116,12 @@ describe("WorkspaceService sendMessage AI settings persistence", () => {
aiSettings: remembered.aiSettings,
aiSettingsByAgent: {
...remembered.aiSettingsByAgent,
[agentId]: { model: "openai:gpt-5.2", thinkingLevel: "high" },
[agentId]: {
model: "openai:gpt-5.2",
thinkingLevel: "high",
autoModelRouting: true,
autoThinkingLevel: true,
},
},
}
);
Expand Down Expand Up @@ -492,6 +499,65 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => {
});
});

const saveWithAutoChoices = () =>
saveWorkspaces(harness.config, projectPath, [
{
id: "ws",
path: workspacePath,
name: "ws",
aiSettingsByAgent: {
exec: {
model: "openai:gpt-4o-mini",
thinkingLevel: "off",
autoModelRouting: true,
autoThinkingLevel: true,
},
},
},
]);

test.each([
[{}, {}],
// A plan handoff runs its send without Auto but keeps the agent's Auto choices.
[{ savedAutoRouting: { model: true, thinkingLevel: false } }, { autoModelRouting: true }],
[{ autoModelRouting: true, savedAutoRouting: { model: false, thinkingLevel: false } }, {}],
])("a send saves the Auto choices it carries: %j", async (sendFlags, savedFlags) => {
await saveWithAutoChoices();
const svc = workspaceService as unknown as {
maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise<void>;
};

await svc.maybePersistAISettingsFromOptions("ws", {
agentId: "exec",
model: "openai:gpt-4o-mini",
thinkingLevel: "off",
...sendFlags,
});

expect(readEntry()?.aiSettingsByAgent?.exec).toEqual({
model: "openai:gpt-4o-mini",
thinkingLevel: "off",
...savedFlags,
});
});

test("a picker update keeps the saved Auto choices", async () => {
await saveWithAutoChoices();

const result = await workspaceService.updateAgentAISettings("ws", "exec", {
model: "openai:gpt-5.2",
thinkingLevel: "high",
});

expect(result.success).toBe(true);
expect(readEntry()?.aiSettingsByAgent?.exec).toEqual({
model: "openai:gpt-5.2",
thinkingLevel: "high",
autoModelRouting: true,
autoThinkingLevel: true,
});
});

test("persists AI settings for sub-agent workspaces so auto-resume can use latest model", async () => {
interface WorkspaceServiceTestAccess {
maybePersistAISettingsFromOptions: (workspaceId: string, options: unknown) => Promise<void>;
Expand Down
20 changes: 19 additions & 1 deletion src/node/services/workspaceService.fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,10 +1059,22 @@ describe("WorkspaceService fork", () => {
generateStableIdSpy.mockRestore();
}
});
test("fork marks the new workspace as pending auto-title when a continue message is queued", async () => {
test("fork keeps the parent's AI selection and marks the new workspace as pending auto-title when a continue message is queued", async () => {
const sourceWorkspaceId = "source-workspace";
const newWorkspaceId = "forked-workspace";
const sourceProjectPath = path.join(tempDir, "project");
const sourceAISelection = {
agentId: "plan",
aiSettings: { model: "anthropic:claude-sonnet-4-5", thinkingLevel: "medium" as const },
aiSettingsByAgent: {
plan: {
model: "openai:gpt-5.2",
thinkingLevel: "high" as const,
autoModelRouting: true,
autoThinkingLevel: true,
},
},
};
const sourceMetadata: FrontendWorkspaceMetadata = {
id: sourceWorkspaceId,
name: "source-branch",
Expand All @@ -1071,6 +1083,7 @@ describe("WorkspaceService fork", () => {
projectName: "project",
runtimeConfig: { type: "local" },
namedWorkspacePath: path.join(sourceProjectPath, "source-branch"),
...sourceAISelection,
};
const forkedWorkspacePath = path.join(sourceProjectPath, "source-branch-1");

Expand Down Expand Up @@ -1135,6 +1148,11 @@ describe("WorkspaceService fork", () => {
(metadata) => metadata.id === newWorkspaceId
);
expect(persistedMetadata?.pendingAutoTitle).toBe(true);
expect({
agentId: persistedMetadata?.agentId,
aiSettings: persistedMetadata?.aiSettings,
aiSettingsByAgent: persistedMetadata?.aiSettingsByAgent,
}).toEqual(sourceAISelection);
} finally {
orchestrateForkSpy.mockRestore();
copyPlanSpy.mockRestore();
Expand Down
Loading
Loading