From baef21f700c51e735a2f492fa7b3864026400ed6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:31:50 -0400 Subject: [PATCH 1/3] fix(chat): make automatic naming actually name things Lane titles, chat titles, and Work status notes all shared one failure shape: a rule that was supposed to keep names short instead threw the name away, and the fallbacks behind it were worse than the thing they replaced. - Delete priorityNamingWords. It renamed any prompt that mentioned a provider plus a login-ish phrase to "-auth-login", inventing the word "auth" the prompt never contained -- and re-triggered on its own branch name, so the wrong name kept coming back. - Six words is now a guideline given to the model, not a rejection rule. An over-long lane title or branch fragment is clamped instead of discarded, and status notes are no longer amputated at word six (only the 72-character display budget remains). - Extract the naming prompts, the provider-failure classification, and the model-candidate chain into sessionNaming.ts. All three callers -- lane identity, chat auto-title, and the legacy lane-name suggestion -- now share one chain instead of three hand-copied ones that had drifted. - Chat auto-titling gains that chain plus a deterministic fallback, so a chat with a real prompt never sits on "Claude Chat". - Classify the account-rejects-this-model 400 as provider-level so the chain jumps providers; keep "not supported for/on/by" per-model so a capability gap still retries a sibling. - Guard the title write against a manual rename that lands mid-flight. - Clip over-long titles on a word boundary, not mid-word. - Right-pane list rows now clamp to the pane width, which longer status notes had started to overflow. --- apps/ade-cli/README.md | 2 +- apps/ade-cli/src/cli.ts | 6 +- .../src/services/sync/rosterBuilder.test.ts | 3 +- .../tuiClient/__tests__/RightPane.test.tsx | 21 ++ .../src/tuiClient/components/RightPane.tsx | 9 +- .../ade-cli-control-plane/SKILL.md | 6 +- .../services/chat/agentChatService.test.ts | 136 ++++++- .../main/services/chat/agentChatService.ts | 333 ++++++++---------- .../main/services/chat/sessionNaming.test.ts | 156 ++++++++ .../src/main/services/chat/sessionNaming.ts | 184 ++++++++++ .../desktop/src/shared/adeCliGuidance.test.ts | 10 +- apps/desktop/src/shared/adeCliGuidance.ts | 3 +- .../src/shared/laneNameFallback.test.ts | 25 +- apps/desktop/src/shared/laneNameFallback.ts | 34 -- .../src/shared/sessionStatusNote.test.ts | 15 +- apps/desktop/src/shared/sessionStatusNote.ts | 27 +- apps/desktop/src/shared/types/sessions.ts | 4 +- docs/features/agents/README.md | 2 +- docs/features/chat/README.md | 3 +- docs/features/chat/agent-routing.md | 21 +- docs/features/lanes/README.md | 12 +- .../features/terminals-and-sessions/README.md | 13 +- 22 files changed, 751 insertions(+), 274 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/sessionNaming.test.ts create mode 100644 apps/desktop/src/main/services/chat/sessionNaming.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 514cf243e6..6fb5825b32 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -480,7 +480,7 @@ ade chat read session-id --limit 20 --max-chars 8000 --text ade chat read session-id --page --cursor 4096 --limit 20 --max-chars 8000 --text ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" -ade chat note "testing desktop auth fallback" # update Work status (3–6 words, max 72 characters); add --session to target explicitly +ade chat note "testing desktop auth fallback" # update Work status (aim for 6 words or fewer; truncated past 72 characters); add --session to target explicitly ade chat ask "Which account should I use?" # escalate a blocking question; add --session to target explicitly ade session show session-id --text # settle/snooze state, and why a snoozed row came back ade session snooze session-id --for 1h # 30m|1h|4h|1d|1.5h; a bare number means minutes; relative durations cap at 30d diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 7e5b887582..91dfb71cae 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -29,6 +29,10 @@ import { type DoctorRow, } from "./commands/doctor"; export { readInstalledDesktopVersion } from "./commands/doctor"; +import { + MAX_STATUS_NOTE_CHARACTERS, + STATUS_NOTE_GUIDELINE_WORDS, +} from "../../desktop/src/shared/sessionStatusNote"; import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks"; import { buildPairingQrPayload } from "../../desktop/src/shared/pairingQr"; import { buildWebClientPairUrl } from "../../desktop/src/shared/webClientUrl"; @@ -1743,7 +1747,7 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --from-linear-issue ENG-431 --parent --type subagent Start a child chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active - $ ade chat note "testing desktop auth fallback" # Update the Work status line (3–6 words, max 72 characters) + $ ade chat note "testing desktop auth fallback" # Update the Work status line (aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer; truncated past ${MAX_STATUS_NOTE_CHARACTERS} characters) $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index cd736203ed..9a15efce30 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -263,7 +263,8 @@ describe("buildRosterSnapshot", () => { expect(byId.get("chat-run")).toMatchObject({ settledAt: "2026-01-02T00:01:00Z", - statusNote: "Indexing complete and waiting for final…", + // Eight words survive: the note only truncates past 72 characters. + statusNote: "Indexing complete and waiting for final review now", exitCode: null, }); expect(byId.get("chat-await")).toMatchObject({ diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 3966984063..88695550c7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -1379,3 +1379,24 @@ describe("RightPane feedback form", () => { expect(draft.summary).toBe("add dark mode"); }); }); + +describe("RightPane list rows", () => { + it("clips a long chat row to the pane instead of wrapping it onto extra lines", () => { + // A status note is bounded at 72 characters, not at six words, so a chat + // row can easily exceed the 38-column pane. It must clip, not wrap. + const longRow = "● claude-auth-login · claude · done: reworked automatic naming so the note keeps its meaning"; + const content = { + kind: "list" as const, + title: "Chats", + rows: [longRow, "● short row"], + action: { kind: "chat-list" as const, ids: ["a", "b"] }, + }; + const result = render(); + const lines = stripAnsi(result.lastFrame() ?? "").split("\n"); + const rowLines = lines.filter((line) => line.includes("claude-auth-login")); + expect(rowLines).toHaveLength(1); + expect(rowLines[0]?.length).toBeLessThanOrEqual(38); + expect(rowLines[0]).toContain("…"); + expect(lines.some((line) => line.includes("short row"))).toBe(true); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index b843154701..4a795c9ebc 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -2385,14 +2385,21 @@ function RightPaneComponent({ // list) can't scroll past the content into a blank pane. const listStart = Math.max(0, Math.min(scrollOffsetRows, Math.max(0, content.rows.length - DETAILS_BODY_MAX_LINES))); const visibleRows = content.rows.slice(listStart, listStart + DETAILS_BODY_MAX_LINES); + // A row is free text (a chat row carries an agent-authored status + // note up to its full 72-character budget), so it has to be clipped + // to the pane: an unclipped row wraps onto extra lines and pushes + // the "N more" footer and every row below it out of the window. + const rowWidth = Math.max(8, paneWidth - 4); return content.rows.length ? visibleRows.map((row, visibleIndex) => { const index = listStart + visibleIndex; + const prefix = content.action ? `${index === selectedIndex ? theme.rail : " "} ` : ""; return ( - {content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row} + {`${prefix}${endTruncate(row, Math.max(4, rowWidth - prefix.length))}`} ); }) : {content.emptyText ?? "No data."}; diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 5500f8fe20..ead454b8c4 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -229,9 +229,9 @@ row. If you are blocked, `ade chat ask ""` raises the row's hand. Treat the status line and hand-raise as separate signals: - **`ade chat note` explains the current state.** Write one concrete, - present-tense summary of **3–6 words and at most 72 characters**. ADE - truncates longer notes, so put the decisive state first and never write a - full sentence. + present-tense summary aiming for **6 words or fewer** — a guideline, not a + hard limit. ADE truncates past **72 characters**, so put the decisive state + first and never write a full sentence, but a long note still beats no note. Good: `CI green; awaiting Codex review` Bad: `Working`, `Still looking`, `Blocked`, or `Done`. - **`ade chat ask` means work cannot continue without a user answer.** Ask the diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2cf416971f..943ccd8b82 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1597,6 +1597,37 @@ function createService(overrides: Record = {}) { return { service, logger, laneService, sessionService, projectConfigService, aiIntegrationService }; } +function installAutoTitleAuth(): void { + // Auto-titling is skipped outright when no model is reachable. + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, + { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, + ] as never); +} + +function installAutoTitleClaudeStream(): void { + let streamCall = 0; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-session-1", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "assistant", + message: { content: [{ type: "text", text: "Done" }], usage: { input_tokens: 1, output_tokens: 1 } }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); +} + const HANDOFF_TEST_SHA = "1234567890abcdef1234567890abcdef12345678"; const HANDOFF_BEHIND_SHA = "0123456789abcdef0123456789abcdef01234567"; const HANDOFF_DIVERGED_SHA = "fedcba9876543210fedcba9876543210fedcba98"; @@ -13426,6 +13457,73 @@ describe("createAgentChatService", () => { expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); }); + + it("does not clobber a manual rename that lands while auto-titling is in flight", async () => { + const events: AgentChatEventEnvelope[] = []; + installAutoTitleClaudeStream(); + installAutoTitleAuth(); + + let renameDuringNaming: Promise | null = null; + const { service, sessionService, aiIntegrationService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + + // The naming request only resolves after the user has renamed the chat, + // which is exactly the race that used to overwrite their title and clear + // the manuallyNamed flag. + aiIntegrationService.summarizeTerminal.mockImplementation(async () => { + renameDuringNaming = service.updateSession({ + sessionId: session.id, + title: "User Picked This", + manuallyNamed: true, + }); + await renameDuringNaming; + return { text: "Model Picked That" } as never; + }); + + await service.sendMessage({ sessionId: session.id, text: "Build me a new feature" }); + await waitForEvent(events, (event): event is AgentChatEventEnvelope => event.event.type === "done"); + for (let i = 0; i < 40 && !renameDuringNaming; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + await renameDuringNaming; + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(renameDuringNaming, "auto-title never ran, so the race was not exercised").not.toBeNull(); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); + expect(sessionService.get(session.id)?.title).toBe("User Picked This"); + expect(sessionService.get(session.id)?.manuallyNamed).toBe(true); + expect(sessionService.updateMeta).not.toHaveBeenCalledWith( + expect.objectContaining({ title: "Model Picked That" }), + ); + }); + + it("falls back to a deterministic title when every naming model fails", async () => { + const events: AgentChatEventEnvelope[] = []; + installAutoTitleClaudeStream(); + installAutoTitleAuth(); + + const { service, sessionService, aiIntegrationService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + // A provider-level failure condemns each provider in turn, so the chain + // runs out of models — the chat must still never sit on "Claude Chat". + aiIntegrationService.summarizeTerminal.mockRejectedValue( + new Error("The model is not supported when using Codex with a ChatGPT account."), + ); + + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await service.sendMessage({ sessionId: session.id, text: "Rewrite the lane naming fallback chain" }); + await waitForEvent(events, (event): event is AgentChatEventEnvelope => event.event.type === "done"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const title = sessionService.get(session.id)?.title ?? ""; + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); + expect(title).not.toBe("Claude Chat"); + expect(title.split(/\s+/).filter(Boolean).length).toBeGreaterThanOrEqual(2); + expect(title.toLowerCase()).toContain("lane"); + }); }); // -------------------------------------------------------------------------- @@ -37729,10 +37827,36 @@ describe("suggestLaneNameFromPrompt", () => { }); expect(result.laneTitle).toBe("Claude OAuth Login"); - expect(result.branchFragment).toBe("claude-auth-login-button"); + expect(result.branchFragment).toBe("claude-auth-login-button-hangs"); + }); + + it("clamps an over-long AI identity instead of discarding it for a slug", async () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, + ]); + const { service, aiIntegrationService } = createSuggestService(); + // Six words is guidance for the model, not a gate: a seven-word answer is + // trimmed, never thrown away in favour of the deterministic slug. + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValueOnce({ + text: JSON.stringify({ + laneTitle: "Rework Session Naming Fallback Chain For Chats", + branchFragment: "rework-session-naming-fallback-chain-for-chats", + }), + } as any); + + const result = await service.generateAutoLaneIdentity({ + prompt: "Rework the session naming fallback chain", + modelId: "openai/gpt-5.4", + laneId: "lane-1", + temporaryBranch: "ade/1a2b3c4d", + }); + + expect(result.source).toBe("ai"); + expect(result.laneTitle).toBe("Rework Session Naming Fallback Chain For"); + expect(result.branchFragment).toBe("rework-session-naming-fallback-chain-for"); }); - it("treats fully invalid structured fields as deterministic fallback", async () => { + 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: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, ]); @@ -37749,11 +37873,13 @@ describe("suggestLaneNameFromPrompt", () => { }); expect(result).toMatchObject({ - laneTitle: "Claude Auth Login Button", - branchFragment: "claude-auth-login-button", + laneTitle: "Claude Auth Login Button Hangs", + branchFragment: "claude-auth-login-button-hangs", source: "deterministic", }); - expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(1); + // 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); }); it("uses the configured naming model before the launched model", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 450c34c1d4..83896e4f2d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -486,7 +486,9 @@ import { extractLeadingSlashCommand, isProviderSlashCommandInput } from "../../. import { deriveDeterministicAutoLaneIdentity, deriveDeterministicLaneNameFromPrompt, + deriveDeterministicLaneTitleFromPrompt, GENERIC_LANE_FALLBACK_NAME, + GENERIC_LANE_FALLBACK_TITLE, genericLaneFallbackName, genericSuffixFromLaneFallbackName, } from "../../../shared/laneNameFallback"; @@ -539,6 +541,15 @@ import { resolveCursorSdkModelSelectionParams, } from "./cursorModelsDiscovery"; import { discoverDroidSdkModelDescriptors } from "./droidModelsDiscovery"; +import { + AUTO_LANE_IDENTITY_JSON_SCHEMA, + AUTO_TITLE_SYSTEM_PROMPT, + buildNamingModelCandidates, + LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT, + LEGACY_LANE_NAME_SYSTEM_PROMPT, + MAX_NAMING_WORDS, + runNamingAcrossProviders, +} from "./sessionNaming"; import { mapCursorSdkMessageToChatEvents, mapCursorSdkRunResultToDoneEvent, @@ -2779,20 +2790,6 @@ const DEFAULT_DROID_MODEL = DEFAULT_DROID_DESCRIPTOR?.providerModelId ?? "claude const DEFAULT_REASONING_EFFORT = "medium"; const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5"; -/** - * Failures that condemn every model behind a provider — a missing or unusable - * CLI, an account that cannot run the model, auth, or quota. Retrying a sibling - * model on the same provider just burns another spawn, so naming skips ahead to - * a different provider instead. - */ -const PROVIDER_LEVEL_NAMING_FAILURE_PATTERN = - /enoent|eacces|spawn\b|not found|no such file|unauthor|unauthenticated|not (?:logged in|authenticated)|\b40[13]\b|api[_ -]?key|credential|not supported with|unsupported model|model[_ -]?not[_ -]?found|does not exist|insufficient|quota|rate limit/i; - -function isProviderLevelNamingFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error ?? ""); - return PROVIDER_LEVEL_NAMING_FAILURE_PATTERN.test(message); -} - const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024; const CLAUDE_TOOL_OUTPUT_TRIM_THRESHOLD_BYTES = 200 * 1024; const CLAUDE_TOOL_OUTPUT_TRIM_PREVIEW_CHARS = 24 * 1024; @@ -2952,42 +2949,6 @@ function evictOldestEntries(map: Map, maxSize: number): void { map.delete(next.value); } } -const AUTO_TITLE_SYSTEM_PROMPT = `You title software development chat sessions. -Return only the title text. -- Use 2 to 6 words. -- Focus on the task, feature, bug, or deliverable. -- Never start with Completed, Complete, Done, Finished, Resolved, or Success. -- No quotes. -- No emoji. -- No trailing punctuation.`; - -const LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT = `Generate the stable identity for an automatically created software workspace. -Return strict JSON only: {"laneTitle":"...","branchFragment":"..."}. -laneTitle: -- Natural readable user-facing title, 2 to 6 words, with spaces and natural title capitalization. -- Preserve meaningful capitalization such as ADE, GitHub, iOS, macOS, Codex, OpenAI, and OAuth. -- Describe the durable workstream, outcome, feature, bug, UI surface, or command. -- Prefer meaningful nouns and product concepts over procedural wording. -- Avoid prompt, question, request, conversation, chat, task, discuss, investigate, or look into unless genuinely part of a feature name. -- Avoid generic leading verbs such as Fix, Update, Improve, Handle, or Work On when a specific noun phrase is available. -- Do not repeat the user's sentence verbatim. No quotes, emoji, or trailing punctuation. -branchFragment: -- Describe the same workstream in 2 to 6 short specific words. -- Lowercase ASCII and hyphen-separated. Do not include the ade/ prefix. -- No spaces, quotes, refs/heads/, punctuation-heavy text, or leading/trailing separators. -- Keep it concise and safe for GitHub, PR lists, terminals, and Git branch naming. -Attached images are primary context for visual and UI requests.`; -const LEGACY_LANE_NAME_SYSTEM_PROMPT = `Name a git worktree lane. -Return only a short 2 to 5 word slug-friendly name with no slash, quotes, emoji, or trailing punctuation.`; -const AUTO_LANE_IDENTITY_JSON_SCHEMA = { - type: "object", - additionalProperties: false, - properties: { - laneTitle: { type: "string" }, - branchFragment: { type: "string" }, - }, - required: ["laneTitle", "branchFragment"], -} as const; const CODEX_REASONING_EFFORTS: Array<{ effort: string; description: string }> = [ { effort: "none", description: "No extra reasoning when supported by the runtime." }, { effort: "minimal", description: "Minimal reasoning for fastest responses." }, @@ -4325,7 +4286,17 @@ function sanitizeAutoTitle(raw: string, maxChars = AUTO_TITLE_MAX_CHARS): string if (/^(session closed|chat completed)\b/u.test(collapsed)) return null; - return normalized.length > maxChars ? normalized.slice(0, maxChars).trimEnd() : normalized; + if (normalized.length <= maxChars) return normalized; + // Cut on a word boundary so an over-long title reads as a shorter phrase + // instead of stopping mid-word ("…installation simplific"). + const clipped = normalized.slice(0, maxChars); + const lastBoundary = clipped.lastIndexOf(" "); + // Cut back to the last space only while at least half the budget survives — + // a title whose first word is longer than that is better clipped than gutted. + const trimmed = (lastBoundary >= Math.floor(maxChars / 2) ? clipped.slice(0, lastBoundary) : clipped) + .replace(/[^\p{L}\p{N})\]]+$/u, "") + .trimEnd(); + return trimmed.length ? trimmed : clipped.trimEnd(); } function fallbackLaneNameFromPrompt(prompt: string): string { @@ -4365,8 +4336,10 @@ function normalizeSuggestedLaneTitle(raw: string): string | null { const title = sanitizeAutoTitle(raw, 72); if (!title) return null; const words = title.split(/\s+/u).filter(Boolean); - if (words.length < 2 || words.length > 6 || /[-_/]{2,}/u.test(title)) return null; - return title; + if (words.length < 2 || /[-_/]{2,}/u.test(title)) return null; + // Six words is the guideline given to the model, not a rejection rule: a + // seven-word answer is clamped, never discarded in favour of a slug. + 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 { @@ -4377,10 +4350,10 @@ function parseAutoLaneIdentity(raw: string): { laneTitle: string | null; branchF 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); - const branchFragment = branchWords.length >= 2 - && branchWords.length <= 6 - && /^[a-z0-9]+(?:-[a-z0-9]+)+$/u.test(branchRaw) - ? normalizeSuggestedLaneName(branchRaw) + // 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, @@ -10788,24 +10761,21 @@ export function createAgentChatService(args: { const availableModels = await getAvailableRegistryModels(auth); if (!availableModels.length) return; - const preferredModelId = - [ + // 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({ + availableModels, + preferred: [ config.titleModelId, DEFAULT_AUTO_TITLE_MODEL_ID, - "anthropic/claude-haiku-4-5", - "openai/gpt-5.4-mini", - "openai/gpt-5.2", - "openai/gpt-5.4", + managed.session.modelId, + managed.session.model, 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; + ], + }); + if (!candidateModelIds.length) return; const laneName = sessionService.get(managed.session.id)?.laneName ?? "Current lane"; const currentTitle = sessionService.get(managed.session.id)?.title ?? null; @@ -10825,30 +10795,67 @@ export function createAgentChatService(args: { managed.autoTitleInFlight = true; try { - 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", - }); - // Re-check after async — user may have manually renamed while the request was in flight. - if (sessionIsManuallyNamed(managed)) return; - if (managed.runtimeTitleAdopted) return; - const nextTitle = setManagedSessionTitle(managed, result.text); - if (!nextTitle) return; + // 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. + 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), + }); + }, + }); + if (adopted) { + managed.autoTitleStage = args.stage; + return; + } + + // Every model failed. A chat with a real user prompt must never sit on its + // provider default title, so derive one from the seed the same way an + // automatically created lane derives its title. + if (sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted) return; + // Only rescue a still-default title — never downgrade a title an earlier + // stage already generated. + const titleNow = sessionService.get(managed.session.id)?.title ?? null; + if (hasCustomChatSessionTitle(titleNow, managed.session.provider)) return; + const deterministic = deriveDeterministicLaneTitleFromPrompt(seed); + // Hold the deterministic title to the same floor as a model-written one: + // a lone word is not a better answer than the provider default. + if (!deterministic || deterministic === GENERIC_LANE_FALLBACK_TITLE) return; + if (deterministic.split(/\s+/u).filter(Boolean).length < 2) return; + const fallbackTitle = setManagedSessionTitle(managed, deterministic); + if (!fallbackTitle) return; managed.autoTitleStage = args.stage; - } catch (error) { - logger.warn("agent_chat.auto_title_failed", { + logger.info("agent_chat.auto_title_deterministic_fallback", { sessionId: managed.session.id, stage: args.stage, - modelId: descriptor.id, - error: error instanceof Error ? error.message : String(error), + attemptCount, + titleLength: fallbackTitle.length, }); } finally { managed.autoTitleInFlight = false; @@ -11160,7 +11167,7 @@ export function createAgentChatService(args: { const temporaryBranch = String(args.temporaryBranch ?? "").trim(); let identity = fallback; let source: "ai" | "deterministic" = "deterministic"; - let selectedModelId = ""; + let selectedModelId: string | null = null; let attemptCount = 0; let cwd = projectRoot; @@ -11194,70 +11201,27 @@ export function createAgentChatService(args: { if (config.titleGenerationEnabled !== false) { const auth = await detectAuth(); const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); - const availableIds = new Set(availableModels.map((descriptor) => descriptor.id)); - const pickAvailable = (...candidates: Array): string => - candidates.find((candidate): candidate is string => - typeof candidate === "string" && availableIds.has(candidate.trim()))?.trim() ?? ""; - // Chain: configured naming model -> the model this chat was launched // with -> a different provider -> deterministic. The launched model is // always present (not only when no naming model is configured) and a // cross-provider candidate is always reachable, so an outage confined // to one provider (auth, missing binary, account-rejected model) can no // longer end naming outright. - const launchedModelId = pickAvailable(chatModelId, requestedModelId); - const primaryModelId = pickAvailable( - config.titleModelId, - launchedModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - availableModels[0]?.id, - ); - const primaryDescriptor = getModelById(primaryModelId); - const primaryProvider = primaryDescriptor - ? resolveProviderGroupForModel(primaryDescriptor) - : null; - const launchedDescriptor = launchedModelId ? getModelById(launchedModelId) : null; - const launchedProvider = launchedDescriptor - ? resolveProviderGroupForModel(launchedDescriptor) - : null; - const leadingProviders = new Set( - [primaryProvider, launchedProvider].filter((group): group is ModelProviderGroup => group !== null), - ); - const crossProviderFallback = availableModels.find( - (descriptor) => !leadingProviders.has(resolveProviderGroupForModel(descriptor)), - )?.id; - const sameProviderFallback = availableModels.find( - (descriptor) => descriptor.id !== primaryModelId - && primaryProvider !== null - && resolveProviderGroupForModel(descriptor) === primaryProvider, - )?.id; - const candidateModelIds = [ - primaryModelId, - launchedModelId, - crossProviderFallback, - sameProviderFallback, - availableModels.find((descriptor) => descriptor.id !== primaryModelId)?.id, - ].reduce((acc, candidate) => { - const modelId = typeof candidate === "string" ? candidate.trim() : ""; - if (!modelId || acc.includes(modelId) || !availableIds.has(modelId)) return acc; - return [...acc, modelId]; - }, []); + const candidateModelIds = buildNamingModelCandidates({ + availableModels, + preferred: [ + config.titleModelId, + chatModelId, + requestedModelId, + DEFAULT_AUTO_TITLE_MODEL_ID, + availableModels[0]?.id, + ], + }); // Naming runs in the background, but it still must not walk the whole // registry when everything is down. - const maxNamingAttempts = 3; - const exhaustedProviders = new Set(); - for (const candidateModelId of candidateModelIds) { - if (attemptCount >= maxNamingAttempts) break; - const descriptor = getModelById(candidateModelId); - if (!descriptor) continue; - const candidateProvider = resolveProviderGroupForModel(descriptor); - // A provider-level failure condemns every model behind it, so skip to - // the next candidate from a provider that has not failed that way. - if (exhaustedProviders.has(candidateProvider)) continue; - attemptCount += 1; - selectedModelId = descriptor.id; - try { + const attempt = await runNamingAcrossProviders<{ laneTitle: string; branchFragment: string }>(candidateModelIds, { + run: async (descriptor) => { const result = await runSessionIntelligencePrompt({ cwd, modelId: descriptor.id, @@ -11269,31 +11233,30 @@ export function createAgentChatService(args: { taskType: "session_title", }); const parsed = parseAutoLaneIdentity(result.text); - if (!parsed) break; - if (!parsed.laneTitle && !parsed.branchFragment) break; - identity = { + if (!parsed || (!parsed.laneTitle && !parsed.branchFragment)) return null; + return { laneTitle: parsed.laneTitle ?? fallback.laneTitle, branchFragment: parsed.branchFragment ?? fallback.branchFragment, }; - source = "ai"; - break; - } catch (error) { - const providerLevel = isProviderLevelNamingFailure(error); - if (providerLevel) { - exhaustedProviders.add(candidateProvider); - } + }, + onFailure: ({ descriptor, provider, providerLevelFailure, error }) => { logger.warn("agent_chat.suggest_lane_name_failed", { laneId: sourceLaneId, temporaryBranch, - modelId: candidateModelId, + modelId: descriptor.id, requestedModelId, chatModelId: chatModelId || null, - provider: candidateProvider, - providerLevelFailure: providerLevel, - attemptCount, + provider, + providerLevelFailure, error: error instanceof Error ? error.message : String(error), }); - } + }, + }); + attemptCount = attempt.attemptCount; + selectedModelId = attempt.selectedModelId; + if (attempt.result) { + identity = attempt.result; + source = "ai"; } } } @@ -11348,37 +11311,37 @@ export function createAgentChatService(args: { if (config.titleGenerationEnabled === false) return fallback(); const auth = await detectAuth(); const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); - const availableIds = new Set(availableModels.map((descriptor) => descriptor.id)); - const candidates = [ - config.titleModelId, - requestedModelId, - DEFAULT_AUTO_TITLE_MODEL_ID, - "anthropic/claude-haiku-4-5", - availableModels[0]?.id, - ].filter((candidate, index, all): candidate is string => - typeof candidate === "string" - && candidate.length > 0 - && availableIds.has(candidate) - && all.indexOf(candidate) === index); - for (const modelId of candidates) { - try { + const candidateModelIds = buildNamingModelCandidates({ + availableModels, + preferred: [ + config.titleModelId, + requestedModelId, + DEFAULT_AUTO_TITLE_MODEL_ID, + availableModels[0]?.id, + ], + }); + const { result: suggested } = await runNamingAcrossProviders(candidateModelIds, { + run: async (descriptor) => { const result = await runSessionIntelligencePrompt({ cwd: projectRoot, - modelId, + modelId: descriptor.id, systemPrompt: LEGACY_LANE_NAME_SYSTEM_PROMPT, prompt: `User message for the new lane:\n${prompt.slice(0, 2000)}`, taskType: "session_title", }); - const normalized = normalizeSuggestedLaneName(result.text); - if (normalized) return normalized; - } catch (error) { + return normalizeSuggestedLaneName(result.text); + }, + onFailure: ({ descriptor, provider, providerLevelFailure, error }) => { logger.warn("agent_chat.suggest_lane_name_failed", { - modelId, + modelId: descriptor.id, requestedModelId, + provider, + providerLevelFailure, error: error instanceof Error ? error.message : String(error), }); - } - } + }, + }); + if (suggested) return suggested; } catch (error) { logger.warn("agent_chat.suggest_lane_name_unavailable", { modelId: requestedModelId, diff --git a/apps/desktop/src/main/services/chat/sessionNaming.test.ts b/apps/desktop/src/main/services/chat/sessionNaming.test.ts new file mode 100644 index 0000000000..ee6907edb7 --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionNaming.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getAvailableModels, type ModelDescriptor } from "../../../shared/modelRegistry"; +import { + buildNamingModelCandidates, + isProviderLevelNamingFailure, + runNamingAcrossProviders, +} from "./sessionNaming"; + +// The registry is the source of truth for provider grouping, so the fixtures are +// real descriptors rather than hand-built shapes that could drift from it. +const ALL_MODELS = getAvailableModels([ + { type: "cli-subscription", cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, + { type: "cli-subscription", cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, +] as never).filter((descriptor) => !descriptor.deprecated); + +function modelsFor(...prefixes: string[]): ModelDescriptor[] { + return ALL_MODELS.filter((descriptor) => prefixes.some((prefix) => descriptor.id.startsWith(prefix))); +} + +const OPENAI_MODELS = modelsFor("openai/"); +const ANTHROPIC_MODELS = modelsFor("anthropic/"); + +describe("isProviderLevelNamingFailure", () => { + it("condemns the provider when the account itself cannot run the model", () => { + // The 400 that silently broke every naming call: the CLI is healthy, the + // account is not entitled, so a sibling model on the same provider is a + // wasted spawn. + expect(isProviderLevelNamingFailure( + new Error(`{"status":400,"message":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`), + )).toBe(true); + expect(isProviderLevelNamingFailure(new Error("spawn codex ENOENT"))).toBe(true); + expect(isProviderLevelNamingFailure(new Error("401 unauthorized"))).toBe(true); + }); + + it("does not condemn the provider for a single model lacking a capability", () => { + // These must still retry a sibling model — condemning the provider here + // would skip straight to the deterministic slug. + expect(isProviderLevelNamingFailure(new Error("Image input is not supported for this model"))).toBe(false); + expect(isProviderLevelNamingFailure(new Error("json schema is not supported by this model"))).toBe(false); + expect(isProviderLevelNamingFailure(new Error("socket hang up"))).toBe(false); + }); +}); + +describe("buildNamingModelCandidates", () => { + it("always reaches a different provider so a single-provider outage cannot end naming", () => { + 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); + }); + + it("drops unavailable and duplicate preferences instead of attempting them", () => { + const candidates = buildNamingModelCandidates({ + availableModels: ALL_MODELS, + preferred: [null, "", "openai/does-not-exist", ANTHROPIC_MODELS[0]?.id, ANTHROPIC_MODELS[0]?.id], + }); + + expect(candidates[0]).toBe(ANTHROPIC_MODELS[0]?.id); + expect(candidates).not.toContain("openai/does-not-exist"); + }); + + it("returns nothing when no preferred model is available", () => { + expect(buildNamingModelCandidates({ availableModels: [], preferred: ["openai/gpt-5.4-mini"] })).toEqual([]); + }); +}); + +describe("runNamingAcrossProviders", () => { + it("skips the rest of a condemned provider without spending an attempt on it", async () => { + const attempted: string[] = []; + const onFailure = vi.fn(); + const candidates = buildNamingModelCandidates({ + availableModels: ALL_MODELS, + preferred: [OPENAI_MODELS[0]?.id, OPENAI_MODELS[1]?.id], + }); + + const { result, attemptCount, selectedModelId } = await runNamingAcrossProviders(candidates, { + run: async (descriptor) => { + attempted.push(descriptor.id); + if (descriptor.id.startsWith("openai/")) { + throw new Error("The model is not supported when using Codex with a ChatGPT account."); + } + return "Rename Naming Fallback"; + }, + onFailure, + }); + + expect(result).toBe("Rename Naming Fallback"); + 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(onFailure).toHaveBeenCalledTimes(1); + expect(onFailure.mock.calls[0]![0]).toMatchObject({ providerLevelFailure: true }); + }); + + 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] }), + { + run: async (descriptor) => { + attempted.push(descriptor.id); + return attempted.length === 1 ? null : "Second Model Wins"; + }, + onFailure: vi.fn(), + }, + ); + + expect(attempted.length).toBeGreaterThan(1); + expect(result).toBe("Second Model Wins"); + }); + + it("stops without adopting anything once shouldStop flips", async () => { + let renamedByUser = false; + const onFailure = vi.fn(); + + const { result, attemptCount } = await runNamingAcrossProviders( + buildNamingModelCandidates({ availableModels: ALL_MODELS, preferred: [OPENAI_MODELS[0]?.id] }), + { + shouldStop: () => renamedByUser, + run: async () => { + renamedByUser = true; + return "Title The User Never Wanted"; + }, + onFailure, + }, + ); + + expect(result).toBeNull(); + expect(attemptCount).toBe(1); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it("gives up after three attempts instead of walking the whole registry", async () => { + const attempted: string[] = []; + const { result, attemptCount } = await runNamingAcrossProviders( + ALL_MODELS.map((descriptor) => descriptor.id), + { + run: async (descriptor) => { + attempted.push(descriptor.id); + throw new Error("socket hang up"); + }, + onFailure: vi.fn(), + }, + ); + + expect(result).toBeNull(); + expect(attemptCount).toBe(3); + expect(attempted).toHaveLength(3); + }); +}); diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts new file mode 100644 index 0000000000..6bf9ee2449 --- /dev/null +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -0,0 +1,184 @@ +/** + * Session naming: the prompts, failure classification, and model-candidate + * chain shared by automatic lane identity and chat auto-titling. + * + * 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 + * had already drifted apart. They live here so "the same chain" is a fact + * rather than a comment. + */ +import { + getModelById, + resolveProviderGroupForModel, + type ModelDescriptor, + type ModelProviderGroup, +} from "../../../shared/modelRegistry"; + +/** + * The word count every naming surface aims for. It is a guideline handed to the + * model, never a rejection rule: an over-long answer is clamped, because a + * clamped real name beats falling back to a deterministic slug. + */ +export const MAX_NAMING_WORDS = 6; + +export const AUTO_TITLE_SYSTEM_PROMPT = `You title software development chat sessions. +Return only the title text. +- Aim for 2 to ${MAX_NAMING_WORDS} words. This is a guideline, not a hard limit: a slightly longer title is far better than no title. +- Focus on the task, feature, bug, or deliverable. +- Never start with Completed, Complete, Done, Finished, Resolved, or Success. +- No quotes. +- No emoji. +- No trailing punctuation.`; + +export const LANE_NAME_FROM_PROMPT_SYSTEM_PROMPT = `Generate the stable identity for an automatically created software workspace. +Return strict JSON only: {"laneTitle":"...","branchFragment":"..."}. +Aim for ${MAX_NAMING_WORDS} words or fewer in both fields. That is a guideline, not a hard limit: a slightly longer answer is far better than an empty or refused one. +laneTitle: +- Natural readable user-facing title, 2 to ${MAX_NAMING_WORDS} words, with spaces and natural title capitalization. +- Preserve meaningful capitalization such as ADE, GitHub, iOS, macOS, Codex, OpenAI, and OAuth. +- Describe the durable workstream, outcome, feature, bug, UI surface, or command. +- Prefer meaningful nouns and product concepts over procedural wording. +- Avoid prompt, question, request, conversation, chat, task, discuss, investigate, or look into unless genuinely part of a feature name. +- Avoid generic leading verbs such as Fix, Update, Improve, Handle, or Work On when a specific noun phrase is available. +- Do not repeat the user's sentence verbatim. No quotes, emoji, or trailing punctuation. +branchFragment: +- Describe the same workstream in 2 to ${MAX_NAMING_WORDS} short specific words. +- Lowercase ASCII and hyphen-separated. Do not include the ade/ prefix. +- No spaces, quotes, refs/heads/, punctuation-heavy text, or leading/trailing separators. +- Keep it concise and safe for GitHub, PR lists, terminals, and Git branch naming. +Attached images are primary context for visual and UI requests.`; + +export const LEGACY_LANE_NAME_SYSTEM_PROMPT = `Name a git worktree lane. +Return only a short slug-friendly name with no slash, quotes, emoji, or trailing punctuation. +Aim for ${MAX_NAMING_WORDS} words or fewer — a guideline, not a hard limit. A slightly longer name is far better than no name.`; + +export const AUTO_LANE_IDENTITY_JSON_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + laneTitle: { type: "string" }, + branchFragment: { type: "string" }, + }, + required: ["laneTitle", "branchFragment"], +} as const; + +/** + * Failures that condemn every model behind a provider — a missing or unusable + * CLI, an account that cannot run the model, auth, or quota. Retrying a sibling + * model on the same provider just burns another spawn, so naming skips ahead to + * a different provider instead. + * + * "not supported with/when" covers the account-rejects-this-model 400 + * ("The 'x' model is not supported when using Codex with a ChatGPT account"). + * It deliberately excludes "not supported for/on/by", which describe a single + * model lacking a capability — those must still retry a sibling model. + */ +const PROVIDER_LEVEL_NAMING_FAILURE_PATTERN = + /enoent|eacces|spawn\b|not found|no such file|unauthor|unauthenticated|not (?:logged in|authenticated)|\b40[13]\b|api[_ -]?key|credential|not supported (?:with|when)|unsupported model|model[_ -]?not[_ -]?found|does not exist|insufficient|quota|rate limit/i; + +export function isProviderLevelNamingFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + return PROVIDER_LEVEL_NAMING_FAILURE_PATTERN.test(message); +} + +/** + * 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. A cross-provider candidate is always reachable, so an + * outage confined to one provider cannot end naming outright. + */ +export function buildNamingModelCandidates(args: { + availableModels: ModelDescriptor[]; + /** Ordered preference list; unavailable and duplicate ids are dropped. */ + preferred: Array; +}): string[] { + const availableIds = new Set(args.availableModels.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 = args.availableModels.find( + (entry) => !leadingProviders.has(resolveProviderGroupForModel(entry)), + )?.id; + const sameProviderFallback = args.availableModels.find( + (entry) => !preferred.includes(entry.id) + && primaryProvider !== null + && resolveProviderGroupForModel(entry) === primaryProvider, + )?.id; + + return availableInOrder([ + ...preferred, + crossProviderFallback, + sameProviderFallback, + args.availableModels.find((entry) => !preferred.includes(entry.id))?.id, + ]); +} + +const MAX_NAMING_ATTEMPTS = 3; + +export type NamingAttemptFailure = { + descriptor: ModelDescriptor; + provider: ModelProviderGroup; + providerLevelFailure: boolean; + attemptCount: number; + error: unknown; +}; + +/** + * Walk the candidate chain until one model returns a usable result. A + * provider-level failure condemns every remaining model behind that provider. + * `run` returning null means "this model answered, but unusably" — the next + * candidate still gets a turn, because a working model beats a slug. + */ +export async function runNamingAcrossProviders( + candidateModelIds: string[], + options: { + /** Abandon the chain without adopting anything — e.g. the user renamed mid-flight. */ + shouldStop?: () => boolean; + run: (descriptor: ModelDescriptor) => Promise; + onFailure: (failure: NamingAttemptFailure) => void; + }, +): Promise<{ result: T | null; attemptCount: number; selectedModelId: string | null }> { + const exhaustedProviders = new Set(); + let attemptCount = 0; + let selectedModelId: string | null = null; + + for (const candidateModelId of candidateModelIds) { + if (attemptCount >= MAX_NAMING_ATTEMPTS) break; + if (options.shouldStop?.()) break; + const descriptor = getModelById(candidateModelId); + if (!descriptor) continue; + const provider = resolveProviderGroupForModel(descriptor); + if (exhaustedProviders.has(provider)) continue; + attemptCount += 1; + selectedModelId = descriptor.id; + try { + const result = await options.run(descriptor); + if (options.shouldStop?.()) break; + if (result !== null) { + return { result, attemptCount, selectedModelId }; + } + } catch (error) { + const providerLevelFailure = isProviderLevelNamingFailure(error); + if (providerLevelFailure) exhaustedProviders.add(provider); + options.onFailure({ descriptor, provider, providerLevelFailure, attemptCount, error }); + } + } + + return { result: null, attemptCount, selectedModelId }; +} diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 4f0fd680dc..68929d810a 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -1,3 +1,4 @@ +import { MAX_STATUS_NOTE_CHARACTERS, STATUS_NOTE_GUIDELINE_WORDS } from "./sessionStatusNote"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -36,8 +37,8 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).toContain("ade chat scheduled-work create"); expect(bootstrap).toContain("tracked provider CLIs"); expect(bootstrap).toContain('ade chat note "testing desktop auth fallback"'); - expect(bootstrap).toContain("use 3–6 words"); - expect(bootstrap).toContain("72 characters"); + expect(bootstrap).toContain("6 words or fewer"); + expect(bootstrap).toContain(`${MAX_STATUS_NOTE_CHARACTERS} characters`); expect(bootstrap).toContain('ade chat ask ""'); expect(bootstrap).toContain("a note alone can leave an idle row looking Done"); expect(bootstrap).toContain("The next accepted user message clears the prior hand-raise"); @@ -75,8 +76,9 @@ describe("ADE bootstrap guidance", () => { for (const invariant of [ "ade chat note", "ade chat ask", - "3–6 words", - "72 characters", + "6 words or fewer", + `${MAX_STATUS_NOTE_CHARACTERS} characters`, + `${STATUS_NOTE_GUIDELINE_WORDS} words or fewer`, "next accepted user message clears the prior hand-raise", "You cannot settle or unsettle a session", ]) { diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 84a91d1bba..17bfad3cca 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -1,3 +1,4 @@ +import { MAX_STATUS_NOTE_CHARACTERS, STATUS_NOTE_GUIDELINE_WORDS } from "./sessionStatusNote"; import { formatAdeAgentSkillRootsForPrompt, getAdeAgentSkillRootsForPrompt } from "./agentSkillRoots"; export const adeBundledAgentSkills = [ @@ -25,7 +26,7 @@ export const adeBundledAgentSkills = [ */ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", - '- Working: `ade chat note "testing desktop auth fallback"`; use 3–6 words and at most 72 characters. Longer notes truncate.', + `- Working: \`ade chat note "testing desktop auth fallback"\`; aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer — a guideline, not a hard limit. Notes truncate past ${MAX_STATUS_NOTE_CHARACTERS} characters, so a long note still beats no note.`, '- Blocked on input: call `ade chat note ""`, then `ade chat ask ""`; a note alone can leave an idle row looking Done.', "- The next accepted user message clears the prior hand-raise. Re-note and re-ask before ending if still blocked.", '- Done: report it and leave `ade chat note ""`.', diff --git a/apps/desktop/src/shared/laneNameFallback.test.ts b/apps/desktop/src/shared/laneNameFallback.test.ts index cbeade3313..1576f071b0 100644 --- a/apps/desktop/src/shared/laneNameFallback.test.ts +++ b/apps/desktop/src/shared/laneNameFallback.test.ts @@ -13,14 +13,14 @@ import { describe("automatic lane identity fallback", () => { it("keeps the lane title readable and the branch fragment Git-friendly", () => { expect(deriveDeterministicAutoLaneIdentity("Can we discuss how ADE names auto-created lanes?")).toEqual({ - laneTitle: "Naming Auto Created Lanes", - branchFragment: "naming-auto-created-lanes", + laneTitle: "ADE Names Auto Created Lanes", + branchFragment: "ade-names-auto-created-lanes", }); }); it("preserves product capitalization in readable titles", () => { expect(deriveDeterministicLaneTitleFromPrompt("The Claude auth login button hangs after OAuth redirects")).toBe( - "Claude Auth Login Button", + "Claude Auth Login Button Hangs", ); }); @@ -37,14 +37,6 @@ describe("lane name fallback", () => { expect(deriveDeterministicLaneNameFromPrompt("Can you please fix the login bug?")).toBe("fix-login-bug"); }); - it("prefers concrete auth/login UI nouns over conversational lead-ins", () => { - expect( - deriveDeterministicLaneNameFromPrompt( - "correct me if im wrong, but i though ade had a way to detect failed claude creds and present a button ro usmthin in the chat to run claude auth login in ade chat temrinal, use context skill, and look into this", - ), - ).toBe("claude-auth-login-button"); - }); - it("keeps prompt-specific context for broad provider auth prompts", () => { expect(deriveDeterministicLaneNameFromPrompt("Debug the Claude OAuth token expiry bug")).toBe( "debug-claude-oauth-token-expiry", @@ -59,6 +51,17 @@ describe("lane name fallback", () => { ).toBe("debug-cursor-sdk-mobile-sync"); }); + it("does not collapse an unrelated prompt into a provider auth name", () => { + // Regression: a keyword trap used to rename any prompt that mentioned a + // provider plus a login-ish phrase to "-auth-login", inventing + // words the prompt never contained. + const name = deriveDeterministicLaneNameFromPrompt( + "Plan ADE distribution and packaging. Claude runs the handoff, and users sign in once.", + ); + expect(name).not.toBe("claude-auth-login"); + expect(name).toContain("distribution"); + }); + it("turns a URL-heavy 'take a look at' prompt into clean tokens, not url noise", () => { // Regression for "take-look-at-https-github". expect( diff --git a/apps/desktop/src/shared/laneNameFallback.ts b/apps/desktop/src/shared/laneNameFallback.ts index 5f9de5120b..f9b7f349bf 100644 --- a/apps/desktop/src/shared/laneNameFallback.ts +++ b/apps/desktop/src/shared/laneNameFallback.ts @@ -129,8 +129,6 @@ export function deriveDeterministicLaneNameFromPrompt( ): string { const collapsed = cleanPromptForNaming(prompt); if (!collapsed.length) return genericLaneFallbackName(options.genericSuffix); - const priorityWords = priorityNamingWords(collapsed); - if (priorityWords.length) return priorityWords.join("-"); const tokens = collapsed.toLowerCase().match(/[a-z0-9]+/g) ?? []; const meaningfulWords = tokens .filter((token) => token.length > 1 && !LANE_FALLBACK_STOPWORDS.has(token)) @@ -165,7 +163,6 @@ export function deriveDeterministicLaneTitleFromPrompt(prompt: string): string { return fragment .split("-") .filter(Boolean) - .slice(0, 6) .map((word) => PRESERVED_TITLE_WORDS.get(word) ?? `${word.charAt(0).toUpperCase()}${word.slice(1)}`) .join(" "); } @@ -184,37 +181,6 @@ export function isAutoLaneTemporaryBranch(branchRef: string): boolean { return AUTO_LANE_TEMP_BRANCH_RE.test(branchRef); } -function priorityNamingWords(cleanedPrompt: string): string[] { - const normalized = cleanedPrompt.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); - if (!normalized) return []; - if ( - /\b(?:name|names|naming)\b/u.test(normalized) - && /\bauto(?:matically)?\b/u.test(normalized) - && /\bcreated?\b/u.test(normalized) - && /\blanes?\b/u.test(normalized) - ) { - return ["naming", "auto", "created", "lanes"]; - } - const provider = [ - "claude", - "codex", - "cursor", - "droid", - "opencode", - ].find((candidate) => new RegExp(`\\b${candidate}\\b`, "u").test(normalized)) - ?? (/\bopen code\b/u.test(normalized) ? "opencode" : null); - if (!provider) return []; - const mentionsAuth = /\b(auth|authenticate|authentication|credential|credentials|creds|oauth)\b/u.test(normalized); - const mentionsLogin = /\b(log\s+in|login(?!\s+history)|signin|sign\s*in)\b/u.test(normalized); - const mentionsUiControl = /\b(button|cta|call to action|chip|banner)\b/u.test(normalized); - if (!mentionsAuth && !mentionsLogin) return []; - if (!mentionsLogin && !mentionsUiControl) return []; - const words = [provider, "auth"]; - if (mentionsLogin) words.push("login"); - if (mentionsUiControl) words.push("button"); - return [...new Set(words)].slice(0, 5); -} - export function genericSuffixFromLaneFallbackName(fallbackName: string | null | undefined): string | null { const normalized = normalizeGenericSuffix(fallbackName); if (!normalized) return null; diff --git a/apps/desktop/src/shared/sessionStatusNote.test.ts b/apps/desktop/src/shared/sessionStatusNote.test.ts index fd6baba6e0..bc956339eb 100644 --- a/apps/desktop/src/shared/sessionStatusNote.test.ts +++ b/apps/desktop/src/shared/sessionStatusNote.test.ts @@ -2,7 +2,20 @@ import { describe, expect, it } from "vitest"; import { normalizeSessionStatusNote } from "./sessionStatusNote"; describe("normalizeSessionStatusNote", () => { - it("keeps the ellipsis inside the 72-character cap when extra words follow an exact boundary", () => { + it("keeps a note past the six-word guideline when it fits the display budget", () => { + // Six words is guidance for agents, not an amputation point: the decisive + // state is often in words seven and eight. + expect(normalizeSessionStatusNote("rebasing lane onto main after CI went green")) + .toBe("rebasing lane onto main after CI went green"); + }); + + it("collapses whitespace and drops empty notes", () => { + expect(normalizeSessionStatusNote(" fixing flaky shard \n")).toBe("fixing flaky shard"); + expect(normalizeSessionStatusNote(" ")).toBeNull(); + expect(normalizeSessionStatusNote(undefined)).toBeNull(); + }); + + it("keeps the ellipsis inside the 72-character cap when a note runs past the budget", () => { const exactSixWordBoundary = [ "abcdefghijkl", "abcdefghijkl", diff --git a/apps/desktop/src/shared/sessionStatusNote.ts b/apps/desktop/src/shared/sessionStatusNote.ts index 83157877e9..e024287d8f 100644 --- a/apps/desktop/src/shared/sessionStatusNote.ts +++ b/apps/desktop/src/shared/sessionStatusNote.ts @@ -1,23 +1,20 @@ -const MAX_STATUS_NOTE_WORDS = 6; -const MAX_STATUS_NOTE_CHARACTERS = 72; -const MAX_STATUS_NOTE_INPUT_CHARACTERS = 200; +/** + * The status line the Work list shows. Six words is the guideline agents are + * given, not an enforced cap: amputating word seven silently deletes the + * decisive half of a note, so the only hard bound is the display budget of 72 + * characters. + */ +export const STATUS_NOTE_GUIDELINE_WORDS = 6; +export const MAX_STATUS_NOTE_CHARACTERS = 72; export function normalizeSessionStatusNote(value: unknown): string | null { const raw = typeof value === "string" ? value.trim() : ""; if (!raw) return null; - const boundedInput = Array.from(raw) - .slice(0, MAX_STATUS_NOTE_INPUT_CHARACTERS) - .join(""); - const words = boundedInput.split(/\s+/); - const wordSummary = words.slice(0, MAX_STATUS_NOTE_WORDS).join(" "); - const characters = Array.from(wordSummary); - const wasTruncated = - words.length > MAX_STATUS_NOTE_WORDS - || characters.length > MAX_STATUS_NOTE_CHARACTERS; - - if (wasTruncated) { + const summary = raw.split(/\s+/).join(" "); + const characters = Array.from(summary); + if (characters.length > MAX_STATUS_NOTE_CHARACTERS) { return `${characters.slice(0, MAX_STATUS_NOTE_CHARACTERS - 1).join("")}…`; } - return wordSummary; + return summary; } diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index ef588a2d11..1ab44a7e3b 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -200,8 +200,8 @@ export type TerminalSessionSummary = { * optional for migration tolerance; nullable-ISO semantics match * lastActivityAt. settledAt presence = the settled tier (activity clears it * at the write site). statusNote is the agent-authored glanceable status line, - * normalized to 3–6 words and at most 72 characters (and used as the outcome - * once settled). + * trimmed to at most 72 characters — agents are asked to aim for 6 words or + * fewer (and it is used as the outcome once settled). * attentionRequestedAt/-Message carry an * `ade chat ask` escalation for chat sessions. lastTurnFailedAt marks a chat * turn that died on a runtime/API error (cleared on next turn start). diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 2939d8108a..e894f4d4f9 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -67,7 +67,7 @@ Regular chat and tracked CLI agents can also narrate their lifecycle directly into the Work list: - `ade chat note "testing desktop auth fallback"` updates the row's quiet - status line, normalized to 3–6 words and at most 72 characters; + status line, trimmed to at most 72 characters (agents aim for 6 words or fewer); an empty note clears it. - `ade chat ask "Which account should I use?"` creates a loud, persisted `Needs you` state, clears settle, and sends a time-sensitive push. The next diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index d3f8acdead..598c93f153 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -22,7 +22,8 @@ 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. `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 yet. 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`) + `providerSupportsHandoffFork()`, `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 Droid out (its session index is machine-local) so the two lists cannot drift. 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` 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 (provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. | -| `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. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt 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. 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. 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 only interrupt, reset/dispose, 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. 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. 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. 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 only interrupt, reset/dispose, 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/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/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 menu section, and ranking/caps come from `shared/chatMentions.ts`. 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), and per-message caps (8/kind menu rows, 12 expansions, 1024-char previews). Types live in `shared/types/chatMentions.ts`. | | `apps/desktop/src/main/services/chat/claudePlanMode.ts` | Plan-mode transitions for Claude sessions, extracted from `agentChatService.ts` so the invariant is unit-testable. Entering plan mode sets `claudePermissionMode = "plan"` and stashes the suspended access mode in `claudePrePlanAccessMode` (persisted and rehydrated with the session); leaving restores it. `isSessionInPlanMode` is the single predicate the `ExitPlanMode` gate uses. Moving the access mode is what makes plan mode real: while it stayed on the pre-plan value, a `bypassPermissions` session read as bypass throughout, so the composer chip never left Bypass and the gate auto-approved the plan with no card. See [Agent Routing](agent-routing.md#interaction-mode). | diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index a77d5290d5..e37fb911e9 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -476,8 +476,27 @@ 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. + +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. + Manual renaming sets `manuallyNamed: true`, which permanently -suppresses further auto-title generation. +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. ## CTO vs. regular chat routing diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index cde630058a..19d190cfdf 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -194,10 +194,14 @@ iOS companion (`apps/ios/ADE/Views/Lanes/`): result. Mobile calls the same host operation through `SyncService.suggestLaneName` (the non-queueable `lanes.suggestName` sync command → `agentChatService.generateAutoLaneIdentity` on the host). - Deterministic fallback intentionally remains a bounded shared heuristic - (noise removal, a few durable-concept rules, and capped meaningful tokens); - broader semantic extraction is deferred so offline naming stays predictable - and reviewable. New hosts apply the identity and return `hostApplied`, after + Deterministic fallback (`apps/desktop/src/shared/laneNameFallback.ts`) + intentionally remains a bounded shared heuristic: stopword/noise removal and + capped meaningful tokens drawn from words the prompt actually contains. It + carries no keyword rules that substitute a canned phrase for the prompt — an + earlier trap renamed any prompt mentioning a provider plus a login-ish phrase + to `-auth-login`, inventing words the user never wrote. Broader + semantic extraction is deferred so offline naming stays predictable and + reviewable. New hosts apply the identity and return `hostApplied`, after which mobile refreshes lane state; the direct `lanes.rename` path remains only for compatibility with older hosts. Naming never blocks or fails lane creation or session launch — any failure / timeout / offline / host-disabled diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 302bac395f..d2d759621d 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -1188,6 +1188,15 @@ Renderer surfaces: lane-aware skill roots so prompt text can point agents at the active Agent Skills search path and explain the `/SKILL.md` package shape. +- `apps/desktop/src/shared/sessionStatusNote.ts` — normalizes an + agent-authored status line at the session-service boundary. Exports + `STATUS_NOTE_GUIDELINE_WORDS` (six — the guideline the CLI help, + bootstrap guidance, and the control-plane skill quote) and + `MAX_STATUS_NOTE_CHARACTERS` (72 — the only hard bound). It collapses + whitespace, drops empty notes, and ellipsizes past the character + budget. It deliberately does **not** amputate at six words: the + decisive half of a note is often in words seven and eight, and + silently deleting it made the Work row lie. - `apps/desktop/src/shared/agentSkillRoots.ts` — resolves candidate Agent Skill roots from the active lane worktree, ancestor and home `.claude` / `.agents` / `.ade` / `.codex` directories, inherited @@ -1520,8 +1529,8 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. not dismiss the user's pending question. Provider structured input carries its own pending item id. OSC markers and prompt-looking output never create `Needs you`. `ade chat note ""` clears - only the status line. Status notes are normalized to 3–6 words and at most - 72 characters at the session-service boundary so the sidebar remains + only the status line. Status notes are trimmed to at + most 72 characters at the session-service boundary so the sidebar remains glanceable; blocking detail belongs in the separate `ade chat ask` question. Beyond the binary settle there is a tri-state **settle override** From 888598a130762e86d034825f1bc8b2136b5a5848 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:52:12 -0400 Subject: [PATCH 2/3] test(sessions): pin the status note's 72-character bound, not a word count sessionService's normalization test still expected notes to be cut at six words. Both fixtures fit the display budget, so they now survive whole -- which is the contract the note normalizer actually enforces. --- .../src/main/services/sessions/sessionService.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 6f4b212c21..0e8a978c79 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1394,16 +1394,18 @@ describe("sessionService resume metadata", () => { "select status_note as statusNote from terminal_sessions where id = ?", ["session-markers"], )?.statusNote).toBe(`${"n".repeat(71)}…`); + // Nine words survive: six words is the guideline agents are given, but the + // only enforced bound is the 72-character display budget. service.setStatusNote( "session-markers", " CI is green and waiting for Codex review now ", ); expect(service.get("session-markers")?.statusNote) - .toBe("CI is green and waiting for…"); + .toBe("CI is green and waiting for Codex review now"); expect(db.get<{ statusNote: string }>( "select status_note as statusNote from terminal_sessions where id = ?", ["session-markers"], - )?.statusNote).toBe("CI is green and waiting for…"); + )?.statusNote).toBe("CI is green and waiting for Codex review now"); service.setStatusNote("session-markers", "界".repeat(100)); expect(service.get("session-markers")?.statusNote).toBe(`${"界".repeat(71)}…`); service.setStatusNote("session-markers", " "); @@ -1416,7 +1418,7 @@ describe("sessionService resume metadata", () => { expect(db.get<{ statusNote: string }>( "select status_note as statusNote from terminal_sessions where id = ?", ["session-markers"], - )?.statusNote).toBe("Completed fixes and waiting for release…"); + )?.statusNote).toBe("Completed fixes and waiting for release review now"); service.requestAttention("session-markers", ` ${"a".repeat(510)} `); expect(service.get("session-markers")).toEqual(expect.objectContaining({ settledAt: null, From 948bab2d1c7096bfcb29db8221d76a1a0a1e225f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:17:58 -0400 Subject: [PATCH 3/3] fix(chat): keep the cross-provider model inside the naming budget Review follow-ups on the naming chain. - Splice the cross-provider candidate ahead of the third preference. Three same-provider preferences failing transiently (a hang-up, a timeout -- none of them provider-level) used to spend the whole three-attempt budget before naming ever tried another provider, which is the outage the chain exists to survive. - Stop condemning a provider for a model-specific error. "model not found" and "does not exist" describe one unavailable model, so a sibling on the same provider still deserves a turn; a bare "not found" was swallowing them, so it is now scoped to "command not found". - Replace the fixed 50ms waits in the two auto-title regression tests with waits on the observable state, so a slow CI worker cannot pass the rename race by luck or fail the fallback before it is stored. --- .../services/chat/agentChatService.test.ts | 21 ++++++++++--- .../main/services/chat/sessionNaming.test.ts | 31 +++++++++++++++++++ .../src/main/services/chat/sessionNaming.ts | 24 +++++++++----- 3 files changed, 63 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 943ccd8b82..414d7fec49 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1597,6 +1597,14 @@ function createService(overrides: Record = {}) { return { service, logger, laneService, sessionService, projectConfigService, aiIntegrationService }; } +async function waitFor(condition: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + function installAutoTitleAuth(): void { // Auto-titling is skipped outright when no model is reachable. vi.mocked(detectAllAuth).mockResolvedValue([ @@ -13473,6 +13481,7 @@ describe("createAgentChatService", () => { // which is exactly the race that used to overwrite their title and clear // the manuallyNamed flag. aiIntegrationService.summarizeTerminal.mockImplementation(async () => { + if (renameDuringNaming) return { text: "Model Picked That" } as never; renameDuringNaming = service.updateSession({ sessionId: session.id, title: "User Picked This", @@ -13484,11 +13493,13 @@ describe("createAgentChatService", () => { await service.sendMessage({ sessionId: session.id, text: "Build me a new feature" }); await waitForEvent(events, (event): event is AgentChatEventEnvelope => event.event.type === "done"); - for (let i = 0; i < 40 && !renameDuringNaming; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 25)); - } + await waitFor(() => Boolean(renameDuringNaming)); await renameDuringNaming; - await new Promise((resolve) => setTimeout(resolve, 50)); + // Wait for the clobber itself rather than a fixed delay: pre-fix code + // writes the model title as soon as the naming call resolves, so this + // returns immediately when the regression is present and costs a bounded + // wait when it is not. + await waitFor(() => sessionService.get(session.id)?.title === "Model Picked That", 1_000); expect(renameDuringNaming, "auto-title never ran, so the race was not exercised").not.toBeNull(); expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); @@ -13516,7 +13527,7 @@ describe("createAgentChatService", () => { const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); await service.sendMessage({ sessionId: session.id, text: "Rewrite the lane naming fallback chain" }); await waitForEvent(events, (event): event is AgentChatEventEnvelope => event.event.type === "done"); - await new Promise((resolve) => setTimeout(resolve, 50)); + await waitFor(() => (sessionService.get(session.id)?.title ?? "") !== "Claude Chat"); const title = sessionService.get(session.id)?.title ?? ""; expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/chat/sessionNaming.test.ts b/apps/desktop/src/main/services/chat/sessionNaming.test.ts index ee6907edb7..5f403c55ef 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.test.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.test.ts @@ -40,6 +40,14 @@ describe("isProviderLevelNamingFailure", () => { expect(isProviderLevelNamingFailure(new Error("json schema is not supported by this model"))).toBe(false); expect(isProviderLevelNamingFailure(new Error("socket hang up"))).toBe(false); }); + + it("does not condemn the provider when one model is unavailable", () => { + // A single retired or unrecognized model says nothing about its siblings. + expect(isProviderLevelNamingFailure(new Error("model_not_found"))).toBe(false); + expect(isProviderLevelNamingFailure(new Error("The model `gpt-x` does not exist"))).toBe(false); + // The binary genuinely being absent is still provider-level. + expect(isProviderLevelNamingFailure(new Error("codex: command not found"))).toBe(true); + }); }); describe("buildNamingModelCandidates", () => { @@ -54,6 +62,29 @@ describe("buildNamingModelCandidates", () => { expect(new Set(candidates).size).toBe(candidates.length); }); + 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("drops unavailable and duplicate preferences instead of attempting them", () => { const candidates = buildNamingModelCandidates({ availableModels: ALL_MODELS, diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts index 6bf9ee2449..a447b2b09e 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -70,11 +70,12 @@ export const AUTO_LANE_IDENTITY_JSON_SCHEMA = { * * "not supported with/when" covers the account-rejects-this-model 400 * ("The 'x' model is not supported when using Codex with a ChatGPT account"). - * It deliberately excludes "not supported for/on/by", which describe a single - * model lacking a capability — those must still retry a sibling model. + * It deliberately excludes "not supported for/on/by", "model not found", and + * "does not exist", which describe a single unavailable model or a capability + * it lacks — those must still retry a sibling model on the same provider. */ const PROVIDER_LEVEL_NAMING_FAILURE_PATTERN = - /enoent|eacces|spawn\b|not found|no such file|unauthor|unauthenticated|not (?:logged in|authenticated)|\b40[13]\b|api[_ -]?key|credential|not supported (?:with|when)|unsupported model|model[_ -]?not[_ -]?found|does not exist|insufficient|quota|rate limit/i; + /enoent|eacces|spawn\b|command not found|no such file|unauthor|unauthenticated|not (?:logged in|authenticated)|\b40[13]\b|api[_ -]?key|credential|not supported (?:with|when)|insufficient|quota|rate limit/i; export function isProviderLevelNamingFailure(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error ?? ""); @@ -84,9 +85,16 @@ 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. A cross-provider candidate is always reachable, so an - * outage confined to one provider cannot end naming outright. + * 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. */ +const MAX_NAMING_ATTEMPTS = 3; + export function buildNamingModelCandidates(args: { availableModels: ModelDescriptor[]; /** Ordered preference list; unavailable and duplicate ids are dropped. */ @@ -121,16 +129,16 @@ export function buildNamingModelCandidates(args: { && resolveProviderGroupForModel(entry) === primaryProvider, )?.id; + const crossProviderSlot = Math.min(preferred.length, MAX_NAMING_ATTEMPTS - 1); return availableInOrder([ - ...preferred, + ...preferred.slice(0, crossProviderSlot), crossProviderFallback, + ...preferred.slice(crossProviderSlot), sameProviderFallback, args.availableModels.find((entry) => !preferred.includes(entry.id))?.id, ]); } -const MAX_NAMING_ATTEMPTS = 3; - export type NamingAttemptFailure = { descriptor: ModelDescriptor; provider: ModelProviderGroup;