diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index 37f41e7ef4..24b4fe579d 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -99,7 +99,7 @@ vi.mock("../opencode/openCodeBinaryManager", () => ({ })); import { createDynamicCursorCliModelDescriptor, getLocalProviderDefaultEndpoint } from "../../../shared/modelRegistry"; -import { createAiIntegrationService } from "./aiIntegrationService"; +import { createAiIntegrationService, missingFeatureModelMessage } from "./aiIntegrationService"; type ServiceFactoryOptions = { aiConfig?: Record; @@ -354,9 +354,12 @@ describe("aiIntegrationService", () => { })); }); - it("resolves a default task model when model is omitted", async () => { + it("uses the feature model override when executeTask omits model", async () => { const { service } = makeService({ - aiConfig: { features: { orchestrator: true } }, + aiConfig: { + features: { orchestrator: true }, + featureModelOverrides: { orchestrator: "openai/gpt-5.4" }, + }, }); await service.executeTask({ @@ -368,7 +371,42 @@ describe("aiIntegrationService", () => { expect(mockState.runProviderTask).toHaveBeenCalledTimes(1); const firstCall = mockState.runProviderTask.mock.calls[0]?.[0] as Record; - expect(firstCall.descriptor).toMatchObject({ id: expect.any(String) }); + expect(firstCall.descriptor).toMatchObject({ id: "openai/gpt-5.4" }); + }); + + it("skips AI instead of picking a default model when no setting is configured", async () => { + const { service } = makeService({ + aiConfig: { features: { orchestrator: true } }, + }); + + await expect( + service.executeTask({ + feature: "orchestrator", + taskType: "implementation", + prompt: "Implement feature", + cwd: "/tmp" + }) + ).rejects.toThrow(missingFeatureModelMessage("orchestrator")); + expect(mockState.runProviderTask).not.toHaveBeenCalled(); + }); + + it("requires an explicit model for session intelligence tasks", async () => { + const { service } = makeService({ + aiConfig: { + features: { terminal_summaries: true }, + featureModelOverrides: { terminal_summaries: "openai/gpt-5.4" }, + }, + }); + + await expect( + service.executeTask({ + feature: "terminal_summaries", + taskType: "session_title", + prompt: "Title this chat", + cwd: "/tmp", + }) + ).rejects.toThrow(/Session intelligence task 'session_title' requires an explicit model/); + expect(mockState.runProviderTask).not.toHaveBeenCalled(); }); it("fails in guest mode when no providers are available", async () => { diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 80f2535ec1..9ba6d5ca99 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -25,7 +25,6 @@ import type { import { decodeOpenCodeRegistryId, replaceDynamicPiModelDescriptors, - getDefaultModelDescriptor, getModelById, getAvailableModels, getLocalProviderDefaultEndpoint, @@ -215,11 +214,6 @@ export type ExecuteAiTaskResult = { durationMs: number; }; -type RuntimeTaskDefaults = { - modelId: string; - timeoutMs: number; -}; - function readCursorSdkAgentName(agent: object): string { if (!("name" in agent)) return ""; const name: unknown = (agent as { name: unknown }).name; @@ -236,67 +230,49 @@ const DEFAULT_AI_FEATURE_FLAGS: Record = { initial_context: true, }; -const DEFAULT_CLAUDE_TASK_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-5"; -const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.6-sol"; - -const TASK_DEFAULTS: Record = { - planning: { - modelId: DEFAULT_CLAUDE_TASK_MODEL_ID, - timeoutMs: 45_000 - }, - implementation: { - modelId: DEFAULT_CODEX_TASK_MODEL_ID, - timeoutMs: 120_000 - }, - review: { - modelId: DEFAULT_CLAUDE_TASK_MODEL_ID, - timeoutMs: 30_000 - }, - conflict_resolution: { - modelId: DEFAULT_CLAUDE_TASK_MODEL_ID, - timeoutMs: 60_000 - }, - commit_message: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 20_000 - }, - narrative: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 45_000 - }, - pr_description: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 30_000 - }, - terminal_summary: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 20_000 - }, - session_title: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 20_000 - }, - session_summary: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 45_000 - }, - handoff_summary: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 45_000 - }, - continuity_summary: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 45_000 - }, - context_compaction: { - modelId: "anthropic/claude-haiku-4-5", - timeoutMs: 120_000 - }, - initial_context: { - modelId: DEFAULT_CLAUDE_TASK_MODEL_ID, - timeoutMs: 120_000 +const SESSION_INTELLIGENCE_TASK_TYPES: ReadonlySet = new Set([ + "session_title", + "session_summary", + "handoff_summary", + "continuity_summary", +]); + +/** These one-shots must receive a caller-chosen model. They never read feature pickers. */ +const EXPLICIT_MODEL_ONLY_TASK_TYPES: ReadonlySet = new Set([ + ...SESSION_INTELLIGENCE_TASK_TYPES, + "context_compaction", +]); + +export function readConfiguredFeatureModel(aiConfig: unknown, feature: AiFeatureKey): string | null { + if (!isRecord(aiConfig)) return null; + const overrides = isRecord(aiConfig.featureModelOverrides) ? aiConfig.featureModelOverrides : {}; + const raw = overrides[feature]; + const modelId = typeof raw === "string" ? raw.trim() : ""; + return modelId.length ? modelId : null; +} + +export function missingFeatureModelMessage(feature: AiFeatureKey): string { + switch (feature) { + case "commit_messages": + return "Choose a Commit Messages model in Settings or type a commit message manually."; + case "pr_descriptions": + return "Choose a PR Descriptions model in Settings or write the description manually."; + case "terminal_summaries": + return "Choose a Summaries model in Settings."; + case "conflict_proposals": + return "Choose a Conflict Proposals model in Settings."; + case "narratives": + return "Choose a Narratives model in Settings."; + case "orchestrator": + return "Choose an Orchestrator model in Settings."; + case "initial_context": + return "Choose an Initial Context model in Settings."; + default: { + const _exhaustive: never = feature; + return _exhaustive; + } } -}; +} const CODEX_FALLBACK_MODELS: AgentModelDescriptor[] = listModelDescriptorsForProvider("codex") .map((descriptor) => ({ id: descriptor.id, label: descriptor.displayName })); @@ -1509,11 +1485,15 @@ export function createAiIntegrationService(args: { ); }; - const resolveModelForTask = async ( + const getConfiguredFeatureModel = (feature: AiFeatureKey): string | null => { + return readConfiguredFeatureModel(extractAiConfig(projectConfigService.get()), feature); + }; + + const resolveModelForTask = ( taskType: AiTaskType, modelIdHint?: string, - authHint?: DetectedAuth[], - ): Promise => { + feature?: AiFeatureKey, + ): string => { const snapshot = projectConfigService.get(); const aiConfig = extractAiConfig(snapshot); const taskRouting = isRecord(aiConfig.taskRouting) ? aiConfig.taskRouting : {}; @@ -1521,37 +1501,26 @@ export function createAiIntegrationService(args: { const overrideModelId = toStringOrNull(taskOverride.model); const requestedModelHint = modelIdHint ?? overrideModelId ?? undefined; - // If explicit model ID provided and valid, use it if (requestedModelHint) { const exact = getModelById(requestedModelHint); if (exact) return exact.id; - } - - // Resolve from alias (e.g. "sonnet" -> "anthropic/claude-sonnet-5") - if (requestedModelHint) { const resolved = resolveModelAlias(requestedModelHint); if (resolved) return resolved.id; + throw new Error(`Unknown model '${requestedModelHint}'.`); } - // Check task defaults and map provider family to model ID. - const defaults = TASK_DEFAULTS[taskType]; - const auth = authHint ?? await detectAuth(); - const available = getAvailableModels(auth); - - if (!available.length) { - throw new Error("No AI providers detected. Install Claude Code CLI, Codex CLI, or configure an API key."); + if (SESSION_INTELLIGENCE_TASK_TYPES.has(taskType)) { + throw new Error(`Session intelligence task '${taskType}' requires an explicit model.`); } - - const preferredDescriptor = getModelById(defaults.modelId) ?? resolveModelAlias(defaults.modelId); - if (preferredDescriptor) { - const exactMatch = available.find((candidate) => candidate.id === preferredDescriptor.id || candidate.shortId === preferredDescriptor.shortId); - if (exactMatch) return exactMatch.id; - const familyMatch = available.find((candidate) => candidate.family === preferredDescriptor.family); - if (familyMatch) return familyMatch.id; + if (taskType === "context_compaction") { + throw new Error("Context compaction requires an explicit model."); } - // Fall back to first available - return available[0].id; + throw new Error( + feature + ? missingFeatureModelMessage(feature) + : "Choose a model in Settings before running this AI task.", + ); }; const executeProviderTaskPath = async ( @@ -1632,7 +1601,10 @@ export function createAiIntegrationService(args: { } checkBudget(args.feature); - const requestedModel = toStringOrNull(args.model); + const featureModel = EXPLICIT_MODEL_ONLY_TASK_TYPES.has(args.taskType) + ? null + : getConfiguredFeatureModel(args.feature); + const requestedModel = toStringOrNull(args.model) ?? featureModel; const explicitDescriptor = requestedModel ? (getModelById(requestedModel) ?? resolveModelAlias(requestedModel)) : null; @@ -1640,7 +1612,8 @@ export function createAiIntegrationService(args: { throw new Error(`Unknown model '${requestedModel}'.`); } - const resolvedModelId = explicitDescriptor?.id ?? await resolveModelForTask(args.taskType, requestedModel ?? undefined, auth); + const resolvedModelId = explicitDescriptor?.id + ?? resolveModelForTask(args.taskType, requestedModel ?? undefined, args.feature); logger.info("ai.task.begin", { requestId, taskType: args.taskType, @@ -2089,6 +2062,7 @@ export function createAiIntegrationService(args: { getAvailabilityAsync, resolveModelForTask, + getConfiguredFeatureModel, invalidateProviderReadinessCaches, // Backward-compatible convenience methods used by migrated services. diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 790a886aea..53d848fdae 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1659,7 +1659,7 @@ function createService(overrides: Record = {}) { const projectConfigService = createMockProjectConfigService(); const aiIntegrationService = { summarizeTerminal: vi.fn(async () => ({ - text: "Generated session intelligence", + text: "", structuredOutput: null, provider: "claude", model: "anthropic/claude-haiku-4-5", @@ -5043,7 +5043,9 @@ describe("createAgentChatService", () => { expect(result.session.provider).toBe("codex"); expect(result.session.threadId).toBe("forked-thread-1"); expect(mockState.sessions.get(result.session.id)?.goal ?? null).toBeNull(); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); const handoffPayloads = mockState.codexRequestPayloads.slice(handoffStart); expect(handoffPayloads).toEqual(expect.arrayContaining([ expect.objectContaining({ @@ -5211,7 +5213,9 @@ describe("createAgentChatService", () => { expect(mockState.openCodeForkCalls.length).toBeGreaterThanOrEqual(1); expect(persisted.providerSessionId).toEqual(expect.stringMatching(/-fork$/)); expect(promptCountAfterFork).toBe(promptCountBeforeFork); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); }); it("forks a Droid chat and resumes the forked session id", async () => { @@ -5242,7 +5246,9 @@ describe("createAgentChatService", () => { expect(result.session.provider).toBe("droid"); expect(sourcePooled.request).toHaveBeenCalledWith("fork_session"); expect(persisted.droidSdkSessionId).toEqual(expect.stringMatching(/^droid-forked-/)); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); }); it("forks a Cursor chat onto a fresh agent replaying the full source transcript", async () => { @@ -5286,7 +5292,9 @@ describe("createAgentChatService", () => { expect(persisted.pendingTranscriptReplay).toContain("Investigate the flaky migration test."); expect(result.replayFork).toBeUndefined(); // No brief was generated — fork carries the conversation, not a summary. - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); }); it("forks a Cursor chat onto another provider by replaying the full transcript", async () => { @@ -5317,7 +5325,9 @@ describe("createAgentChatService", () => { expect(result.session.provider).toBe("codex"); expect(persisted.pendingTranscriptReplay).toContain("Keep the banner aligned with the composer."); expect(persisted.pendingTranscriptReplay).toContain("verbatim replay"); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); }); it("forks a Claude chat onto a Codex model with a full transcript replay", async () => { @@ -5347,7 +5357,9 @@ describe("createAgentChatService", () => { expect(result.replayFork).toBeUndefined(); expect(persisted.pendingTranscriptReplay).toContain("Replay this turn across providers."); expect(persisted.pendingTranscriptReplay).not.toMatch(/This is a brief/i); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); }); it("gives a forked Cursor chat's first send the source conversation as context", async () => { @@ -5938,7 +5950,9 @@ describe("createAgentChatService", () => { expect(result.session.provider).toBe("claude"); expect(result.session.interactionMode).toBe("plan"); expect(result.session.permissionMode).toBe("plan"); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); await vi.waitFor(() => { expect(claudeSdkResumeSessionCompat).toHaveBeenCalledWith( sourceSdkSessionId, @@ -14179,6 +14193,22 @@ describe("createAgentChatService", () => { const { service, sessionService } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { + cli: { mode: "edit" }, + inProcess: { mode: "edit" }, + }, + chat: {}, + sessionIntelligence: { titles: { enabled: false } }, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, }); const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); if (args.manuallyName) { @@ -15511,7 +15541,9 @@ describe("createAgentChatService", () => { await service.sendMessage({ sessionId: session.id, text: "Use runtime title." }, { awaitDispatch: true }); await waitForSessionTitle(sessionService, session.id, "OpenCode Native Title"); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith( + expect.objectContaining({ taskType: "handoff_summary" }), + ); expect(vi.mocked(startOpenCodeSession).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ title: null }), ); @@ -19057,7 +19089,25 @@ describe("createAgentChatService", () => { sdkSessionId: "sdk-legacy-owner-unavailable", responseText: "Done.", }); - const { service, sessionService, logger } = createService({ db: scheduledWork.db }); + const { service, sessionService, logger } = createService({ + db: scheduledWork.db, + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { + cli: { mode: "edit" }, + inProcess: { mode: "edit" }, + }, + chat: {}, + sessionIntelligence: { titles: { enabled: false } }, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, + }); const session = await service.createSession({ laneId: "lane-1", provider: "claude", @@ -19069,10 +19119,15 @@ describe("createAgentChatService", () => { }); await service.dispose({ sessionId: session.id }); const sessionRow = sessionService.get(session.id); - sessionService.get - .mockImplementationOnce(() => sessionRow) - .mockImplementationOnce(() => null) - .mockImplementation((sessionId: string) => sessionId === session.id ? sessionRow : null); + // Probe once for the still-present chat, then fail later lookups so the + // wake cannot proceed. Call-count once() mocks are consumed by unrelated + // session-intelligence reads after a turn. + let cancelLookups = 0; + sessionService.get.mockImplementation((id: string) => { + if (id !== session.id) return null; + cancelLookups += 1; + return cancelLookups === 1 ? sessionRow : null; + }); await expect(service.cancelScheduledWork({ sessionId: session.id, @@ -43379,7 +43434,7 @@ describe("suggestLaneNameFromPrompt", () => { const result = await service.suggestLaneNameFromPrompt({ prompt: "Fix null model clearing for background jobs", - modelId: "", + modelId: "anthropic/claude-sonnet-5", laneId: "lane-1", }); @@ -43387,9 +43442,12 @@ describe("suggestLaneNameFromPrompt", () => { expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith(expect.objectContaining({ model: "openai/gpt-5.4-mini", })); - expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalledWith(expect.objectContaining({ model: "anthropic/claude-haiku-4-5", })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + model: "anthropic/claude-sonnet-5", + })); }); it("normalizes AI-generated name: strips special chars and lowercases", async () => { @@ -43590,6 +43648,7 @@ describe("suggestLaneNameFromPrompt", () => { it("retries the next model when structured fields are unusable, then falls back deterministically", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, ]); const { service, aiIntegrationService } = createSuggestService(); @@ -43609,9 +43668,44 @@ describe("suggestLaneNameFromPrompt", () => { branchFragment: "claude-auth-login-button-hangs", source: "deterministic", }); - // An unusable answer no longer ends the chain: the remaining candidates - // still get a turn before naming settles for the deterministic slug. - expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(3); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + model: "openai/gpt-5.4", + })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(1); + }); + + it("uses the launched chat model when the title setting answers unusably", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService({ titleModelId: "openai/gpt-5.4-mini" }); + vi.mocked(aiIntegrationService.summarizeTerminal) + .mockResolvedValueOnce({ + text: JSON.stringify({ laneTitle: "Fix", branchFragment: "refs/heads/NOPE" }), + } as any) + .mockResolvedValueOnce({ + text: JSON.stringify({ laneTitle: "Claude OAuth Login", branchFragment: "claude-oauth-login" }), + } as any); + + const result = await service.generateAutoLaneIdentity({ + prompt: "The Claude auth login button hangs after OAuth redirects.", + modelId: "openai/gpt-5.4", + laneId: "lane-1", + temporaryBranch: "ade/1a2b3c4d", + }); + + expect(result).toMatchObject({ + laneTitle: "Claude OAuth Login", + branchFragment: "claude-oauth-login", + source: "ai", + }); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ + model: "openai/gpt-5.4-mini", + })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(2, expect.objectContaining({ + model: "openai/gpt-5.4", + })); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(2); }); it("uses the configured naming model before the launched model", async () => { @@ -43635,7 +43729,7 @@ describe("suggestLaneNameFromPrompt", () => { })); }); - it("uses the default title model before the launched chat model", async () => { + it("uses the launched chat model when no title model is configured", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, @@ -43654,7 +43748,7 @@ describe("suggestLaneNameFromPrompt", () => { }); expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ - model: "anthropic/claude-haiku-4-5", + model: "openai/gpt-5.4", })); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 26fec5ddee..f34d4a3df4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -195,7 +195,7 @@ import { import { runGit } from "../git/git"; import { CLAUDE_RUNTIME_AUTH_ERROR, isClaudeRuntimeAuthError } from "../ai/claudeRuntimeProbe"; import { resolveCodexExecutable } from "../ai/codexExecutable"; -import { withTimeout } from "../ai/utils"; +import { parseStructuredOutput, withTimeout } from "../ai/utils"; import { fileSizeOrZero, hasNullByte, @@ -660,7 +660,7 @@ import { import { AUTO_LANE_IDENTITY_JSON_SCHEMA, AUTO_TITLE_SYSTEM_PROMPT, - buildNamingModelCandidates, + buildSessionIntelligenceModelCandidates, LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, LEGACY_LANE_NAME_SYSTEM_PROMPT, MAX_NAMING_WORDS, @@ -3410,7 +3410,6 @@ const DEFAULT_OPENCODE_MODEL_ID = DEFAULT_OPENCODE_DESCRIPTOR?.id ?? "anthropic/ const DEFAULT_CURSOR_MODEL = DEFAULT_CURSOR_DESCRIPTOR?.providerModelId ?? "auto"; const DEFAULT_DROID_MODEL = DEFAULT_DROID_DESCRIPTOR?.providerModelId ?? "claude-sonnet-4-5-20250929"; const DEFAULT_REASONING_EFFORT = "medium"; -const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5"; const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024; const CLAUDE_TOOL_OUTPUT_TRIM_THRESHOLD_BYTES = 200 * 1024; @@ -5056,26 +5055,22 @@ function normalizeSuggestedLaneTitle(raw: string): string | null { return words.length > MAX_NAMING_WORDS ? words.slice(0, MAX_NAMING_WORDS).join(" ") : title; } -function parseAutoLaneIdentity(raw: string): { laneTitle: string | null; branchFragment: string | null } | null { - try { - const parsed = JSON.parse(raw.trim()) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - const record = parsed as Record; - if (Object.keys(record).some((key) => key !== "laneTitle" && key !== "branchFragment")) return null; - const branchRaw = typeof record.branchFragment === "string" ? record.branchFragment.trim() : ""; - const branchWords = branchRaw.split("-").filter(Boolean); - // Same guideline-not-gate rule as the title: an over-long fragment is - // clamped to the first words rather than thrown away. - const branchFragment = branchWords.length >= 2 && /^[a-z0-9]+(?:-[a-z0-9]+)+$/u.test(branchRaw) - ? normalizeSuggestedLaneName(branchWords.slice(0, MAX_NAMING_WORDS).join("-")) - : null; - return { - laneTitle: typeof record.laneTitle === "string" ? normalizeSuggestedLaneTitle(record.laneTitle) : null, - branchFragment, - }; - } catch { - return null; - } +function parseAutoLaneIdentity(raw: unknown): { laneTitle: string | null; branchFragment: string | null } | null { + const value = typeof raw === "string" ? parseStructuredOutput(raw) : raw; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const branchRaw = typeof record.branchFragment === "string" ? record.branchFragment.trim() : ""; + const branchWords = branchRaw.split("-").filter(Boolean); + // Same guideline-not-gate rule as the title: an over-long fragment is + // clamped to the first words rather than thrown away. Extra JSON keys are + // ignored: Grok and other non-schema models often annotate the object. + const branchFragment = branchWords.length >= 2 && /^[a-z0-9]+(?:-[a-z0-9]+)+$/u.test(branchRaw) + ? normalizeSuggestedLaneName(branchWords.slice(0, MAX_NAMING_WORDS).join("-")) + : null; + return { + laneTitle: typeof record.laneTitle === "string" ? normalizeSuggestedLaneTitle(record.laneTitle) : null, + branchFragment, + }; } function defaultChatSessionTitle(provider: AgentChatProvider): string { @@ -11220,24 +11215,13 @@ export function createAgentChatService(args: { const auth = await detectAuth().catch(() => []); const availableModels = await getAvailableRegistryModels(auth); - if (!availableModels.length) return; - - const preferredModelId = - [ - resolveChatConfig().summaryModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - "anthropic/claude-haiku-4-5", - "openai/gpt-5.4-mini", - "openai/gpt-5.2", - availableModels[0]?.id, - ].find((candidate) => { - const modelId = typeof candidate === "string" ? candidate.trim() : ""; - return modelId.length > 0 && availableModels.some((descriptor) => descriptor.id === modelId); - }) ?? null; - - if (!preferredModelId) return; - const descriptor = getModelById(preferredModelId); - if (!descriptor) return; + const candidateModelIds = buildSessionIntelligenceModelCandidates({ + availableModels, + settingModelId: resolveChatConfig().summaryModelId, + sessionModelId: managed.session.modelId, + sessionModel: managed.session.model, + }); + if (!candidateModelIds.length) return; const prompt = [ "You are ADE's continuity compaction assistant.", @@ -11252,26 +11236,32 @@ export function createAgentChatService(args: { managed.continuitySummaryInFlight = true; try { - const result = await runSessionIntelligencePrompt({ - cwd: managed.laneWorktreePath, - modelId: descriptor.id, - prompt, - taskType: "continuity_summary", + const { result } = await runNamingAcrossProviders(candidateModelIds, { + run: async (descriptor) => { + const response = await runSessionIntelligencePrompt({ + cwd: managed.laneWorktreePath, + modelId: descriptor.id, + prompt, + taskType: "continuity_summary", + }); + const text = response.text.trim(); + return text.length ? text : null; + }, + onFailure: (failure) => { + logger.warn("agent_chat.identity_continuity_summary_failed", { + sessionId: managed.session.id, + reason, + modelId: failure.descriptor.id, + error: failure.error instanceof Error ? failure.error.message : String(failure.error), + }); + }, }); - const text = result.text.trim(); - if (text.length) { - managed.continuitySummary = text; + if (result) { + managed.continuitySummary = result; managed.continuitySummaryUpdatedAt = nowIso(); persistChatState(managed); - writeCtoThreadStateFromSummary(managed, text, reason); + writeCtoThreadStateFromSummary(managed, result, reason); } - } catch (error) { - logger.warn("agent_chat.identity_continuity_summary_failed", { - sessionId: managed.session.id, - reason, - modelId: descriptor.id, - error: error instanceof Error ? error.message : String(error), - }); } finally { managed.continuitySummaryInFlight = false; } @@ -11630,25 +11620,12 @@ export function createAgentChatService(args: { const deterministicBrief = buildDeterministicHandoffBrief(args); const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); - const preferredModelId = [ - resolveChatConfig().summaryModelId, - "openai/gpt-5.4-mini", - "openai/gpt-5.2", - DEFAULT_AUTO_TITLE_MODEL_ID, - availableModels[0]?.id, - ].find((candidate) => { - const modelId = typeof candidate === "string" ? candidate.trim() : ""; - return modelId.length > 0 && availableModels.some((descriptor) => descriptor.id === modelId); - }) ?? null; - - if (!preferredModelId) { - return { brief: deterministicBrief, usedFallbackSummary: true }; - } - - const descriptor = getModelById(preferredModelId); - if (!descriptor) { - return { brief: deterministicBrief, usedFallbackSummary: true }; - } + const candidateModelIds = buildSessionIntelligenceModelCandidates({ + availableModels, + settingModelId: resolveChatConfig().summaryModelId, + sessionModelId: args.managed.session.modelId, + sessionModel: args.managed.session.model, + }); const transcriptText = args.transcript.entries.map((entry) => { const speaker = entry.role === "user" ? "User" : "Assistant"; @@ -11680,26 +11657,33 @@ export function createAgentChatService(args: { deterministicBrief, ].filter(Boolean).join("\n"); - try { - const result = await runSessionIntelligencePrompt({ - cwd: args.managed.laneWorktreePath, - modelId: descriptor.id, - prompt, - taskType: "handoff_summary", - }); - const brief = result.text.trim(); - if (!brief.length) { - return { brief: deterministicBrief, usedFallbackSummary: true }; - } - return { brief, usedFallbackSummary: false }; - } catch (error) { - logger.warn("agent_chat.handoff_summary_failed", { - sessionId: args.managed.session.id, - modelId: descriptor.id, - error: error instanceof Error ? error.message : String(error), - }); + if (!candidateModelIds.length) { + return { brief: deterministicBrief, usedFallbackSummary: true }; + } + + const { result } = await runNamingAcrossProviders(candidateModelIds, { + run: async (descriptor) => { + const response = await runSessionIntelligencePrompt({ + cwd: args.managed.laneWorktreePath, + modelId: descriptor.id, + prompt, + taskType: "handoff_summary", + }); + const brief = response.text.trim(); + return brief.length ? brief : null; + }, + onFailure: (failure) => { + logger.warn("agent_chat.handoff_summary_failed", { + sessionId: args.managed.session.id, + modelId: failure.descriptor.id, + error: failure.error instanceof Error ? failure.error.message : String(failure.error), + }); + }, + }); + if (!result) { return { brief: deterministicBrief, usedFallbackSummary: true }; } + return { brief: result, usedFallbackSummary: false }; }; const buildHandoffPrompt = (brief: string, handoffNote: string | null = null): string => { @@ -11945,23 +11929,12 @@ export function createAgentChatService(args: { const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); - if (!availableModels.length) return; - - // Same chain as automatic lane naming: preferred title models -> the model - // this chat was launched with -> a different provider -> deterministic. One - // provider being down (auth, missing binary, account-rejected model) must - // not leave the chat sitting on its provider default title. - const candidateModelIds = buildNamingModelCandidates({ + const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - preferred: [ - config.titleModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - managed.session.modelId, - managed.session.model, - availableModels[0]?.id, - ], + settingModelId: config.titleModelId, + sessionModelId: managed.session.modelId, + sessionModel: managed.session.model, }); - if (!candidateModelIds.length) return; const laneName = sessionService.get(managed.session.id)?.laneName ?? "Current lane"; const currentTitle = sessionService.get(managed.session.id)?.title ?? null; @@ -11983,39 +11956,40 @@ export function createAgentChatService(args: { try { // A model that answers unusably (a rejected or empty title) returns null // so the next candidate still gets a turn — a working model beats a slug. + // An empty candidate list is a no-op walk and falls through to deterministic. const { result: adopted, attemptCount } = await runNamingAcrossProviders(candidateModelIds, { - shouldStop: () => sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted, - run: async (descriptor) => { - const result = await runSessionIntelligencePrompt({ - cwd: managed.laneWorktreePath, - modelId: descriptor.id, - systemPrompt: AUTO_TITLE_SYSTEM_PROMPT, - prompt: [ - args.stage === "final" - ? "Write a final concise title for this completed coding chat." - : "Write a concise title for this new coding chat.", - titleContext.join("\n"), - ].join("\n\n"), - taskType: "session_title", - }); - // Guard BEFORE the write: setManagedSessionTitle has side effects - // (session meta, runtime push), so a manual rename that landed while - // this request was in flight must stop it here, not after. - if (sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted) return null; - return setManagedSessionTitle(managed, result.text); - }, - onFailure: ({ descriptor, provider, providerLevelFailure, attemptCount, error }) => { - logger.warn("agent_chat.auto_title_failed", { - sessionId: managed.session.id, - stage: args.stage, - modelId: descriptor.id, - provider, - providerLevelFailure, - attemptCount, - error: error instanceof Error ? error.message : String(error), - }); - }, - }); + shouldStop: () => sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted, + run: async (descriptor) => { + const result = await runSessionIntelligencePrompt({ + cwd: managed.laneWorktreePath, + modelId: descriptor.id, + systemPrompt: AUTO_TITLE_SYSTEM_PROMPT, + prompt: [ + args.stage === "final" + ? "Write a final concise title for this completed coding chat." + : "Write a concise title for this new coding chat.", + titleContext.join("\n"), + ].join("\n\n"), + taskType: "session_title", + }); + // Guard BEFORE the write: setManagedSessionTitle has side effects + // (session meta, runtime push), so a manual rename that landed while + // this request was in flight must stop it here, not after. + if (sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted) return null; + return setManagedSessionTitle(managed, result.text); + }, + onFailure: ({ descriptor, provider, providerLevelFailure, attemptCount: currentAttempt, error }) => { + logger.warn("agent_chat.auto_title_failed", { + sessionId: managed.session.id, + stage: args.stage, + modelId: descriptor.id, + provider, + providerLevelFailure, + attemptCount: currentAttempt, + error: error instanceof Error ? error.message : String(error), + }); + }, + }); if (adopted) { managed.autoTitleStage = args.stage; return; @@ -12069,28 +12043,11 @@ export function createAgentChatService(args: { const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); const config = resolveChatConfig(); - // An existing OpenCode chat can outlive the inventory snapshot that was - // available when it was created. Keep its selected model eligible for an - // explicit refresh, but only if the registry resolves it to this chat's - // runtime provider. This preserves provider isolation without making a - // valid dynamic OpenCode model disappear from the action. - const sessionModelDescriptor = [managed.session.modelId, managed.session.model] - .map((modelRef) => typeof modelRef === "string" && modelRef.trim().length ? getModelById(modelRef) : undefined) - .find((descriptor) => descriptor && resolveProviderGroupForModel(descriptor) === managed.session.provider); - const metadataModels = sessionModelDescriptor && !availableModels.some((descriptor) => descriptor.id === sessionModelDescriptor.id) - ? [...availableModels, sessionModelDescriptor] - : availableModels; - return buildNamingModelCandidates({ - availableModels: metadataModels, - provider: managed.session.provider, - preferred: [ - config.titleModelId, - managed.session.modelId, - managed.session.model, - DEFAULT_AUTO_TITLE_MODEL_ID, - availableModels.find((descriptor) => - resolveProviderGroupForModel(descriptor) === managed.session.provider)?.id, - ], + return buildSessionIntelligenceModelCandidates({ + availableModels, + settingModelId: config.titleModelId, + sessionModelId: managed.session.modelId, + sessionModel: managed.session.model, }); }, buildRecentConversationContext: (managed, limit) => @@ -12834,20 +12791,11 @@ export function createAgentChatService(args: { if (config.titleGenerationEnabled !== false) { const auth = await detectAuth(); const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); - // Same chain as chat auto-title: configured naming model -> default - // title model (haiku) -> launched chat model -> requested model -> - // first available -> deterministic. Haiku stays ahead of the chat - // model so a JSON miss on the configured namer does not send lane - // identity to grok while chat titles still use haiku. - const candidateModelIds = buildNamingModelCandidates({ + const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - preferred: [ - config.titleModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - chatModelId, - requestedModelId, - availableModels[0]?.id, - ], + settingModelId: config.titleModelId, + sessionModelId: chatModelId, + sessionModel: requestedModelId, }); // Naming runs in the background, but it still must not walk the whole @@ -12864,7 +12812,7 @@ export function createAgentChatService(args: { jsonSchema: AUTO_LANE_IDENTITY_JSON_SCHEMA, taskType: "session_title", }); - const parsed = parseAutoLaneIdentity(result.text); + const parsed = parseAutoLaneIdentity(result.structuredOutput ?? result.text); if (!parsed || (!parsed.laneTitle && !parsed.branchFragment)) return null; return resolveCoherentAutoLaneIdentity(parsed, fallback); }, @@ -12940,14 +12888,10 @@ export function createAgentChatService(args: { if (config.titleGenerationEnabled === false) return fallback(); const auth = await detectAuth(); const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); - const candidateModelIds = buildNamingModelCandidates({ + const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - preferred: [ - config.titleModelId, - requestedModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - availableModels[0]?.id, - ], + settingModelId: config.titleModelId, + sessionModelId: requestedModelId, }); const { result: suggested } = await runNamingAcrossProviders(candidateModelIds, { run: async (descriptor) => { @@ -18110,24 +18054,13 @@ export function createAgentChatService(args: { // Fire-and-forget AI summary enhancement const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); - if (!availableModels.length) return; - - const preferredModelId = - [ - config.summaryModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - "anthropic/claude-haiku-4-5", - "openai/gpt-5.4-mini", - "openai/gpt-5.2", - availableModels[0]?.id, - ].find((candidate) => { - const modelId = typeof candidate === "string" ? candidate.trim() : ""; - return modelId.length > 0 && availableModels.some((d) => d.id === modelId); - }) ?? null; - - if (!preferredModelId) return; - const descriptor = getModelById(preferredModelId); - if (!descriptor) return; + const candidateModelIds = buildSessionIntelligenceModelCandidates({ + availableModels, + settingModelId: config.summaryModelId, + sessionModelId: managed.session.modelId, + sessionModel: managed.session.model, + }); + if (!candidateModelIds.length) return; const baseSummary = session.summary ?? deterministicText ?? ""; const userRequest = managed.autoTitleSeed?.trim() ?? ""; @@ -18145,22 +18078,28 @@ export function createAgentChatService(args: { managed.summaryInFlight = true; try { - const result = await runSessionIntelligencePrompt({ - cwd: managed.laneWorktreePath, - modelId: descriptor.id, - prompt, - taskType: "session_summary", + const { result } = await runNamingAcrossProviders(candidateModelIds, { + run: async (descriptor) => { + const response = await runSessionIntelligencePrompt({ + cwd: managed.laneWorktreePath, + modelId: descriptor.id, + prompt, + taskType: "session_summary", + }); + const text = response.text.trim(); + return text.length ? text : null; + }, + onFailure: (failure) => { + logger.warn("agent_chat.session_summary_failed", { + sessionId: managed.session.id, + modelId: failure.descriptor.id, + error: failure.error instanceof Error ? failure.error.message : String(failure.error), + }); + }, }); - const text = result.text.trim(); - if (text.length) { - sessionService.setSummary(managed.session.id, text); + if (result) { + sessionService.setSummary(managed.session.id, result); } - } catch (error) { - logger.warn("agent_chat.session_summary_failed", { - sessionId: managed.session.id, - modelId: descriptor.id, - error: error instanceof Error ? error.message : String(error), - }); } finally { managed.summaryInFlight = false; } diff --git a/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts b/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts new file mode 100644 index 0000000000..42231c559f --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionMetadataService.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentChatSession } from "../../../shared/types/chat"; +import { getAvailableModels } from "../../../shared/modelRegistry"; +import type { Logger } from "../logging/logger"; +import { createSessionMetadataRegenerator, type SessionMetadataManagedSession } from "./sessionMetadataService"; +import type { SessionMetadataPromptRunner } from "./sessionNaming"; + +const ANTHROPIC_MODELS = getAvailableModels([ + { type: "cli-subscription", cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, +] as never).filter((descriptor) => descriptor.id.startsWith("anthropic/") && !descriptor.deprecated); + +const normalizeTitle = (value: string): string | null => { + const words = value.trim().split(/\s+/u).filter(Boolean); + return words.length >= 2 ? words.slice(0, 6).join(" ") : null; +}; +const normalizeStatusLine = (value: string): string | null => { + const summary = value.trim().replace(/\s+/g, " "); + return summary.length ? summary.slice(0, 72) : null; +}; + +function createHarness(args?: { + runPrompt?: SessionMetadataPromptRunner; + summary?: string | null; + autoTitleSeed?: string | null; + preview?: string | null; + resolveModelCandidates?: () => Promise; +}) { + const managed = { + session: { + id: "sess-1", + provider: "cursor", + modelId: "cursor/grok-4.6", + model: "grok-4.6", + laneId: "lane-1", + goal: null, + } as AgentChatSession, + laneWorktreePath: "/tmp/lane", + preview: args?.preview ?? null, + autoTitleSeed: args?.autoTitleSeed ?? "start skill using aws other", + deleted: false, + sessionMetadataGenerationVersion: 0, + sessionMetadataTitleRevision: 0, + } satisfies SessionMetadataManagedSession; + const sessionRow = { + title: "Start Skill Using Aws Other", + laneName: "Start Skill Using Aws Other", + statusNote: null as string | null, + lastOutputPreview: args?.preview ?? null, + summary: args?.summary ?? null, + }; + const applyTitle = vi.fn(async (_managed: typeof managed, title: string) => title); + const setStatusNote = vi.fn(() => { + sessionRow.statusNote = "applied"; + return true; + }); + const renameLane = vi.fn(); + const runPrompt = vi.fn, ReturnType>( + args?.runPrompt ?? (async () => ({ + text: JSON.stringify({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + }), + })), + ); + const logger = { info: vi.fn(), warn: vi.fn() } as unknown as Logger; + const regenerate = createSessionMetadataRegenerator({ + ensureManagedSession: () => managed, + getSession: () => sessionRow, + getLaneSummary: async () => ({ name: sessionRow.laneName }), + resolveModelCandidates: args?.resolveModelCandidates ?? (async () => [ANTHROPIC_MODELS[0]!.id]), + buildRecentConversationContext: () => "", + runPrompt, + normalizeTitle, + normalizeStatusLine, + applyTitle, + setStatusNote, + renameLane, + persistChatState: vi.fn(), + logger, + }); + return { regenerate, managed, sessionRow, applyTitle, setStatusNote, renameLane, runPrompt }; +} + +describe("createSessionMetadataRegenerator", () => { + it("applies metadata when the model wraps JSON and adds extra keys", async () => { + const { regenerate, applyTitle, setStatusNote, renameLane } = createHarness({ + runPrompt: vi.fn(async () => ({ + text: [ + "Sure — here you go:", + "```json", + JSON.stringify({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + commentary: "extra grok field", + }), + "```", + ].join("\n"), + structuredOutput: { + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + commentary: "extra grok field", + }, + })), + }); + + await expect(regenerate({ sessionId: "sess-1" })).resolves.toMatchObject({ + applied: ["title", "statusLine", "laneName"], + skipped: [], + }); + expect(applyTitle).toHaveBeenCalledWith(expect.anything(), "Wire Rag Search"); + expect(setStatusNote).toHaveBeenCalledWith("sess-1", "Sources show before generate"); + expect(renameLane).toHaveBeenCalledWith({ laneId: "lane-1", name: "Search Answer Path" }); + }); + + it("uses the conversation summary when every model returns unusable JSON", async () => { + const { regenerate, applyTitle, setStatusNote, renameLane } = createHarness({ + summary: "Wired project aiSummary into RAG excerpts so Cmd+K answers from the overview", + runPrompt: vi.fn(async () => ({ text: "I named it. Hope that helps!" })), + }); + + const result = await regenerate({ sessionId: "sess-1" }); + expect(result.applied.length).toBeGreaterThan(0); + expect(applyTitle).toHaveBeenCalled(); + expect(String(applyTitle.mock.calls[0]?.[1])).not.toMatch(/start skill using aws/i); + expect(setStatusNote).toHaveBeenCalled(); + expect(renameLane).toHaveBeenCalled(); + }); + + it("uses deterministic metadata when no naming model is available", async () => { + const { regenerate, applyTitle, setStatusNote, renameLane, runPrompt } = createHarness({ + summary: "Wired project aiSummary into RAG excerpts so Cmd+K answers from the overview", + resolveModelCandidates: async () => [], + }); + + const result = await regenerate({ sessionId: "sess-1" }); + expect(runPrompt).not.toHaveBeenCalled(); + expect(result.applied.length).toBeGreaterThan(0); + expect(applyTitle).toHaveBeenCalled(); + expect(String(applyTitle.mock.calls[0]?.[1])).not.toMatch(/start skill using aws/i); + expect(setStatusNote).toHaveBeenCalled(); + expect(renameLane).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/chat/sessionMetadataService.ts b/apps/desktop/src/main/services/chat/sessionMetadataService.ts index cd85348b5e..a0383a61e8 100644 --- a/apps/desktop/src/main/services/chat/sessionMetadataService.ts +++ b/apps/desktop/src/main/services/chat/sessionMetadataService.ts @@ -7,6 +7,7 @@ import type { import { normalizeAgentChatSessionMetadataFields } from "../../../shared/types/chat"; import { buildSessionMetadataPrompt, + deriveDeterministicSessionMetadata, runSessionMetadataGeneration, type SessionMetadataPromptRunner, } from "./sessionNaming"; @@ -109,10 +110,6 @@ export function createSessionMetadataRegenerator; + selectedModelId: string | null; + attemptCount: number; + } = { result: null, selectedModelId: null, attemptCount: 0 }; + if (candidateModelIds.length) { + const prompt = buildSessionMetadataPrompt({ + provider: managed.session.provider, + chatModel: managed.session.modelId ?? managed.session.model, + currentLaneName: snapshot.laneName, + currentChatTitle: snapshot.title, + currentStatusLine: snapshot.statusLine, + goal: managed.session.goal?.trim().slice(0, 2_000) ?? null, + summary: initialRow.summary?.trim().slice(0, 2_000) ?? null, + latestOutputPreview, + originalRequest: managed.autoTitleSeed?.trim().slice(0, 2_000) ?? null, + recentConversation: recentConversation.slice(-8_000), + }); + generated = await runSessionMetadataGeneration({ + candidateModelIds, + cwd: managed.laneWorktreePath, + prompt, + runPrompt: dependencies.runPrompt, + normalizeTitle: dependencies.normalizeTitle, + normalizeStatusLine: dependencies.normalizeStatusLine, + shouldStop: () => managed.deleted || managed.sessionMetadataGenerationVersion !== generationVersion, + onFailure: ({ descriptor, provider, providerLevelFailure, attemptCount: currentAttempt, error }) => { + dependencies.logger.warn("agent_chat.session_metadata_generation_failed", { + sessionId, + modelId: descriptor.id, + provider, + providerLevelFailure, + attemptCount: currentAttempt, + error: error instanceof Error ? error.message : String(error), + }); + }, + }); + } + selectedModelId = generated.selectedModelId; + attemptCount = generated.attemptCount; + const metadata = generated.result ?? deriveDeterministicSessionMetadata({ + seeds: [ + initialRow.summary, + latestOutputPreview, + managed.autoTitleSeed, + recentConversation, + ], normalizeTitle: dependencies.normalizeTitle, normalizeStatusLine: dependencies.normalizeStatusLine, - shouldStop: () => managed.deleted || managed.sessionMetadataGenerationVersion !== generationVersion, - onFailure: ({ descriptor, provider, providerLevelFailure, attemptCount: currentAttempt, error }) => { - dependencies.logger.warn("agent_chat.session_metadata_generation_failed", { - sessionId, - modelId: descriptor.id, - provider, - providerLevelFailure, - attemptCount: currentAttempt, - error: error instanceof Error ? error.message : String(error), - }); - }, }); - selectedModelId = generated.selectedModelId; - attemptCount = generated.attemptCount; - if (!generated.result) { + if (!metadata) { throw new Error("The AI returned no usable session metadata."); } + if (!generated.result) { + dependencies.logger.info("agent_chat.session_metadata_deterministic_fallback", { + sessionId, + attemptCount, + }); + } // A newer explicit request cancels every field from this response. Manual // edits do not change the request version, so untouched fields can still @@ -173,7 +192,6 @@ export function createSessionMetadataRegenerator { }); describe("buildNamingModelCandidates", () => { - it("always reaches a different provider so a single-provider outage cannot end naming", () => { + it("returns only the preferred models that are available, in order", () => { const candidates = buildNamingModelCandidates({ availableModels: ALL_MODELS, preferred: [OPENAI_MODELS[0]?.id, OPENAI_MODELS[1]?.id], }); - expect(candidates.slice(0, 2)).toEqual([OPENAI_MODELS[0]?.id, OPENAI_MODELS[1]?.id]); - expect(candidates.some((id) => id.startsWith("anthropic/"))).toBe(true); - expect(new Set(candidates).size).toBe(candidates.length); + expect(candidates).toEqual([OPENAI_MODELS[0]?.id, OPENAI_MODELS[1]?.id]); + expect(candidates.some((id) => id.startsWith("anthropic/"))).toBe(false); }); - it("keeps the cross-provider candidate inside the attempt budget", async () => { - // Three same-provider preferences failing transiently must not spend the - // whole budget before naming ever tries another provider. - const preferred = OPENAI_MODELS.slice(0, 3).map((descriptor) => descriptor.id); - expect(preferred).toHaveLength(3); - const candidates = buildNamingModelCandidates({ availableModels: ALL_MODELS, preferred }); - - expect(candidates.indexOf(candidates.find((id) => id.startsWith("anthropic/"))!)).toBeLessThan(3); - - const attempted: string[] = []; - const { result } = await runNamingAcrossProviders(candidates, { - run: async (descriptor) => { - attempted.push(descriptor.id); - if (descriptor.id.startsWith("openai/")) throw new Error("socket hang up"); - return "Cross Provider Wins"; - }, - onFailure: vi.fn(), - }); - - expect(result).toBe("Cross Provider Wins"); - expect(attempted.some((id) => id.startsWith("anthropic/"))).toBe(true); - }); - - it("can scope candidates to the selected runtime provider", () => { + it("does not splice a hardcoded namer from another provider", () => { const candidates = buildNamingModelCandidates({ availableModels: ALL_MODELS, - preferred: [OPENAI_MODELS[0]?.id, ANTHROPIC_MODELS[0]?.id], - provider: "codex", + preferred: [OPENAI_MODELS[0]?.id], }); - expect(candidates.length).toBeGreaterThan(0); - expect(candidates.every((id) => id.startsWith("openai/"))).toBe(true); + expect(candidates).toEqual([OPENAI_MODELS[0]?.id]); }); it("drops unavailable and duplicate preferences instead of attempting them", () => { @@ -111,6 +90,42 @@ describe("buildNamingModelCandidates", () => { }); }); +describe("buildSessionIntelligenceModelCandidates", () => { + it("uses the setting first and the session model second", () => { + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + settingModelId: ANTHROPIC_MODELS[0]?.id, + sessionModelId: OPENAI_MODELS[0]?.id, + })).toEqual([ANTHROPIC_MODELS[0]?.id, OPENAI_MODELS[0]?.id]); + }); + + it("keeps the session model even when the auth snapshot is empty", () => { + const sessionModelId = OPENAI_MODELS[0]?.id; + expect(sessionModelId).toBeTruthy(); + expect(withSessionModelDescriptors([], [sessionModelId]).map((descriptor) => descriptor.id)).toEqual([sessionModelId]); + expect(buildSessionIntelligenceModelCandidates({ + availableModels: [], + sessionModelId, + })).toEqual([sessionModelId]); + }); + + it("resolves a session-model alias onto its canonical id", () => { + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + sessionModel: "sonnet", + })).toEqual(["anthropic/claude-sonnet-5"]); + expect(buildSessionIntelligenceModelCandidates({ + availableModels: [], + sessionModel: "sonnet", + })).toEqual(["anthropic/claude-sonnet-5"]); + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + settingModelId: "anthropic/claude-sonnet-5", + sessionModel: "sonnet", + })).toEqual(["anthropic/claude-sonnet-5"]); + }); +}); + describe("runNamingAcrossProviders", () => { it("skips the rest of a condemned provider without spending an attempt on it", async () => { const attempted: string[] = []; @@ -131,11 +146,11 @@ describe("runNamingAcrossProviders", () => { onFailure, }); - expect(result).toBe("Rename Naming Fallback"); + expect(result).toBeNull(); expect(attempted.filter((id) => id.startsWith("openai/"))).toHaveLength(1); - expect(attempted.at(-1)?.startsWith("anthropic/")).toBe(true); - expect(attemptCount).toBe(2); - expect(selectedModelId).toBe(attempted.at(-1)); + expect(attempted.some((id) => id.startsWith("anthropic/"))).toBe(false); + expect(attemptCount).toBe(1); + expect(selectedModelId).toBe(attempted[0]); expect(onFailure).toHaveBeenCalledTimes(1); expect(onFailure.mock.calls[0]![0]).toMatchObject({ providerLevelFailure: true }); }); @@ -143,7 +158,10 @@ describe("runNamingAcrossProviders", () => { it("advances to the next candidate when a model answers unusably", async () => { const attempted: string[] = []; const { result } = await runNamingAcrossProviders( - buildNamingModelCandidates({ availableModels: ALL_MODELS, preferred: [OPENAI_MODELS[0]?.id] }), + buildNamingModelCandidates({ + availableModels: ALL_MODELS, + preferred: [OPENAI_MODELS[0]?.id, ANTHROPIC_MODELS[0]?.id], + }), { run: async (descriptor) => { attempted.push(descriptor.id); @@ -153,7 +171,7 @@ describe("runNamingAcrossProviders", () => { }, ); - expect(attempted.length).toBeGreaterThan(1); + expect(attempted.length).toBe(2); expect(result).toBe("Second Model Wins"); }); @@ -196,3 +214,114 @@ describe("runNamingAcrossProviders", () => { expect(attempted).toHaveLength(3); }); }); + +const normalizeTitle = (value: string): string | null => { + const words = value.trim().split(/\s+/u).filter(Boolean); + return words.length >= 2 ? words.slice(0, 6).join(" ") : null; +}; +const normalizeStatusLine = (value: string): string | null => { + const summary = value.trim().replace(/\s+/g, " "); + return summary.length ? summary.slice(0, 72) : null; +}; + +describe("parseGeneratedSessionMetadata", () => { + it("keeps the three naming fields when the model adds extra keys", () => { + expect(parseGeneratedSessionMetadata({ + raw: { + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + notes: "Grok likes to annotate", + }, + normalizeTitle, + normalizeStatusLine, + })).toEqual({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + }); + }); + + it("accepts a partial object and fenced JSON with surrounding prose", () => { + expect(parseGeneratedSessionMetadata({ + raw: { chatTitle: "Wire Rag Search" }, + normalizeTitle, + normalizeStatusLine, + })).toEqual({ + chatTitle: "Wire Rag Search", + laneName: null, + statusLine: null, + }); + + expect(parseGeneratedSessionMetadata({ + raw: [ + "Sure, here is the metadata:", + "```json", + JSON.stringify({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + }), + "```", + ].join("\n"), + normalizeTitle, + normalizeStatusLine, + })).toEqual({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + }); + }); +}); + +describe("deriveDeterministicSessionMetadata", () => { + it("prefers the conversation summary over the original kickoff prompt", () => { + expect(deriveDeterministicSessionMetadata({ + seeds: [ + "Wired project aiSummary into RAG excerpts so Cmd+K answers from the overview", + "start skill using aws other", + ], + normalizeTitle, + normalizeStatusLine, + })).toMatchObject({ + chatTitle: expect.stringMatching(/wired/i), + laneName: expect.stringMatching(/wired/i), + statusLine: expect.stringMatching(/aiSummary|RAG|Cmd/i), + }); + }); +}); + +describe("runSessionMetadataGeneration", () => { + it("still reaches a JSON-capable namer when the chat model answers unusably", async () => { + const attempted: string[] = []; + const { result } = await runSessionMetadataGeneration({ + candidateModelIds: [OPENAI_MODELS[0]!.id, ANTHROPIC_MODELS[0]!.id], + cwd: "/tmp", + prompt: "Refresh this chat", + runPrompt: async ({ modelId }) => { + attempted.push(modelId); + if (modelId.startsWith("openai/")) { + return { text: "I named it. Hope that helps!" }; + } + return { + text: JSON.stringify({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + }), + }; + }, + normalizeTitle, + normalizeStatusLine, + onFailure: vi.fn(), + }); + + expect(attempted[0]?.startsWith("openai/")).toBe(true); + expect(attempted.some((id) => id.startsWith("anthropic/"))).toBe(true); + expect(result).toEqual({ + chatTitle: "Wire Rag Search", + laneName: "Search Answer Path", + statusLine: "Sources show before generate", + }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts index 29badf39e2..f721a2e0fa 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -1,18 +1,24 @@ /** * Session naming: the prompts, failure classification, and model-candidate - * chain shared by automatic lane identity and chat auto-titling. + * chain shared by automatic lane identity, chat auto-titling, and explicit + * session-metadata regeneration. * - * All three callers — lane identity, chat auto-title, and the legacy lane-name - * suggestion — used to carry their own hand-copied chain and retry loop, which + * Those callers used to carry their own hand-copied chain and retry loop, which * had already drifted apart. They live here so "the same chain" is a fact - * rather than a comment. + * rather than a comment: the user's title setting, then this session's model, + * then a deterministic name. No hardcoded Haiku/mini namer. */ import { - getModelById, + deriveDeterministicLaneTitleFromPrompt, + GENERIC_LANE_FALLBACK_TITLE, +} from "../../../shared/laneNameFallback"; +import { + resolveModelDescriptor, resolveProviderGroupForModel, type ModelDescriptor, type ModelProviderGroup, } from "../../../shared/modelRegistry"; +import { parseStructuredOutput } from "../ai/utils"; /** * The word count every naming surface aims for. It is a guideline handed to the @@ -105,26 +111,61 @@ export type SessionMetadataPromptRunner = (args: { jsonSchema: typeof SESSION_METADATA_JSON_SCHEMA; }) => Promise<{ text: string; structuredOutput?: unknown }>; +function asJsonRecord(raw: unknown): Record | null { + const value = typeof raw === "string" ? parseStructuredOutput(raw) : raw; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function readOptionalString(record: Record, key: string): string | null { + const value = record[key]; + return typeof value === "string" ? value : null; +} + +/** + * Pull the three naming fields out of a model response. Extra keys, missing + * fields, fenced JSON, and surrounding prose are ignored: Cursor Grok (and + * other non-schema models) routinely wrap or annotate the object, and a + * partial real name beats a slug. + */ export function parseGeneratedSessionMetadata(args: { raw: unknown; normalizeTitle: (value: string) => string | null; normalizeStatusLine: (value: string) => string | null; }): GeneratedSessionMetadata | null { - if (!args.raw || typeof args.raw !== "object" || Array.isArray(args.raw)) return null; - const record = args.raw as Record; - if (Object.keys(record).some((key) => key !== "chatTitle" && key !== "laneName" && key !== "statusLine")) { - return null; - } - if (typeof record.chatTitle !== "string" || typeof record.laneName !== "string" || typeof record.statusLine !== "string") { - return null; - } - const chatTitle = args.normalizeTitle(record.chatTitle); - const laneName = args.normalizeTitle(record.laneName); - const statusLine = args.normalizeStatusLine(record.statusLine); + const record = asJsonRecord(args.raw); + if (!record) return null; + const chatTitleRaw = readOptionalString(record, "chatTitle"); + const laneNameRaw = readOptionalString(record, "laneName"); + const statusLineRaw = readOptionalString(record, "statusLine"); + const chatTitle = chatTitleRaw ? args.normalizeTitle(chatTitleRaw) : null; + const laneName = laneNameRaw ? args.normalizeTitle(laneNameRaw) : null; + const statusLine = statusLineRaw ? args.normalizeStatusLine(statusLineRaw) : null; if (!chatTitle && !laneName && !statusLine) return null; return { chatTitle, laneName, statusLine }; } +/** + * Last-resort names when every model returns unusable JSON. Prefer the + * conversation summary over the original kickoff prompt so "Generate all + * three" does not restamp the launch-instruction slug. + */ +export function deriveDeterministicSessionMetadata(args: { + seeds: Array; + normalizeTitle: (value: string) => string | null; + normalizeStatusLine: (value: string) => string | null; +}): GeneratedSessionMetadata | null { + const seed = args.seeds + .map((value) => (typeof value === "string" ? value.trim() : "")) + .find((value) => value.length > 0) ?? ""; + if (!seed) return null; + const title = args.normalizeTitle(deriveDeterministicLaneTitleFromPrompt(seed)); + const chatTitle = title && title !== GENERIC_LANE_FALLBACK_TITLE ? title : null; + const statusLine = args.normalizeStatusLine(seed); + if (!chatTitle && !statusLine) return null; + return { chatTitle, laneName: chatTitle, statusLine }; +} + export function buildSessionMetadataPrompt(args: { provider: string; chatModel?: string | null; @@ -155,8 +196,6 @@ export function buildSessionMetadataPrompt(args: { export async function runSessionMetadataGeneration(args: { candidateModelIds: string[]; - /** Session provider whose context is allowed to reach the model runner. */ - provider: string; cwd: string; prompt: string; runPrompt: SessionMetadataPromptRunner; @@ -165,11 +204,10 @@ export async function runSessionMetadataGeneration(args: { shouldStop?: () => boolean; onFailure: (failure: NamingAttemptFailure) => void; }): Promise<{ result: GeneratedSessionMetadata | null; attemptCount: number; selectedModelId: string | null }> { - const candidateModelIds = args.candidateModelIds.filter((modelId) => { - const descriptor = getModelById(modelId); - return descriptor && resolveProviderGroupForModel(descriptor) === args.provider; - }); - return runNamingAcrossProviders(candidateModelIds, { + // Walk the caller's setting-then-session candidates only. Cursor Grok (and + // other non-schema models) often return unusable JSON; the next candidate + // still gets a turn. ADE already holds the transcript excerpt. + return runNamingAcrossProviders(args.candidateModelIds, { shouldStop: args.shouldStop, run: async (descriptor) => { const result = await args.runPrompt({ @@ -183,13 +221,8 @@ export async function runSessionMetadataGeneration(args: { normalizeTitle: args.normalizeTitle, normalizeStatusLine: args.normalizeStatusLine, }; - const structured = parseGeneratedSessionMetadata({ raw: result.structuredOutput, ...parserArgs }); - if (structured) return structured; - try { - return parseGeneratedSessionMetadata({ raw: JSON.parse(result.text.trim()), ...parserArgs }); - } catch { - return null; - } + return parseGeneratedSessionMetadata({ raw: result.structuredOutput, ...parserArgs }) + ?? parseGeneratedSessionMetadata({ raw: result.text, ...parserArgs }); }, onFailure: args.onFailure, }); @@ -216,65 +249,61 @@ export function isProviderLevelNamingFailure(error: unknown): boolean { } /** - * Build the ordered model chain naming walks: the caller's preferred models - * first, then a model from a provider none of them belong to, then a sibling on - * the leading provider. - * - * The cross-provider candidate is spliced in ahead of the third preference so - * it always falls inside the attempt budget. Otherwise three same-provider - * preferences failing transiently — a timeout, a hang-up, none of them - * provider-level — would spend the whole budget before naming ever tried - * another provider, which is the outage this chain exists to survive. + * Keep a session's own model in the naming pool even when it is missing from + * the current auth snapshot (OpenCode/Cursor chats can outlive inventory). */ +export function withSessionModelDescriptors( + availableModels: ModelDescriptor[], + modelRefs: Array, +): ModelDescriptor[] { + const seen = new Set(availableModels.map((entry) => entry.id)); + const extra: ModelDescriptor[] = []; + for (const ref of modelRefs) { + const modelId = typeof ref === "string" ? ref.trim() : ""; + if (!modelId || seen.has(modelId)) continue; + const descriptor = resolveModelDescriptor(modelId); + if (!descriptor || seen.has(descriptor.id)) continue; + seen.add(descriptor.id); + extra.push(descriptor); + } + return extra.length ? [...availableModels, ...extra] : availableModels; +} + const MAX_NAMING_ATTEMPTS = 3; export function buildNamingModelCandidates(args: { availableModels: ModelDescriptor[]; /** Ordered preference list; unavailable and duplicate ids are dropped. */ preferred: Array; - /** Optional runtime provider scope for calls carrying provider-owned context. */ - provider?: string | null; }): string[] { - const scopedModels = args.provider - ? args.availableModels.filter((entry) => resolveProviderGroupForModel(entry) === args.provider) - : args.availableModels; - const availableIds = new Set(scopedModels.map((entry) => entry.id)); - const availableInOrder = (candidates: Array): string[] => - candidates.reduce((acc, candidate) => { - const modelId = typeof candidate === "string" ? candidate.trim() : ""; - if (!modelId || acc.includes(modelId) || !availableIds.has(modelId)) return acc; - return [...acc, modelId]; - }, []); - - const preferred = availableInOrder(args.preferred); - const [primary] = preferred; - if (!primary) return []; - - const providerOf = (modelId: string): ModelProviderGroup | null => { - const descriptor = getModelById(modelId); - return descriptor ? resolveProviderGroupForModel(descriptor) : null; - }; - const leadingProviders = new Set( - preferred.map(providerOf).filter((group): group is ModelProviderGroup => group !== null), - ); - const primaryProvider = providerOf(primary); - const crossProviderFallback = scopedModels.find( - (entry) => !leadingProviders.has(resolveProviderGroupForModel(entry)), - )?.id; - const sameProviderFallback = scopedModels.find( - (entry) => !preferred.includes(entry.id) - && primaryProvider !== null - && resolveProviderGroupForModel(entry) === primaryProvider, - )?.id; + const availableIds = new Set(args.availableModels.map((entry) => entry.id)); + return args.preferred.reduce((acc, candidate) => { + const modelId = typeof candidate === "string" ? candidate.trim() : ""; + if (!modelId) return acc; + // Aliases like Claude's stored `sonnet` must match the canonical registry + // id that withSessionModelDescriptors already added to the pool. + const canonicalId = resolveModelDescriptor(modelId)?.id ?? modelId; + if (acc.includes(canonicalId) || !availableIds.has(canonicalId)) return acc; + return [...acc, canonicalId]; + }, []); +} - const crossProviderSlot = Math.min(preferred.length, MAX_NAMING_ATTEMPTS - 1); - return availableInOrder([ - ...preferred.slice(0, crossProviderSlot), - crossProviderFallback, - ...preferred.slice(crossProviderSlot), - sameProviderFallback, - scopedModels.find((entry) => !preferred.includes(entry.id))?.id, - ]); +/** + * Session intelligence picks a model in this order only: the user's setting, + * then this session's model. There is no hardcoded Haiku/mini/"first available" + * namer. Callers fall through to a deterministic title/summary when both miss. + */ +export function buildSessionIntelligenceModelCandidates(args: { + availableModels: ModelDescriptor[]; + settingModelId?: string | null; + sessionModelId?: string | null; + sessionModel?: string | null; +}): string[] { + const preferred = [args.settingModelId, args.sessionModelId, args.sessionModel]; + return buildNamingModelCandidates({ + availableModels: withSessionModelDescriptors(args.availableModels, preferred), + preferred, + }); } export type NamingAttemptFailure = { @@ -307,7 +336,7 @@ export async function runNamingAcrossProviders( for (const candidateModelId of candidateModelIds) { if (attemptCount >= MAX_NAMING_ATTEMPTS) break; if (options.shouldStop?.()) break; - const descriptor = getModelById(candidateModelId); + const descriptor = resolveModelDescriptor(candidateModelId); if (!descriptor) continue; const provider = resolveProviderGroupForModel(descriptor); if (exhaustedProviders.has(provider)) continue; diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index abe34e07a3..e5120baf47 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import { openKvDb } from "../state/kvDb"; import { createConflictService } from "./conflictService"; +import { missingFeatureModelMessage } from "../ai/aiIntegrationService"; function git(cwd: string, args: string[]): string { const res = spawnSync("git", args, { cwd, encoding: "utf8" }); @@ -256,7 +257,12 @@ describe("conflictService conflict context integrity", () => { getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/lane-1" }) } as any, projectConfigService: { - get: () => ({ effective: { providerMode: "subscription" } }) + get: () => ({ + effective: { + providerMode: "subscription", + ai: { featureModelOverrides: { conflict_proposals: "anthropic/claude-sonnet-5" } }, + }, + }) } as any, aiIntegrationService: { getMode: () => "subscription", @@ -289,10 +295,70 @@ describe("conflictService conflict context integrity", () => { expect(proposal.diffPatch).toContain("diff --git"); expect(capturedRequest).toBeTruthy(); + expect(capturedRequest.model).toBe("anthropic/claude-sonnet-5"); expect(typeof capturedRequest.prompt).toBe("string"); expect(capturedRequest.prompt).toContain("relevantFilesForConflict"); }); + it("refuses subscription conflict proposals when no Conflict Proposals model is configured", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-no-model-")); + const { laneHeadSha } = seedRepoWithLaneWork(repoRoot); + const dbPath = path.join(repoRoot, "kv.sqlite"); + const db = await openKvDb(dbPath, createLogger()); + const projectId = "proj-no-model"; + await seedProjectAndLane(db, projectId, repoRoot); + + db.run( + ` + insert into conflict_predictions( + id, project_id, lane_a_id, lane_b_id, status, conflicting_files_json, overlap_files_json, + lane_a_sha, lane_b_sha, predicted_at, expires_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + randomUUID(), + projectId, + "lane-1", + null, + "conflict", + JSON.stringify([]), + JSON.stringify(["src/a.ts"]), + laneHeadSha, + null, + "2026-02-15T18:50:00.000Z", + "2026-02-15T20:00:00.000Z" + ] + ); + + const laneSummary = createLaneSummary(repoRoot); + const requestConflictProposal = vi.fn(); + const service = createConflictService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [laneSummary], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/lane-1" }) + } as any, + projectConfigService: { + get: () => ({ effective: { providerMode: "subscription" } }) + } as any, + aiIntegrationService: { + getMode: () => "subscription", + requestConflictProposal, + } as any + }); + + const preview = await service.prepareProposal({ laneId: "lane-1" }); + await expect( + service.requestProposal({ laneId: "lane-1", contextDigest: preview.contextDigest }), + ).rejects.toThrow(missingFeatureModelMessage("conflict_proposals")); + expect(requestConflictProposal).not.toHaveBeenCalled(); + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + }); + it("returns insufficient-context proposal without calling subscription provider", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-insufficient-")); const { laneHeadSha } = seedRepoWithLaneWork(repoRoot); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index b911c32980..f434a79931 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -71,7 +71,7 @@ import type { AdeDb } from "../state/kvDb"; import type { createLaneService } from "../lanes/laneService"; import type { createOperationService } from "../history/operationService"; import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; +import { missingFeatureModelMessage, readConfiguredFeatureModel, type createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createSessionService } from "../sessions/sessionService"; import type { LaneWorktreeLockService } from "../lanes/laneWorktreeLockService"; import { @@ -2887,10 +2887,19 @@ export function createConflictService({ JSON.stringify(prepared.conflictContext, null, 2) ].join("\n"); + const model = readConfiguredFeatureModel( + projectConfigService.get().effective.ai, + "conflict_proposals", + ); + if (!model) { + throw new Error(missingFeatureModelMessage("conflict_proposals")); + } + const aiResult = await aiIntegrationService.requestConflictProposal({ laneId, cwd: laneGit.worktreePath, prompt, + model, jsonSchema: outputSchema }); const structured = diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index f24850eea7..45eb749ac1 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -19,6 +19,7 @@ vi.mock("./git", () => ({ })); import { createGitOperationsService } from "./gitOperationsService"; +import { missingFeatureModelMessage } from "../ai/aiIntegrationService"; const STASH_LIST_FORMAT = "--format=%H%x1f%gd%x1f%cI%x1f%gs"; @@ -1112,6 +1113,38 @@ describe("gitOperationsService.generateCommitMessage", () => { expect(generateCommitMessage).not.toHaveBeenCalled(); }); + it("refuses to call AI when no Commit Messages model is configured", async () => { + const generateCommitMessage = vi.fn(); + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + branchRef: "feature/commit-messages", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + } as any, + operationService: { + start: vi.fn(), + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => true, + getStatus: vi.fn(async () => ({ availableModelIds: ["openai/gpt-5.4"] })), + generateCommitMessage, + } as any, + logger: makeStubLogger(), + }); + + await expect(service.generateCommitMessage({ laneId: "lane-1" })).rejects.toThrow( + missingFeatureModelMessage("commit_messages"), + ); + expect(generateCommitMessage).not.toHaveBeenCalled(); + }); + it("uses the configured model and sends a lightweight changed-files prompt", async () => { let capturedPrompt = ""; let capturedModel = ""; diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index bcd6b65d40..cc129fb4e6 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -42,7 +42,7 @@ import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createOperationService } from "../history/operationService"; import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; +import { missingFeatureModelMessage, readConfiguredFeatureModel, type createAiIntegrationService } from "../ai/aiIntegrationService"; import { isRecord, safeJsonParse } from "../shared/utils"; type LaneInfo = { @@ -270,18 +270,6 @@ export function createGitOperationsService({ return promise; } - function extractEffectiveAiConfig(): Record { - const snapshot = projectConfigService.get(); - return isRecord(snapshot.effective.ai) ? snapshot.effective.ai : {}; - } - - function getConfiguredCommitMessageModel(): string | null { - const aiConfig = extractEffectiveAiConfig(); - const overrides = isRecord(aiConfig.featureModelOverrides) ? aiConfig.featureModelOverrides : {}; - const modelId = typeof overrides.commit_messages === "string" ? overrides.commit_messages.trim() : ""; - return modelId.length ? modelId : null; - } - function normalizeCommitMessage(rawText: string): string { const firstLine = rawText .split(/\r?\n/) @@ -355,9 +343,9 @@ export function createGitOperationsService({ throw new Error("AI commit messages are off. Enable Commit Messages in Settings or type a commit message manually."); } - const model = getConfiguredCommitMessageModel(); + const model = readConfiguredFeatureModel(projectConfigService.get().effective.ai, "commit_messages"); if (!model) { - throw new Error("Choose a Commit Messages model in Settings or type a commit message manually."); + throw new Error(missingFeatureModelMessage("commit_messages")); } const aiStatus = await aiIntegrationService.getStatus().catch(() => null); diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index ed3ab11e70..d8efd93842 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -1958,6 +1958,7 @@ describe("createPrSummaryService", () => { }; const aiIntegrationService = { + getConfiguredFeatureModel: vi.fn(() => "openai/gpt-5.5"), draftPrDescription: vi.fn(async () => ({ text: '{"summary":"ok","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":["c"]}', durationMs: 10, @@ -1987,6 +1988,9 @@ describe("createPrSummaryService", () => { expect(result.riskAreas).toEqual(["a"]); expect(result.headSha).toBe("headA"); expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(1); + expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledWith( + expect.objectContaining({ model: "openai/gpt-5.5" }), + ); const cached = await svc.getSummary("pr-1"); expect(cached?.summary).toBe("ok"); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index bd951ddff7..434614a1bb 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -45,6 +45,7 @@ vi.mock("../shared/remoteTrackingBranch", () => ({ fetchRemoteTrackingBranch: vi.fn(), })); +import { missingFeatureModelMessage } from "../ai/aiIntegrationService"; import { buildIntegrationPreflight } from "./integrationPlanning"; import { githubReadFailureBackoffMs } from "./githubReadBackoff"; import { @@ -5873,7 +5874,7 @@ describe("prService.draftDescription", () => { const { service } = buildService({ aiIntegrationService }); await expect( - (service as any).draftDescription({ laneId: LANE_ID, requireAi: true }), + (service as any).draftDescription({ laneId: LANE_ID, requireAi: true, model: "openai/gpt-5.5" }), ).rejects.toThrow(/AI draft failed: the model returned an empty response\./); }); @@ -5884,9 +5885,58 @@ describe("prService.draftDescription", () => { const { service } = buildService({ aiIntegrationService }); await expect( - (service as any).draftDescription({ laneId: LANE_ID, requireAi: true }), + (service as any).draftDescription({ laneId: LANE_ID, requireAi: true, model: "openai/gpt-5.5" }), ).rejects.toThrow(/AI draft failed: No AI provider is available\./); }); + + it("refuses requireAi drafts when no PR Descriptions model is configured", async () => { + const aiIntegrationService = makeAi(); + const { service } = buildService({ aiIntegrationService }); + + await expect( + (service as any).draftDescription({ laneId: LANE_ID, requireAi: true }), + ).rejects.toThrow(missingFeatureModelMessage("pr_descriptions")); + expect(aiIntegrationService.draftPrDescription).not.toHaveBeenCalled(); + }); + + it("returns the deterministic template (no AI call) when subscription mode has no PR model", async () => { + const aiIntegrationService = makeAi(); + const { service } = buildService({ + aiIntegrationService, + projectConfigService: { + get: () => ({ effective: { providerMode: "subscription", ai: {} } }), + }, + }); + + const draft = await (service as any).draftDescription({ laneId: LANE_ID }); + + expect(aiIntegrationService.draftPrDescription).not.toHaveBeenCalled(); + expect(draft.body).toContain("## Summary"); + }); + + it("uses the configured PR Descriptions model when the caller omits model", async () => { + const aiIntegrationService = makeAi(async () => ({ text: "Drafted from settings." })); + const { service } = buildService({ + aiIntegrationService, + projectConfigService: { + get: () => ({ + effective: { + providerMode: "subscription", + ai: { featureModelOverrides: { pr_descriptions: "openai/gpt-5.5" } }, + }, + }), + }, + }); + + const draft = await (service as any).draftDescription({ laneId: LANE_ID }); + + expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(1); + expect(aiIntegrationService.draftPrDescription.mock.calls[0][0]).toMatchObject({ + laneId: LANE_ID, + model: "openai/gpt-5.5", + }); + expect(draft.body).toContain("Drafted from settings."); + }); }); describe("prService.createFromLane", () => { diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index e17784f1e3..889e4af5db 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -148,7 +148,7 @@ import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionSer import type { createOperationService } from "../history/operationService"; import type { createGithubService } from "../github/githubService"; import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; +import { missingFeatureModelMessage, readConfiguredFeatureModel, type createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createConflictService } from "../conflicts/conflictService"; import type { createAgentChatService } from "../chat/agentChatService"; import type { LaneWorktreeLockService } from "../lanes/laneWorktreeLockService"; @@ -6791,7 +6791,7 @@ export function createPrService({ }; const draftDescription = async (args: DraftPrDescriptionArgs): Promise<{ title: string; body: string }> => { - const { laneId, model, reasoningEffort } = args; + const { laneId, reasoningEffort } = args; const lane = (await laneService.list({ includeArchived: true })).find((entry) => entry.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); @@ -6832,6 +6832,11 @@ export function createPrService({ }; const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; + const configuredModel = readConfiguredFeatureModel( + projectConfigService.get().effective.ai, + "pr_descriptions", + ); + const model = (args.model?.trim() || configuredModel) || undefined; const defaultTitle = lane.linearIssue ? buildLinearPrTitle(lane.linearIssue) : lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || lane.name; @@ -6862,7 +6867,11 @@ export function createPrService({ ); } - if (aiIntegrationService && (providerMode !== "guest" || args.requireAi)) { + if (args.requireAi && !model) { + throw new Error(missingFeatureModelMessage("pr_descriptions")); + } + + if (aiIntegrationService && model && (providerMode !== "guest" || args.requireAi)) { const prompt = [ "You are ADE's PR drafting assistant. Keep content factual and concise.", "Return JSON only with shape: {\"title\": string, \"body\": string}.", @@ -6878,7 +6887,7 @@ export function createPrService({ laneId, cwd: lane.worktreePath, prompt, - ...(model ? { model } : {}), + model, ...(reasoningEffort ? { reasoningEffort } : {}) }); const parsed = parsePrDraftJson(draft.text); @@ -12378,7 +12387,13 @@ export function createPrService({ // Continue without files } - if (aiIntegrationService) { + const configuredModel = readConfiguredFeatureModel( + projectConfigService.get().effective.ai, + "pr_descriptions", + ); + const model = (args.model?.trim() || configuredModel) || undefined; + + if (aiIntegrationService && model) { const diffSummary = files .map((f) => `${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`) .join("\n"); @@ -12403,7 +12418,7 @@ export function createPrService({ laneId: row.lane_id, cwd: projectRoot, prompt, - ...(args.model ? { model: args.model } : {}) + model, }); const rawJson = extractFirstJsonObject(draft.text); if (rawJson) { diff --git a/apps/desktop/src/main/services/prs/prSummaryService.ts b/apps/desktop/src/main/services/prs/prSummaryService.ts index 2a3fe36c77..8305431485 100644 --- a/apps/desktop/src/main/services/prs/prSummaryService.ts +++ b/apps/desktop/src/main/services/prs/prSummaryService.ts @@ -215,7 +215,8 @@ export function createPrSummaryService(deps: PrSummaryServiceDeps) { unresolvedThreadCount: inputs.unresolvedThreadCount, }); - if (!deps.aiIntegrationService) { + const model = deps.aiIntegrationService?.getConfiguredFeatureModel("pr_descriptions") ?? null; + if (!deps.aiIntegrationService || !model) { const fallback: PrAiSummary = { prId, summary: `This PR modifies ${inputs.files.length} file(s).`, @@ -237,6 +238,7 @@ export function createPrSummaryService(deps: PrSummaryServiceDeps) { laneId: "", // aiIntegrationService accepts empty laneId for one-shot tasks; uses projectRoot cwd. cwd: deps.projectRoot, prompt, + model, }); const parsed = parsePrSummaryJson(result.text); const summary: PrAiSummary = { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index eea47f4048..a1920dadc5 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -414,6 +414,9 @@ function createHarness(overrides: { canPerform: ReturnType; } | null; getAdeCliAgentEnv?: (env?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + projectConfigService?: { + get: ReturnType; + }; } = {}) { const mockPty = createMockPty(); const broadcastData = vi.fn(); @@ -557,6 +560,7 @@ function createHarness(overrides: { ...(overrides.aiIntegrationService ? { aiIntegrationService: overrides.aiIntegrationService as any } : {}), ...(overrides.diskPressureMonitor !== undefined ? { diskPressureMonitor: overrides.diskPressureMonitor as any } : {}), ...(overrides.getAdeCliAgentEnv ? { getAdeCliAgentEnv: overrides.getAdeCliAgentEnv } : {}), + ...(overrides.projectConfigService ? { projectConfigService: overrides.projectConfigService as any } : {}), logger: logger as any, broadcastData, broadcastExit, @@ -2852,6 +2856,12 @@ describe("ptyService", () => { args: ["--no-alt-screen"], startupCommand: "codex --no-alt-screen", initialInput: "print cwd", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { model: "openai/gpt-5.4" }, + }, }); const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; @@ -2869,6 +2879,7 @@ describe("ptyService", () => { expect.objectContaining({ prompt: expect.stringContaining("print cwd"), taskType: "session_title", + model: "openai/gpt-5.4", }), ); } finally { @@ -6059,6 +6070,12 @@ describe("ptyService", () => { cols: 80, rows: 24, toolType, + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: null, + launch: { model: "openai/gpt-5.4" }, + }, }); // Mark the metadata file as non-existent so readPersistedChatManuallyNamed returns false const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; @@ -6086,6 +6103,7 @@ describe("ptyService", () => { cwd: "/tmp/test-worktree/subdir", prompt: expect.stringContaining("Fix the flaky login tests"), timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, + model: "openai/gpt-5.4", }), ); } finally { @@ -6324,6 +6342,12 @@ describe("ptyService", () => { cols: 80, rows: 24, toolType: "claude", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: null, + launch: { model: "anthropic/claude-sonnet-5" }, + }, }); const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; @@ -6343,6 +6367,7 @@ describe("ptyService", () => { expect.objectContaining({ prompt: expect.stringContaining("Fix the flaky login tests"), taskType: "session_title", + model: "anthropic/claude-sonnet-5", }), ); expect(sessionService.get(createdSessionId)?.title).toBe("ADE generated title"); @@ -6999,6 +7024,12 @@ describe("ptyService", () => { title: "Summary session", cols: 80, rows: 24, + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { model: "openai/gpt-5.4" }, + }, }); laneService.getLaneBaseAndBranch.mockReturnValue({ @@ -7013,7 +7044,155 @@ describe("ptyService", () => { }); expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( - expect.objectContaining({ cwd: "/tmp/test-worktree/subdir" }), + expect.objectContaining({ cwd: "/tmp/test-worktree/subdir", model: "openai/gpt-5.4" }), + ); + }); + + it("skips AI titles and summaries when no setting or launch model is configured", async () => { + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "Should not run" })), + }; + const { service, mockPty, sessionService } = createHarness({ aiIntegrationService }); + await service.create({ + laneId: "lane-1", + title: "Summary session", + cols: 80, + rows: 24, + }); + + mockPty._emitter.emit("exit", { exitCode: 0 }); + await vi.waitFor(() => { + expect(sessionService.setSummary).toHaveBeenCalled(); + }); + expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + }); + + it("walks from the titles/summaries setting to the launch model when the setting fails", async () => { + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async ({ model }: { model?: string }) => { + if (model === "anthropic/claude-haiku-4-5") { + throw new Error("quota exceeded"); + } + return { text: "Used launch model" }; + }), + }; + const { service, mockPty } = createHarness({ + aiIntegrationService, + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + sessionIntelligence: { + titles: { enabled: true, modelId: "anthropic/claude-haiku-4-5" }, + summaries: { enabled: true, modelId: "anthropic/claude-haiku-4-5" }, + }, + }, + }, + })), + }, + }); + await service.create({ + laneId: "lane-1", + title: "Summary session", + cols: 80, + rows: 24, + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { model: "openai/gpt-5.4" }, + }, + }); + + mockPty._emitter.emit("exit", { exitCode: 0 }); + await vi.waitFor(() => { + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( + expect.objectContaining({ model: "openai/gpt-5.4" }), + ); + }); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: "anthropic/claude-haiku-4-5" }), + ); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ model: "openai/gpt-5.4" }), + ); + }); + + it("persists the runtime launch model onto existing session resume metadata", async () => { + const { service, sessionService } = createHarness(); + createDetachedResumableSession(sessionService, { sessionId: "session-launch-model" }); + await service.create({ + laneId: "lane-1", + sessionId: "session-launch-model", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude --resume claude-session-123", + runtimeCliLaunch: { + provider: "claude", + permissionMode: "default", + model: "anthropic/claude-sonnet-4-5", + }, + }); + expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-launch-model", + resumeMetadata: expect.objectContaining({ + launch: expect.objectContaining({ model: "anthropic/claude-sonnet-4-5" }), + }), + }), + ); + }); + + it("does not overwrite an existing launch model when backfilling resume metadata", async () => { + const { service, sessionService } = createHarness(); + sessionService.create({ + sessionId: "session-keep-model", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Claude CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-keep-model.log", + toolType: "claude", + resumeCommand: "claude --resume claude-session-123", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: "claude-session-123", + launch: { permissionMode: "default", model: "anthropic/claude-opus-4-6" }, + }, + }); + sessionService.end({ + sessionId: "session-keep-model", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: null, + status: "detached", + }); + await service.create({ + laneId: "lane-1", + sessionId: "session-keep-model", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude --resume claude-session-123", + runtimeCliLaunch: { + provider: "claude", + permissionMode: "default", + model: "anthropic/claude-sonnet-4-5", + }, + }); + expect(sessionService.updateMeta).not.toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-keep-model", + resumeMetadata: expect.anything(), + }), ); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index f51f0c6b85..8977905913 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -2502,6 +2502,38 @@ export function createPtyService({ return typeof raw === "string" && raw.trim().length ? raw.trim() : null; }; + const resolveSessionLaunchModelId = ( + session: { resumeMetadata?: TerminalResumeMetadata | null } | null | undefined, + ): string | undefined => { + const raw = session?.resumeMetadata?.launch?.model; + return typeof raw === "string" && raw.trim().length ? raw.trim() : undefined; + }; + + const uniqueCliModelIds = (...ids: Array): string[] => { + const out: string[] = []; + for (const id of ids) { + if (!id || out.includes(id)) continue; + out.push(id); + } + return out; + }; + + const tryCliAiModels = async ( + modelIds: string[], + run: (modelId: string) => Promise, + onFailure: (modelId: string, error: unknown) => void, + ): Promise => { + for (const modelId of modelIds) { + try { + const result = await run(modelId); + if (result) return result; + } catch (error) { + onFailure(modelId, error); + } + } + return null; + }; + // Generate an early CLI session title from the first user input PLUS a slice // of the actual session output, so the name reflects what the session is doing // (e.g. "Inspect GitHub login screenshot") rather than echoing the opening @@ -2520,8 +2552,9 @@ export function createPtyService({ } const laneName = session.laneName?.trim() || "Current lane"; const outputSlice = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n").trim().slice(-4000); - const titleModelId = resolveTitleModelId(); + const titleModelIds = uniqueCliModelIds(resolveTitleModelId(), resolveSessionLaunchModelId(session)); const titleReasoningEffort = resolveTitleReasoningEffort(); + if (!titleModelIds.length) return; const prompt = [ "Write a concise title for this CLI coding session.", "Return only plain text, max 80 characters, no punctuation at the end.", @@ -2534,29 +2567,30 @@ export function createPtyService({ ...(outputSlice ? ["", "Session output so far:", outputSlice] : []), ].join("\n"); const capturedAi = aiIntegrationService; - try { + const title = await tryCliAiModels(titleModelIds, async (modelId) => { const result = await capturedAi.summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, prompt, taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, - ...(titleModelId ? { model: titleModelId } : {}), + model: modelId, ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), }); - if (entry.disposed) return; - const title = sanitizeGeneratedCliTitle(result.text); - if (!title) return; - if (isSessionManuallyNamed(sessionService, entry.sessionId)) { - logger.info("pty.cli_user_title_skipped_user_renamed", { sessionId: entry.sessionId }); - return; - } - sessionService.updateMeta({ sessionId: entry.sessionId, title, manuallyNamed: false }); - } catch (err) { + if (entry.disposed) return null; + return sanitizeGeneratedCliTitle(result.text); + }, (modelId, err) => { logger.warn("pty.cli_user_title_generation_failed", { sessionId: entry.sessionId, + modelId, error: err instanceof Error ? err.message : String(err), }); + }); + if (!title || entry.disposed) return; + if (isSessionManuallyNamed(sessionService, entry.sessionId)) { + logger.info("pty.cli_user_title_skipped_user_renamed", { sessionId: entry.sessionId }); + return; } + sessionService.updateMeta({ sessionId: entry.sessionId, title, manuallyNamed: false }); }; const tryCliUserTitleFromWrite = (entry: PtyEntry, data: string): void => { @@ -2800,22 +2834,32 @@ export function createPtyService({ transcript.slice(-18_000) ].join("\n"); - const summaryModelId = typeof si?.summaries?.modelId === "string" && si.summaries.modelId.trim().length + const summarySetting = typeof si?.summaries?.modelId === "string" && si.summaries.modelId.trim().length ? si.summaries.modelId.trim() : undefined; + const summaryModelIds = uniqueCliModelIds(summarySetting, resolveSessionLaunchModelId(session)); const summaryReasoningEffort = typeof si?.summaries?.reasoningEffort === "string" && si.summaries.reasoningEffort.trim().length ? si.summaries.reasoningEffort.trim() : undefined; - const aiSummary = await aiIntegrationService!.summarizeTerminal({ - cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, - prompt, - ...(summaryModelId ? { model: summaryModelId } : {}), - ...(summaryReasoningEffort ? { reasoningEffort: summaryReasoningEffort } : {}), + const aiSummary = await tryCliAiModels(summaryModelIds, async (modelId) => { + const result = await aiIntegrationService!.summarizeTerminal({ + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, + prompt, + model: modelId, + ...(summaryReasoningEffort ? { reasoningEffort: summaryReasoningEffort } : {}), + }); + const text = result.text.trim(); + return text.length ? text : null; + }, (modelId, err) => { + logger.warn("pty.ai_summary_failed", { + sessionId, + modelId, + error: err instanceof Error ? err.message : String(err), + }); }); - const text = aiSummary.text.trim(); - if (text.length) { - sessionService.setSummary(sessionId, text); + if (aiSummary) { + sessionService.setSummary(sessionId, aiSummary); } } catch (err) { logger.warn("pty.ai_summary_failed", { @@ -2833,39 +2877,46 @@ export function createPtyService({ if (isSessionManuallyNamed(sessionService, sessionId)) { logger.info("pty.session_title_refresh_skipped_user_renamed", { sessionId }); } else { - const titlePrompt = [ - "Generate a concise final title for this completed terminal session.", - "Return only plain text, max 80 characters, no punctuation at the end.", - "", - `Session type: ${session.toolType ?? "terminal"}`, - `Initial title: ${session.title}`, - session.goal ? `Current goal: ${session.goal}` : null, - `Exit code: ${session.exitCode ?? "unknown"}`, - "", - "Terminal transcript tail:", - transcript.slice(-2000), - ].filter(Boolean).join("\n"); - - const titleModelId = resolveTitleModelId(); - const titleReasoningEffort = resolveTitleReasoningEffort(); - const titleResult = await aiIntegrationService!.summarizeTerminal({ - cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, - prompt: titlePrompt, - taskType: "session_title", - timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, - ...(titleModelId ? { model: titleModelId } : {}), - ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), - }); - const finalTitle = sanitizeGeneratedCliTitle(titleResult.text); - if (finalTitle) { - // Re-check in case user renamed during AI call - if (isSessionManuallyNamed(sessionService, sessionId)) { - logger.info("pty.session_title_refresh_skipped_user_renamed", { sessionId }); - } else { - sessionService.updateMeta({ sessionId, title: finalTitle, manuallyNamed: false }); + const titlePrompt = [ + "Generate a concise final title for this completed terminal session.", + "Return only plain text, max 80 characters, no punctuation at the end.", + "", + `Session type: ${session.toolType ?? "terminal"}`, + `Initial title: ${session.title}`, + session.goal ? `Current goal: ${session.goal}` : null, + `Exit code: ${session.exitCode ?? "unknown"}`, + "", + "Terminal transcript tail:", + transcript.slice(-2000), + ].filter(Boolean).join("\n"); + + const titleModelIds = uniqueCliModelIds(resolveTitleModelId(), resolveSessionLaunchModelId(session)); + const titleReasoningEffort = resolveTitleReasoningEffort(); + const finalTitle = await tryCliAiModels(titleModelIds, async (modelId) => { + const titleResult = await aiIntegrationService!.summarizeTerminal({ + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, + prompt: titlePrompt, + taskType: "session_title", + timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, + model: modelId, + ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), + }); + return sanitizeGeneratedCliTitle(titleResult.text); + }, (modelId, err) => { + logger.warn("pty.session_title_refresh_failed", { + sessionId, + modelId, + error: err instanceof Error ? err.message : String(err), + }); + }); + if (finalTitle) { + if (isSessionManuallyNamed(sessionService, sessionId)) { + logger.info("pty.session_title_refresh_skipped_user_renamed", { sessionId }); + } else { + sessionService.updateMeta({ sessionId, title: finalTitle, manuallyNamed: false }); + } } } - } } catch (err) { logger.warn("pty.session_title_refresh_failed", { sessionId, @@ -5570,6 +5621,21 @@ export function createPtyService({ toolType: toolTypeHint, startupCommand: requestedStartupCommand, }); + const runtimeLaunchModel = typeof runtimeCliLaunch?.model === "string" && runtimeCliLaunch.model.trim().length + ? runtimeCliLaunch.model.trim() + : ""; + if (runtimeLaunchModel && initialResumeMetadata && !String(initialResumeMetadata.launch?.model ?? "").trim()) { + initialResumeMetadata = { + ...initialResumeMetadata, + launch: { + ...initialResumeMetadata.launch, + model: runtimeLaunchModel, + }, + }; + if (existingSession) { + sessionService.updateMeta({ sessionId, resumeMetadata: initialResumeMetadata }); + } + } let initialResumeCommand = existingSession?.resumeCommand ?? (requestedResumeMetadata ? buildTrackedCliResumeCommand(requestedResumeMetadata) : defaultResumeCommandForTool(toolTypeHint)); const transcriptPath = tracked @@ -6728,34 +6794,33 @@ export function createPtyService({ strippedOutput.slice(0, 800) ].join("\n"); - const titleModelId = resolveTitleModelId(); + const titleModelIds = uniqueCliModelIds(resolveTitleModelId(), resolveSessionLaunchModelId(session)); const titleReasoningEffort = resolveTitleReasoningEffort(); - capturedAi - .summarizeTerminal({ + if (!titleModelIds.length) return; + void tryCliAiModels(titleModelIds, async (modelId) => { + const result = await capturedAi.summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, prompt, taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, - ...(titleModelId ? { model: titleModelId } : {}), + model: modelId, ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), - }) - .then((result) => { - const title = sanitizeGeneratedCliTitle(result.text); - if (title) { - // Re-check in case user renamed during AI call - if (isSessionManuallyNamed(sessionService, sessionId)) { - logger.info("pty.session_title_skipped_user_renamed", { sessionId }); - } else { - sessionService.updateMeta({ sessionId, title, manuallyNamed: false }); - } - } - }) - .catch((err) => { - logger.warn("pty.session_title_generation_failed", { - sessionId, - error: err instanceof Error ? err.message : String(err) - }); }); + return sanitizeGeneratedCliTitle(result.text); + }, (modelId, err) => { + logger.warn("pty.session_title_generation_failed", { + sessionId, + modelId, + error: err instanceof Error ? err.message : String(err), + }); + }).then((title) => { + if (!title) return; + if (isSessionManuallyNamed(sessionService, sessionId)) { + logger.info("pty.session_title_skipped_user_renamed", { sessionId }); + return; + } + sessionService.updateMeta({ sessionId, title, manuallyNamed: false }); + }); }, PTY_AI_TITLE_DEBOUNCE_MS); } diff --git a/apps/desktop/src/main/services/review/reviewService.test.ts b/apps/desktop/src/main/services/review/reviewService.test.ts index ac70b97a0a..02ef69c419 100644 --- a/apps/desktop/src/main/services/review/reviewService.test.ts +++ b/apps/desktop/src/main/services/review/reviewService.test.ts @@ -692,6 +692,13 @@ describe("reviewService", () => { vi.clearAllMocks(); }); + it("refuses to start a review run when no model is selected", async () => { + const harness = createHarness({ outputs: [] }); + await expect(harness.start({ modelId: "" })).rejects.toThrow( + "Choose a review model before starting a review.", + ); + }); + it("merges overlapping multi-pass findings and persists the pass-level artifact trail", async () => { const harness = createHarness({ outputs: [ diff --git a/apps/desktop/src/main/services/review/reviewService.ts b/apps/desktop/src/main/services/review/reviewService.ts index bf3c0eec16..5ec9686dc2 100644 --- a/apps/desktop/src/main/services/review/reviewService.ts +++ b/apps/desktop/src/main/services/review/reviewService.ts @@ -188,27 +188,6 @@ type ReviewCandidateFindingRow = { created_at: string; }; -const REVIEW_MODEL_FALLBACK_ID = "openai/gpt-5.4"; - -function resolveBuiltinReviewModelId(): string { - const candidates = [ - getDefaultModelDescriptor("codex")?.id ?? null, - getDefaultModelDescriptor("opencode")?.id ?? null, - REVIEW_MODEL_FALLBACK_ID, - getDefaultModelDescriptor("claude")?.id ?? null, - getDefaultModelDescriptor("cursor")?.id ?? null, - ].filter((modelId): modelId is string => Boolean(modelId?.trim())); - - for (const modelId of candidates) { - const descriptor = getModelById(modelId); - if (descriptor) return descriptor.id; - } - - return REVIEW_MODEL_FALLBACK_ID; -} - -const DEFAULT_REVIEW_MODEL_ID = resolveBuiltinReviewModelId(); - const MANIFEST_PROMPT_FILE_LIMIT = 100; const REVIEW_PASS_ORDER: ReviewPassKey[] = [ @@ -1460,7 +1439,7 @@ function mapRunRow(row: ReviewRunRow): ReviewRun { compareAgainst: { kind: "default_branch" }, selectionMode: "full_diff", dirtyOnly: false, - modelId: DEFAULT_REVIEW_MODEL_ID, + modelId: "", reasoningEffort: null, publishBehavior: "local_only", }); @@ -1654,18 +1633,10 @@ export function createReviewService({ const cancelledRuns = new Set(); const activeReviewerSessions = new Map>(); let disposed = false; - const configuredDefaultModelId = + const recommendedModelId = getDefaultModelDescriptor("codex")?.id ?? getDefaultModelDescriptor("opencode")?.id - ?? REVIEW_MODEL_FALLBACK_ID; - const defaultReviewModelId = getModelById(configuredDefaultModelId)?.id ?? DEFAULT_REVIEW_MODEL_ID; - - if (defaultReviewModelId !== configuredDefaultModelId) { - logger.warn("review.default_model_fallback_selected", { - requestedModelId: configuredDefaultModelId, - resolvedModelId: defaultReviewModelId, - }); - } + ?? null; function assertNotDisposed(): void { if (disposed) { @@ -2097,7 +2068,7 @@ export function createReviewService({ defaultBranchName: projectDefaultBranch ?? laneSummaries.find((lane) => lane.laneType === "primary")?.branchRef ?? null, lanes: laneSummaries, recentCommitsByLane, - recommendedModelId: defaultReviewModelId, + recommendedModelId, }; } @@ -2111,7 +2082,7 @@ export function createReviewService({ ? "dirty_only" : "full_diff"), dirtyOnly: partial?.dirtyOnly ?? target.mode === "working_tree", - modelId: partial?.modelId?.trim() || defaultReviewModelId, + modelId: partial?.modelId?.trim() || "", reasoningEffort: partial?.reasoningEffort?.trim() || null, fastMode: (partial?.fastMode ?? partial?.codexFastMode) === true, publishBehavior: target.mode === "pr" && partial?.publishBehavior === "auto_publish" @@ -3027,6 +2998,9 @@ export function createReviewService({ throw new Error("PR-backed review runs are not available in this workspace."); } const config = resolveConfig(args.target, args.config); + if (!config.modelId) { + throw new Error("Choose a review model before starting a review."); + } const startedAt = nowIso(); const run: ReviewRun = { id: randomUUID(), diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx index c810eea469..3e121e712a 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx @@ -50,6 +50,7 @@ function installAdeMocks() { { feature: "terminal_summaries", enabled: true, dailyUsage: 0 }, { feature: "pr_descriptions", enabled: true, dailyUsage: 0 }, { feature: "commit_messages", enabled: true, dailyUsage: 0 }, + { feature: "conflict_proposals", enabled: true, dailyUsage: 0 }, ], detectedAuth: [{ type: "cli-subscription", cli: "codex", authenticated: true }], availableModelIds: ["openai/gpt-5.4"], @@ -64,6 +65,7 @@ function installAdeMocks() { terminal_summaries: "openai/gpt-5.4", pr_descriptions: "openai/gpt-5.4", commit_messages: "openai/gpt-5.4", + conflict_proposals: "openai/gpt-5.4", }, sessionIntelligence: { titles: { @@ -106,6 +108,7 @@ describe("AiFeaturesSection", () => { "ai-feature-terminal_summaries", "ai-feature-pr_descriptions", "ai-feature-commit_messages", + "ai-feature-conflict_proposals", "ai-feature-chat-auto-title", ]; await waitFor(() => { diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 52593adf55..82aefb46ee 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -16,7 +16,7 @@ import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; -import { Alarm, ChatCircleDots, GitPullRequest, GitCommit, ChatText, type Icon } from "@phosphor-icons/react"; +import { Alarm, ChatCircleDots, GitPullRequest, GitCommit, GitMerge, ChatText, type Icon } from "@phosphor-icons/react"; import { useOpenProviderSignIn } from "../shared/useOpenProviderSignIn"; type FeatureInfo = { @@ -38,6 +38,7 @@ const FEATURES: FeatureInfo[] = [ { key: "terminal_summaries", label: "Summarize completed chats and terminals", description: "Replace raw last output with a concise session summary when work completes", subtitle: "Show what happened instead of the last terminal line", icon: ChatCircleDots }, { key: "pr_descriptions", label: "PR description drafting", description: "Draft PR descriptions when you trigger the action in the PR flows", subtitle: "Get a head start on PR descriptions when you're ready to merge", icon: GitPullRequest }, { key: "commit_messages", label: "Commit messages", description: "Generate a brief git commit subject when the field is empty", subtitle: "Meaningful commit messages generated from your staged changes", icon: GitCommit }, + { key: "conflict_proposals", label: "Conflict proposals", description: "Draft a merge-conflict resolution when you request one", subtitle: "Propose a patch when you request AI conflict resolution", icon: GitMerge }, ]; function normalizeModelSetting(value: unknown): string { diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index c97b22d44e..872c811ba0 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -22,9 +22,10 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, brief or full-history fork selection, the destination chat's model / reasoning effort / fast mode / permission mode (the shared `PermissionModePicker` and `ReasoningEffortPicker`, each self-hiding when the chosen model can't honor it), optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, a **Fetch & fast-forward there** offer when the destination lane is clean and a strict ancestor of the source commit, transport disclosure, route-pinned final send, and recoverable source-marker completion. Source blockers are `BlockedActionReason` values rendered next to a `BlockedActionButton`, so no blocker can hide behind a disabled control. The modal takes a `runtimePin` naming the machine the **source** chat runs on (`null` = this tab's bound machine) and pins every source-side call to it — lane list, `git.getSyncStatus`, `git.getOriginRemote`, `git.push`, `git.pull`, `agentChat.prepareCrossMachineHandoff`, `validateCrossMachineSource`, `markCrossMachineHandoff` — while destination dispatch keeps routing by target id. The pin lives in a ref and is frozen once per operation, so every await inside one handoff reaches the same runtime; reading it fresh after an await could cross a lane-index change and split one handoff across two machines. Eligibility follows the same rule: the Handoff menu offers the cross-machine card based on the chat's own binding (`isRemoteChat`), so a local chat viewed from a remote-bound tab can still hand off, and a chat pinned to a remote machine cannot. `crossMachineHandoffPresentation.tsx` holds the pure half — stage/mode types, `SourceCheck`, branch/route/readiness copy, permission tone and icon maps, and `CheckRow` — so the copy and lookups that shipped wrong are directly testable. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines (Droid's session index is machine-local, and Cursor's local fork is ADE-side context seeding that produces no provider artifact to send). A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | | `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`, `cursor`) + `providerSupportsHandoffFork()`, the companion `providerForkReplaysTranscript()` (true only for Cursor, whose fork is an ADE-side full-transcript replay onto a brand-new agent rather than a native provider fork, so UI copy must not promise a copied provider thread — it promises the conversation, bounded by the target model's context window), `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). Cross-machine fork has its own narrower list: `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS` + `providerSupportsCrossMachineHandoffFork()`, derived from `HANDOFF_FORK_PROVIDERS` by filtering out Droid (its session index is machine-local) and every replay-forked provider (Cursor produces no transportable artifact at all), so the two lists cannot drift. `validateForkTransport` gates inbound capsules on the cross-machine helper rather than the local one, so a provider whose fork has nothing to package is refused by the provider check instead of by the transport-kind allowlist. The preflight also carries an optional `laneFastForward` (`laneId`, `laneName`, `behindBy`) — the destination's own assertion that its existing lane is clean and a strict ancestor of the source commit. `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` and `laneFastForward` only when present, and rejects a `behindBy` that is not a positive integer because the destination refuses a zero-distance fast-forward. `chat.ts` also owns `ACTIVE_TURN_DISPATCH_MODES` — THE per-provider active-turn delivery matrix, in menu order with the first entry as the provider's default (`claude`: `inline`, `queue`, `interrupt`; `cursor`: `interrupt`, `queue`; everything else queue-only) — read through `activeTurnDispatchModes()`, `defaultActiveTurnDispatchMode()` and `supportsActiveTurnDispatchMode()`, with the companion facts `activeTurnInterruptContinues()` (true only for Cursor, whose interrupt cancels and resends on the same thread instead of folding into the live query, so the affordance says "continue") and `unsupportedActiveTurnDispatchModeMessage()` (the one rejection string, templated off the table). Every surface reads it rather than restating the rules — the composer's split send button, the chat pane's dispatch wiring, `agentChatService`'s steer/dispatch guards, and the `ade code` TUI's `/steer` commands; iOS mirrors it by hand in `WorkActiveSendCapability` because it cannot import TS. `chat.ts` is also the canonical cross-client contract for context-usage state/sample metadata, Claude result provenance/error/correlation fields, queue-aware interrupt results, the bounded `queue_recovery` lifecycle, and the desktop prompt-stash DTOs plus `MAX_PROMPT_STASHES`. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (cross-machine fork provider support, provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. It gates on `providerSupportsCrossMachineHandoffFork`, not the local-fork predicate, so a provider whose fork produces no transportable artifact (Droid's machine-local index, Cursor's context-only reseed) is refused by the provider check rather than incidentally by the kind allowlist. | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. OpenCode stream rendering gates every rendered content type on the assistant message role: `message.part.updated` events carry no role and user-message parts (including synthetic/ignored prompt context) ride the same event stream as assistant output, so text/reasoning deltas emit only for parts whose message id `message.updated` announced as `assistant` (`openCodeMessageRoleById` — unknown ids stay unrendered because OpenCode announces every message before its parts), synthetic/ignored parts are skipped outright, and image `file` parts still emit only for assistant-owned messages. Lane naming and chat auto-titling both run through the session-intelligence prompt path over the shared candidate chain in `sessionNaming.ts` (configured `titleModelId` → the model the chat was launched with → a model from another provider → a sibling on the leading provider), and only then fall back to a deterministic prompt-derived title/slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. It is single-flight per session (a second concurrent caller — a double-click, or a desktop and a phone dismissing at once — awaits the pass already running instead of starting a second one) and records which cards its own drains resolved so it never emits a second receipt for the same card. `settleCodexPendingInputs` is the single settle for a Codex turn: it answers each open app-server approval request, clears `runtime.approvals`, drains staged `pendingPlanFollowups`, cancels the local `codex` **and** `ade` cards, and emits exactly one `pending_input_resolved` per card; every path that ends a Codex turn calls it (`interrupt`, the local interrupt finish, the `turn/aborted` handler, runtime teardown, `thread/deleted`, the app-server `error`/`exit` handlers, and settlement). `settleClaudePendingApprovals` is the Claude counterpart for `canUseTool` waiters, which can only be answered on the query that raised them. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Plan-mode transitions run through `claudePlanMode.ts` and emit a plan-mode notice carrying the resulting access mode, so the renderer composer chip updates from an authoritative value even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)); an expired Cursor access token recycles the worker while resuming the *same* agent id, so the recovery is silent and the thread survives. Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. OpenCode stream rendering gates every rendered content type on the assistant message role: `message.part.updated` events carry no role and user-message parts (including synthetic/ignored prompt context) ride the same event stream as assistant output, so text/reasoning deltas emit only for parts whose message id `message.updated` announced as `assistant` (`openCodeMessageRoleById` — unknown ids stay unrendered because OpenCode announces every message before its parts), synthetic/ignored parts are skipped outright, and image `file` parts still emit only for assistant-owned messages. Lane naming and chat auto-titling both run through the session-intelligence prompt path over the shared candidate chain in `sessionNaming.ts` (configured `titleModelId` when set, then the model the chat was launched with). An empty candidate list still uses a deterministic prompt-derived title/slug — it does not throw or skip naming. Branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. It is single-flight per session (a second concurrent caller — a double-click, or a desktop and a phone dismissing at once — awaits the pass already running instead of starting a second one) and records which cards its own drains resolved so it never emits a second receipt for the same card. `settleCodexPendingInputs` is the single settle for a Codex turn: it answers each open app-server approval request, clears `runtime.approvals`, drains staged `pendingPlanFollowups`, cancels the local `codex` **and** `ade` cards, and emits exactly one `pending_input_resolved` per card; every path that ends a Codex turn calls it (`interrupt`, the local interrupt finish, the `turn/aborted` handler, runtime teardown, `thread/deleted`, the app-server `error`/`exit` handlers, and settlement). `settleClaudePendingApprovals` is the Claude counterpart for `canUseTool` waiters, which can only be answered on the query that raised them. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Plan-mode transitions run through `claudePlanMode.ts` and emit a plan-mode notice carrying the resulting access mode, so the renderer composer chip updates from an authoritative value even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)); an expired Cursor access token recycles the worker while resuming the *same* agent id, so the recovery is silent and the thread survives. Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | | `apps/desktop/src/main/services/chat/chatRuntimeBudget.ts` | The process-wide warm-runtime budget. Owns `MAX_CONCURRENT_ACTIVE_RUNTIMES` (5) and `createChatRuntimeBudget()`, which chat services register with as `RuntimeBudgetParticipant`s (`countActiveRuntimes` + `listEvictableRuntimes`). `enforce(excludeSessionId)` releases at most one runtime per call — the globally least-recently-used releasable one across every registered participant — and yields when nothing is releasable. Constructed once per host (`main.ts`, `bootstrap.ts`) and passed to every project scope's `createAgentChatService`; a service constructed without one gets a private budget, which is the old per-service behaviour and the right answer for tests. Deliberately dependency-free of any runtime type so the LRU choice is testable without standing up a chat service. See [Session lifecycle](#session-lifecycle) below. | -| `apps/desktop/src/main/services/chat/sessionNaming.ts` | Canonical home for everything the three naming callers share — automatic lane identity, chat auto-title, and the legacy lane-name suggestion — because each used to carry its own hand-copied chain that had already drifted. Owns the three system prompts and the lane-identity JSON schema, `MAX_NAMING_WORDS` (six words, handed to the model as a **guideline**: an over-long answer is clamped, never rejected, because a clamped real name beats a slug), `isProviderLevelNamingFailure` (a missing/unusable CLI, auth, quota, or an account that cannot run the model — including the "model is not supported when using X with a Y account" 400; it deliberately excludes "not supported for/on/by", which describes one model lacking a capability and must still retry a sibling), `buildNamingModelCandidates` (preferred ids → a model from a provider none of them belong to → a sibling on the leading provider, so a cross-provider candidate is always reachable), and `runNamingAcrossProviders` (walks the chain up to three attempts; a provider-level failure condemns every remaining model behind that provider, `run` returning null means "answered unusably" and the next candidate still gets a turn, and `shouldStop` abandons the chain when the user renames mid-flight). | +| `apps/desktop/src/main/services/chat/sessionNaming.ts` | Canonical home for everything the naming callers share — automatic lane identity, chat auto-title, explicit session-metadata regeneration, and the legacy lane-name suggestion — because each used to carry its own hand-copied chain that had already drifted. Owns the three system prompts and the lane-identity JSON schema, `MAX_NAMING_WORDS` (six words, handed to the model as a **guideline**: an over-long answer is clamped, never rejected, because a clamped real name beats a slug), `isProviderLevelNamingFailure` (a missing/unusable CLI, auth, quota, or an account that cannot run the model — including the "model is not supported when using X with a Y account" 400; it deliberately excludes "not supported for/on/by", which describes one model lacking a capability and must still retry a sibling), `buildNamingModelCandidates` / `buildSessionIntelligenceModelCandidates` (the user title setting, then this session's model; no hardcoded Haiku/mini/"first available" namer; an empty candidate list still uses the deterministic name and does not throw or skip naming), and `runNamingAcrossProviders` (walks the chain up to three attempts; a provider-level failure condemns every remaining model behind that provider, `run` returning null means "answered unusably" and the next candidate still gets a turn, and `shouldStop` abandons the chain when the user renames mid-flight). Session-metadata JSON parsing ignores extra keys and extracts fenced objects, so a Cursor Grok annotation does not discard a usable title. | +| `apps/desktop/src/main/services/chat/sessionMetadataService.ts` | Explicit title / lane-name / status-line regeneration. Walks the same session-intelligence candidate chain, then `deriveDeterministicSessionMetadata` when the list is empty or every model misses. Throws `The AI returned no usable session metadata.` only when that deterministic derivation also yields nothing. | | `apps/desktop/src/main/services/chat/spawnMissionOwnership.ts` | The single statement of who a spawned child chat is currently working for, so the policy is written and tested in one place instead of inline in `reportChildSpawnEnded`. Wake vs quiet is the child's persisted `spawnKind` (`subagent` always wakes; `peer` never does). `isHumanChildMessage` / `countHumanChildMessagesForTurn` / `formatHumanChildMessageAnnotation` name how many human messages landed in a finished turn so the next subagent wake can say `The user also sent N message(s) to this chat.` Parent dispatches, scheduled wakes, relays, host continuations, and any orchestration origin are not human messages. `HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS` / `stripHostAuthoredMessageProvenance` export the same key list to every untrusted entry point (the ADE RPC edge, the automation action bridge) so provenance is always what the host observed, never what a caller asserted. | | `apps/desktop/src/main/services/chat/chatMentionService.ts` | Composer @-mention service (chats / lanes / terminals), created inside `agentChatService` with injected roster/transcript/PTY deps. Owns the keystroke-rate `chat.listMentionSuggestions` action (daemon-routed, read-only): one shared 1.5 s-TTL roster cache with a single in-flight promise collapses a typing burst into one sessions/lanes/terminals read, per-source failures degrade only their own candidate pool, and ranking/caps come from `shared/chatMentions.ts` (mixed best-match, not per-kind sections). Also owns send-time expansion: `applyChatMentionExpansion` rewrites send/steer args so the provider receives `` pointer blocks (identity attributes, a ≤1 KB CRLF-normalized neutralized preview, and literal `ade chat read` / `ade lanes show` / `ade terminal read` / `ade search` commands — double-quoted-only so they paste into sh, PowerShell, and cmd) while `displayText` keeps the user's literal chips. Idempotence uses a module-private Symbol marker (structured clone strips it, so nothing over IPC/sync can pre-mark), the single expansion owner on the steer side is `steerWithOptions`, and slash-command prompt rewrites re-attach blocks via `carryChatMentionBlocks`. Lane details never derive git state from `lane.status` (lanes are listed without a status probe and the unprobed default is indistinguishable from clean). Fires the content-free `onMentionsExpanded` analytics hook once per send that actually gained blocks. | | `apps/desktop/src/shared/chatMentions.ts` | Pure, surface-agnostic mention grammar shared by desktop, TUI, web preview mock, and (future) iOS: `@chat:` / `@lane:` / `@term:` token parsing derived from one prefix table (`CHAT_MENTION_KINDS` is the canonical kind order), word-boundary matching so emails never match, `renderChatMentionBlock` (attribute escaping + preview truncation on line boundaries + neutralization of forged `` tags and block headers so another session's transcript text cannot inject fake pointer blocks), `rankChatMentionSuggestions` (exact > prefix > substring > subsequence, recency tie-break, deterministic id tie-break — kind is not a sort key), and per-message caps (16 mixed menu rows, 12 expansions, 1024-char previews). Types live in `shared/types/chatMentions.ts`. | @@ -153,7 +154,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers (including scheduled-work create, list, per-job cancel, and per-chat pause), persists/retrieves parallel launch recovery state in `kv`, and refreshes the runtime scheduler after the global AI config pause changes. | | `apps/desktop/src/shared/ipc.ts` | `ade.agentChat.*` IPC channel constants. | -Explicit session metadata regeneration is a user-invoked, one-shot call through the selected chat runtime. It can refresh the chat title, lane name, status line, or all applicable fields together; the primary lane keeps its immutable name, and an explicit request may replace a title previously chosen by the user. +Explicit session metadata regeneration is a user-invoked, one-shot call through the selected chat runtime. It can refresh the chat title, lane name, status line, or all applicable fields together; the primary lane keeps its immutable name, and an explicit request may replace a title previously chosen by the user. Naming follows the user's title-model setting when one is set, then the chat's own model, then a deterministic name. An empty candidate list still uses that deterministic name — it does not throw or skip naming. Extra keys, fenced JSON, and surrounding prose are accepted; if every model still misses, ADE derives names from the conversation summary / latest output instead of throwing `The AI returned no usable session metadata.` ## Built-in browser authentication limits @@ -2236,7 +2237,9 @@ config service): - `ai.mode` -- `subscription` vs `guest`; gates auto-title, tool availability, and provider selection. -- `ai.sessionIntelligence.titles.*` -- AI title generation. Legacy +- `ai.sessionIntelligence.titles.*` -- AI title generation. The + configured `titleModelId` when set, then this chat's model, then + deterministic. An empty candidate list still names the chat. Legacy `ai.chat.autoTitleReasoningEffort` is migrated into this tree. - `ai.permissions.*` -- per-provider permission defaults (`claudePermissionMode`, Codex approval/sandbox defaults, OpenCode diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index e3dce6bc99..27cfd44d94 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -875,9 +875,11 @@ on the Claude Agent SDK: `metadata.hideFullPrompt`, so desktop transcripts do not show or copy the internal brief body as ordinary user-authored text. `buildDeterministicHandoffBrief()` provides a deterministic - fallback when the LLM summarization call fails or no eligible - summarizer is available; `AgentChatHandoffResult.usedFallbackSummary` - surfaces which path was taken. + fallback when the session-intelligence chain is empty, the LLM + summarization call fails, or no eligible summarizer is available; + `AgentChatHandoffResult.usedFallbackSummary` surfaces which path was + taken. An empty candidate list still returns that brief — it does + not throw or skip the handoff. ## Auto-title generation @@ -893,28 +895,55 @@ Sessions auto-title through two stages when `ai.sessionIntelligence.titles.refreshOnComplete` (default true) triggers a final refresh after a turn completes. -Both stages walk the shared naming chain built by `buildNamingModelCandidates` -in `sessionNaming.ts` — the configured `titleModelId`, the default title model, -the model the chat itself was launched with, then a model from a provider none -of those belong to — and run it through `runNamingAcrossProviders`. A -provider-level failure (missing CLI, auth, quota, or an account that cannot run -the requested model) condemns every remaining model behind that provider, so -one provider being down cannot end titling outright. +Both stages walk the shared naming chain built by `buildSessionIntelligenceModelCandidates` +in `sessionNaming.ts` — the configured `titleModelId` when set, then this +chat's model — and run it through `runNamingAcrossProviders`. There is no +hardcoded Haiku or "first available" namer. A provider-level failure +condemns every remaining model behind that provider. An empty candidate +list is a no-op walk and still uses the deterministic title; naming does +not throw or skip just because no model is configured. Six words is the guideline the prompt gives the model, not a rejection rule: a seven-word title is clamped to the first six rather than discarded, and an over-long title is cut on a word boundary so it never stops mid-word. If every -candidate fails, the chat falls back to a title derived deterministically from -the seed prompt — the same derivation an automatically created lane uses — so a -chat with a real prompt never sits on its provider default title. The fallback -only rescues a still-default title, and only when it produces at least two -words. +candidate fails (or none exist), the chat falls back to a title derived +deterministically from the seed prompt — the same derivation an automatically +created lane uses — so a chat with a real prompt never sits on its provider +default title. The fallback only rescues a still-default title, and only when +it produces at least two words. Manual renaming sets `manuallyNamed: true`, which permanently suppresses further auto-title generation. The manual-rename check runs *before* the title write, not after, because adopting a title has side effects (session meta, runtime push) that a rename landing mid-request must stop. +The same chain — Settings title/summary model, then this session's model, +then deterministic — covers chat titles, end-of-session summaries, explicit +session-metadata regeneration, automatic lane names, handoff briefs, and +identity-continuity summaries. CLI titles and terminal summaries are +separate: they try the title/summary setting, then the stored launch model, +and skip the AI call when both are missing. See +[AI-driven titles](../terminals-and-sessions/pty-and-sessions.md#ai-driven-titles). + +## One-shot utility tasks + +Commit messages, PR drafts/summaries, and conflict proposals pick a +model once: the caller argument, else the feature picker in Settings, +else skip or throw a Settings prompt. Review start requires an explicit +run `modelId` (not a Settings feature picker). There is no hardcoded +Haiku / Sonnet / "first available" namer. + +- Commit messages and conflict proposals throw `Choose a … model in Settings`. +- PR drafts and PR AI summaries use the deterministic template when the + picker is empty; `requireAi` callers throw the Settings prompt instead + of a stub. +- Review start requires an explicit `modelId` on the run. Empty throws + `Choose a review model before starting a review.` Launch context may + advertise a Codex catalog `recommendedModelId` as a picker hint; the + service never fills a model if the caller omits one. +- Live chat compaction is unchanged — it always uses the chat's own + provider. + ## CTO vs. regular chat routing CTO sessions (`identityKey: "cto"`) are routed differently: diff --git a/docs/features/conflicts/README.md b/docs/features/conflicts/README.md index d67215640c..d6d6c7be88 100644 --- a/docs/features/conflicts/README.md +++ b/docs/features/conflicts/README.md @@ -233,7 +233,7 @@ Proposals (AI + apply/undo): |---------|-------------| | `ade.conflicts.listProposals` | Proposals for a lane | | `ade.conflicts.prepareProposal` | Build bounded context, return preview | -| `ade.conflicts.requestProposal` | Dispatch to provider via `aiIntegrationService` | +| `ade.conflicts.requestProposal` | Dispatch to the Conflict Proposals model from Settings via `aiIntegrationService`; empty picker throws `Choose a Conflict Proposals model in Settings` rather than defaulting to Sonnet or the first available provider | | `ade.conflicts.applyProposal` | Apply via `git apply --3way`, record operation | | `ade.conflicts.undoProposal` | Reverse-apply via `git apply -R` | diff --git a/docs/features/conflicts/simulation.md b/docs/features/conflicts/simulation.md index 643cc59a47..41cfe55e1f 100644 --- a/docs/features/conflicts/simulation.md +++ b/docs/features/conflicts/simulation.md @@ -95,8 +95,10 @@ entries. 2. Short-circuit if `insufficientContext`: record a `failed` proposal with explicit data-gap messaging, do not dispatch. 3. Route through `aiIntegrationService.requestConflictProposal` - which calls `AgentExecutor.execute()` with the Claude CLI by - default (`sonnet`, read-only permissions, 60 s timeout). + using the Conflict Proposals model from Settings + (`featureModelOverrides.conflict_proposals`). If that picker is + empty, the call throws `Choose a Conflict Proposals model in Settings` + — it does not default to Sonnet or the first available provider. 4. Persist the result as a `conflict_proposals` row with: - `source: 'subscription'` or `'local'` - `confidence: number | null` (0.0–1.0) diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 14c3bec909..ad947d2383 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -175,8 +175,9 @@ iOS companion (`apps/ios/ADE/Views/Lanes/`): `LaneDetailGitActionsPane.swift` is the single git surface embedded in the lane detail (a port of desktop's `LaneGitActionsPane`): commit message + amend with an AI "Suggest message" button (calls - `aiCommitMessages.generate` and shows an inline setup hint when the - host reports AI commit messages aren't configured), pull/push/fetch, + `aiCommitMessages.generate`, which requires the Commit Messages model + from Settings and shows an inline setup hint when that picker is + empty — it does not fall through to Haiku), pull/push/fetch, staged and unstaged files with per-file and bulk stage / unstage / discard / restore / open-diff / open-files affordances, stash push/apply/pop/drop, recent-commit history with revert / cherry-pick @@ -222,7 +223,9 @@ iOS companion (`apps/ios/ADE/Views/Lanes/`): title's slug first; only then do remaining collisions receive `-2`, `-3`, and later suffixes. A manual lane or branch rename wins over a late result. Automatic lane identity uses the same model chain as chat auto-title - (configured naming model → default title model → launched chat model). Mobile + (configured naming model when set, then the launched chat model, then + deterministic). An empty candidate list still uses the deterministic name + — it does not throw or skip naming. Mobile calls the same host operation through `SyncService.suggestLaneName` (the non-queueable `lanes.suggestName` sync command → `agentChatService.generateAutoLaneIdentity` on the host). diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 86bd205857..d0e52be705 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -656,7 +656,18 @@ Renderer — settings: - `apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx` — Background Jobs settings for AI-powered helpers: auto-naming chats, CLI sessions, and lanes; summarizing completed chats and terminals; - PR description drafting; and commit message drafting. Reasoning-effort + PR description drafting; commit message drafting; and conflict + proposals. One-shot helpers (commit messages, PR descriptions, + conflict proposals, terminal summaries) require that row's Settings + model: an empty picker skips AI or throws a Settings prompt instead of + silently picking Haiku or the first available model. Commit messages + and conflict proposals refuse with a Settings prompt; PR drafts and + summaries use the deterministic template. Auto-naming is session + intelligence, not a one-shot: the title setting, then this session's + model, then deterministic — an empty title picker still names the + chat. CLI titles/summaries try the setting, then the stored launch + model, and skip the AI call when both are missing. Live chat + compaction stays on the chat's own provider. Reasoning-effort pickers use `useFamilyDefaults={false}` so each row keeps an independent effort override. The section also owns **Pause all scheduled work**, persisted as `ai.chat.scheduledWorkPaused`. This pauses Claude @@ -1413,7 +1424,7 @@ changing rather than which service backs it: |---|---|---| | General | `ProjectSection.tsx`, `AdeCliSection.tsx`, `AutoUpdatesSection.tsx`, `KeepAwakeSection.tsx`, `ProductAnalyticsSection.tsx`, `DiagnosticsSharingSection.tsx`, `AboutSection.tsx` | The top ADE card shows running/installed/downloaded versions, the runtime service, and update controls; below it are project health, the `ade` command line (`#ade-cli`), **Sleep** (`#keep-awake`, hidden on hosted web — a browser holds no power lock), and the two Privacy consents — anonymous analytics and diagnostics sharing (`#diagnostics-sharing`, hidden on hosted web). Legacy `?tab=workspace`, `?tab=project`, `?tab=context`, `?tab=onboarding`, `?tab=help`, and `?tab=tours` land here. | | Appearance | `AppearanceSection.tsx`, `LaunchPromptSection.tsx` (renders `ChatAppearancePreview`) | Theme, chat typography and density, chat surface (tint, corners), chat details (copy-button position, message minimap, prompt-stash bookmark, launch-prompt clipboard, live preview), and terminal text. Rebuilt on the primitives — the old version used `font-mono` for every prose line and four different control idioms. Persisted to `localStorage` under `ade.userPreferences.v1`. | -| Agents & Models | `ProvidersSection.tsx`, `OAuthConnectModal.tsx`, `AiFeaturesSection.tsx`, `BudgetCapEditor.tsx`, `DictationSection.tsx` | Provider connections, model routing, background helpers, spend cap, and voice input — merged because provider auth and per-task model routing are one mental model. **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid, Pi — Pi's card also carries in-app provider sign-in) and **OpenCode — Universal Model Access**. Background helpers cover summaries, PR descriptions, commit messages, auto-naming, and scheduled-work recovery. Legacy `?tab=ai`, `?tab=providers`, `?tab=background-jobs`, and `?tab=automations` land here. | +| Agents & Models | `ProvidersSection.tsx`, `OAuthConnectModal.tsx`, `AiFeaturesSection.tsx`, `BudgetCapEditor.tsx`, `DictationSection.tsx` | Provider connections, model routing, background helpers, spend cap, and voice input — merged because provider auth and per-task model routing are one mental model. **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid, Pi — Pi's card also carries in-app provider sign-in) and **OpenCode — Universal Model Access**. Background helpers cover summaries, PR descriptions, commit messages, conflict proposals, auto-naming, and scheduled-work recovery. Legacy `?tab=ai`, `?tab=providers`, `?tab=background-jobs`, and `?tab=automations` land here. | | Lanes | `LaneBehaviorSection.tsx`, `LaneTemplatesSection.tsx`, `PrChatTranscriptsSection.tsx` | How lanes start (`new lane base`), stay current (`auto-rebase`), and tell you they fell behind (`rebase suggestions` off/badge/banner + min-behind threshold), plus lane init recipes and PR transcript gists. Legacy `?tab=lane-templates` lands here. | | Integrations | `GitHubIntegrationSection.tsx`, `LinearIntegrationSection.tsx` | GitHub and Linear — reinstated as its own tab. Legacy `?tab=integrations`, `?tab=github`, and `?tab=linear` land here; `?integration=github|linear` too, while `?integration=cli` follows the `ade-cli` anchor to General. | | Notifications | `NotificationsSection.tsx`, `AgentCompletionSoundSection.tsx` | Delivery for `AttentionPreferences`: per-event policy (off / ambient / notify) for agent and PR events, quiet hours, focus suppression, phone delivery and escalation, the agent completion sound, and the Lanes banner budget. The per-event matrix and quiet hours were fully modelled with balanced defaults but had **no UI at all** before this tab. | diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 838428229d..9245064401 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -291,9 +291,22 @@ type AiConfig = { }; ``` -`effective.ai.mode` is the source of truth for guest vs subscription -behavior. Legacy `providers.mode` migration is still in the service -but idempotent. +`featureModelOverrides` / `featureReasoningOverrides` are the per-feature +model pickers in Settings → Agents & Models → Background helpers +(commit messages, PR descriptions, terminal summaries, conflict +proposals). An empty picker **skips AI** for that one-shot — ADE does +not fall through to Haiku, Sonnet, or the first available model. +Commit messages and conflict proposals refuse with a Settings prompt; +PR drafts and PR AI summaries use the deterministic template instead. +Review start is not a feature picker: the run requires an explicit +`modelId`, and empty throws `Choose a review model before starting a review.` +Session intelligence (chat titles, summaries, metadata, lane names, +handoff, continuity) uses the title/summary setting, then this session's +model, then deterministic. An empty candidate list still uses +deterministic naming — it does not throw or skip. CLI titles and +terminal summaries try the setting, then the stored launch model, and +skip the AI call when both are missing. Live chat compaction stays on +the chat's own provider. ### Custom providers and model slugs @@ -361,6 +374,12 @@ end-of-session summaries: - `summaries.modelId` (`null` clears a project override) - `summaries.reasoningEffort` +Chat, lane, metadata, handoff, and continuity callers walk +`titles.modelId` / `summaries.modelId` and then this session's model. +An empty list still uses the deterministic name. CLI title/summary +callers walk the same settings and then `resumeMetadata.launch.model`, +and skip AI when both are missing. + Legacy `ai.chat.autoTitleEnabled`, `ai.chat.autoTitleModelId`, and `ai.chat.autoTitleRefreshOnComplete` are read on load and migrated into `sessionIntelligence.titles.*` by `coerceAiConfig`. They are diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 65935e4bac..11f526f24a 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -168,7 +168,7 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prPollingService.ts` | Webhook-first PR freshness plus the direct-GitHub safety net. `reconcilePrs(prIds)` coalesces webhook-linked ids and refreshes only those rows immediately. A healthy relay suppresses hot polling and reduces broad refreshes to a 15-minute safety sweep; an unhealthy relay uses the configurable 60 s fallback (clamped to 5 s–5 min) and user-driven hot windows of 15 s for the first minute, then 30 s until the three-minute cap. Empty-cache discovery runs at most every 30 minutes with a healthy relay or 10 minutes without one. Before every network refresh, the poller honors credential cooldown/reset state and preserves the final 500 core/GraphQL requests for foreground actions. It writes `last_polled_at` per PR for delta polling. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) for runtime-bound windows; the desktop main process owns the local-bound instance. | | `prMergeAutoSettlementService.ts` | Applies the enabled lane-PR merge settlement policy after each polling snapshot. It files chat and tracked-agent-CLI sessions for a newly discovered merged PR even when the session has pending input or background work: the merge is the explicit override. The single exception is a chat turn that is running *right now* — see [Active-turn deferral](#active-turn-deferral). **Which** sessions it may file is an explicit `MergeSettlementScope` union rather than an implicit fallthrough — see [Merge settlement scope](#merge-settlement-scope). Each PR is handled once — including when the scope resolves to `ambiguous` and nothing is filed at all, because this merge looked and decided — so user reactivation is not re-filed by that old merge, while another linked PR can file a later lifecycle. It emits `pr-sessions-auto-settled` only when the preceding in-memory snapshot contained that PR as open or draft. A first-sight merge — including backfilled history from another machine or the first snapshot after restart — is filed silently, so an imported history cannot generate merge toasts or push notifications. | | `prChatCards.ts` | Converts bounded PR polling transitions into durable `ade_card` episodes for linked Work chats: CI completion/failure, review received, merge ready, conflicts, and merged. CI jobs are failure-first, capped at three visible rows with `rowsTruncated`, and report an honest `degradedReason` + Retry action when both job/check detail sources fail instead of rendering an empty success state. Desktop-main and daemon-owned pollers call the same emitter, and failures are isolated per PR/session so one cold or malformed chat cannot stop the poll loop. | -| `prSummaryService.ts` | AI PR summary generator; caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | +| `prSummaryService.ts` | AI PR summary generator; uses the PR Descriptions model from Settings, otherwise a deterministic template (`This PR modifies N file(s).`). Caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | | `workflowGraph.ts` | `createWorkflowGraph` — reconstructs the CI pipeline DAG (`PrWorkflowGraph`) behind a swappable `WorkflowGraph` interface. GitHub's jobs API does not return `needs:`, so the graph is built by parsing the workflow YAML that actually ran and joining it to live run state. Parses **only** `jobs..needs` and `jobs..strategy.matrix`, with the existing `yaml` dep. Source order: lane worktree `git show :.github/workflows/` → GitHub Contents API `?ref=` (fork PRs / non-local repos) → `source: "none"` with an `unavailableReason`; it never guesses an edge. A single WORKFLOW degrades to flat swimlanes (not the whole graph) when a job uses a reusable workflow (`uses:`), has a `${{ }}` `name:`, or the YAML will not parse. Matrix legs collapse into one node whose state is the worst leg (failed > running > queued > passed > skipped); `tier` is a cycle-safe longest-path rank over `needs`; `criticalPath` is the longest-duration chain. Running nodes report live elapsed. Parsed YAML is cached per `(repo, headSha)` behind a TTL; the graph itself is always recomputed from live run state. | | `checkLogParser.ts` | Pure parsing for `prService.getCheckLog`: strips the per-line ISO timestamp, splits a job log on top-level `##[group]` / `##[endgroup]` markers into step sections, selects the failing step's section, and lifts a framework summary headline (vitest/jest/pytest/go) — falling through to `null` rather than guessing. `prService` owns the bounded streaming download (the logs endpoint 302s to a pre-signed blob; the redirect is followed without the API token and reading stops past a few MB, setting `truncated`). | | `githubPrStackService.ts` | Native GitHub stack decoding, persistence, and repository reconciliation | @@ -178,6 +178,12 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prRebaseResolver.ts` | Builds rebase-resolution prompts, launches chat session | | `resolverUtils.ts` | Shared permission-mode mapping, recent commit reading, comment noise filter, and the `looksLikeResolutionAck` heuristic that flags resolved-looking replies on unresolved review threads | +AI review runs live in `apps/desktop/src/main/services/review/reviewService.ts`. +`startReviewRun` requires an explicit `modelId`; empty throws +`Choose a review model before starting a review.` Launch context may +advertise a Codex catalog `recommendedModelId` as a picker hint — the +service never fills a model if the caller omits one. + GitHub access and relay dependencies: | File | Responsibility | @@ -242,7 +248,7 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `apps/desktop/src/shared/prChecksRollup.ts` | The canonical checks rollup — the single answer to "was this commit verified?". Two entry points: `rollupChecks(input)` for the service, which sees check runs, legacy commit statuses and required contexts separately; and `rollupPrChecks(rows)` for surfaces that only hold a flattened `PrCheck[]` (the ADE CLI, the `ade code` TUI, chat toolbars). State mapping delegates to `prPipelineState`, so the rollup can never disagree with the per-job rows rendered beneath it. Also exports `NON_CI_PRODUCER_APP_SLUGS`' predicates (`isCiProducerAppSlug`, `isCiProducerCheck`), the `COMMIT_STATUS_APP_SLUG` sentinel, `NO_CI_REASON`, and `CI_PENDING_GRACE_MS`. See [Checks rollup](#checks-rollup-what-counts-as-a-pass). | | `shared/PrCommentComposer.tsx` | Inline comment composer used at the bottom of the timeline view; thin wrapper around `ChatComposerShell` with Enter-to-submit semantics. | | `shared/PrReviewSubmitModal.tsx` | Modal that captures the optional review body and `Approve` / `Request changes` / `Comment` event before submitting through `ade.prs.submitReview`. | -| `shared/PrRequestAiReviewDialog.tsx` | "Request AI review" launcher rendered from the metadata rail; opens `LaneDialogShell`, picks a default Codex model + reasoning, and dispatches `startReviewRun`. | +| `shared/PrRequestAiReviewDialog.tsx` | "Request AI review" launcher rendered from the metadata rail; opens `LaneDialogShell`, lets the user pick a model + reasoning, and dispatches `startReviewRun`. The picker may start on the Codex catalog default as a hint. `startReviewRun` requires an explicit `modelId` and throws `Choose a review model before starting a review` if the caller omits one — the service does not fill Haiku or a first-available model. | | `shared/PrManageLaneDialogHost.tsx` | Hosts the shared `ManageLaneDialog` (delete / archive / adopt / appearance) from PR surfaces. Owns the local delete-confirmation state so the lane dialog can mount without polluting the PR detail pane. | | `shared/GitHubPrSearchInput.tsx`, `shared/GitHubRepoSyncBar.tsx` | Repo-PR header chrome shared by the GitHub tab and detail views: the magnifying-glass search input and the "syncing…" toolbar that drives manual snapshot refreshes. | | `shared/PrUserAvatar.tsx` | Shared GitHub user avatar with a fallback `UserCircle` glyph for users that don't have a cached avatar URL. Commit rows without a linked GitHub account use the Gravatar identicon URL the service derives from the commit-author email (see `prService.getCommits`), so the CSP allowlist includes `gravatar.com`. | @@ -1782,8 +1788,10 @@ Overview tab is selected): `prSummaryService` generates a `PrAiSummary` (summary text, risk areas, reviewer hotspots, unresolved concerns) via the AI integration -service and caches it in `pull_request_ai_summaries` keyed by -`(pr_id, head_sha)`. Pushing new commits advances `head_sha` +service when the PR Descriptions model is set in Settings, and caches +it in `pull_request_ai_summaries` keyed by `(pr_id, head_sha)`. An +empty picker returns a deterministic template instead of silently +picking Haiku. Pushing new commits advances `head_sha` (maintained by `prService.upsertFromGithub`) so the next read misses and the summary regenerates. `regenerateSummary` forces a rebuild regardless of cache state. @@ -1914,6 +1922,10 @@ through the existing command surface (`prs.createFromLane`, PRs with `source lane -> target lane` titles and no AI-generated title/body step; the explicit `prs.draftDescription` action remains available to callers that request PR-description drafting directly. +AI drafts use the PR Descriptions model from Settings when one is set; +if that picker is empty, ADE returns the deterministic template +instead of silently picking Haiku. `requireAi` callers get a Settings +prompt rather than a stub. The mobile client calls `getMobileSnapshot` on open and re-fetches on focus or after a successful mutation. Unmapped GitHub projections are local-only on the host, so webhook changes also emit a tiny `prs_updated` sync invalidation. diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 0569486b90..133a7e6713 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -49,7 +49,8 @@ and in tests. - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, transcript capture with a 16 MiB physical retention ceiling and lifetime logical byte offsets, runtime - state, AI auto-titles, tool-type routing, continuation-target backfill, + state, AI auto-titles (Settings title model, then stored launch + model; skip AI if both are missing), tool-type routing, continuation-target backfill, session-id based write/resize entry points used by mobile sync terminal control, `readTranscriptTail({ sessionId, ... })` which merges the on-disk transcript tail with the live PTY output tail so diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 422cea0f73..4bccea5641 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -207,7 +207,10 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: disposal. 6. Build initial `resumeMetadata` via `buildInitialResumeMetadata` — extracts a pre-assigned `--session-id ` from the Claude - startup command when present. + startup command when present. When the runtime launch carries a + model and the stored `resumeMetadata.launch.model` is empty (including + resume of an existing session), ADE writes that launch model onto the + metadata so later CLI title/summary walks have a session model to try. 7. Insert a new `terminal_sessions` row (or skip when resuming an existing one) and call `sessionService.create`. Set runtime state to `running`. @@ -670,7 +673,14 @@ command argument whenever structured resume metadata is available. Three paths, all gated by `sessionIntelligence.titles.enabled` and the presence of an AI integration service in non-guest mode (except the -Claude runtime-title capture, which is free): +Claude runtime-title capture, which is free). + +CLI AI titles and terminal summaries try models in this order: the +Settings title/summary model, then `resumeMetadata.launch.model`. +`tryCliAiModels` walks that list and continues on failure. If both +are missing, ADE skips the AI call and keeps the deterministic +title/summary already on the row — it does not throw, pick Haiku, or +use the first available provider. - **Output snippet title** (shell, cursor, aider, continue): `aiTitleTimer` fires after 6 s, sends up to 800 chars of @@ -711,8 +721,9 @@ Claude runtime-title capture, which is free): At session close, when `refreshOnComplete` is enabled, the transcript tail (last 2000 chars) is re-summarized into a final title through the -same service. Failure logs a warn and moves on — the title contract -never fails the session. +same setting-then-launch-model walk. Failure logs a warn and moves on — +the title contract never fails the session. End-of-session summaries +use the same walk against `sessionIntelligence.summaries.modelId`. ### Continuation metadata backfill