diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index ae13cf8e0..d27db7ee3 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -499,7 +499,7 @@ ade chat attach-linear-issue --issue-id ENG-431 ade chat create --from-linear-issue ENG-431 --no-parent ade chat list --personal --text ade chat create --personal --provider codex --model openai/gpt-5.5 --prompt "Plan a trip" -ade chat steer personal-session-id --personal --text "focus on the tradeoffs" +ade chat steer personal-session-id --personal --text "focus on the tradeoffs" # add --dispatch inline|interrupt for atomic active-turn delivery ade chat interrupt personal-session-id --personal --keep-queue ade chat restore-queue personal-session-id recovery-id --personal ade chat actions --personal --text @@ -550,6 +550,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 steer session-id --text "active-turn context" --dispatch interrupt # atomic active-turn delivery: inline | interrupt; omit to stage for the next turn (Claude takes both, Cursor takes interrupt) 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 # status + elapsed, live agent pids, settle/snooze state, and why a snoozed row came back @@ -572,7 +573,7 @@ ade chat demote [session-id] # take over a s ade chat promote [session-id] # restore a peer as a subagent so it reports to its parent again ade chat keep-reporting [session-id] # dismiss the takeover prompt without changing the report channel ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane to hand off into another lane -ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); stays in source lane +ade chat fork session-id --model openai/gpt-5.6-sol # fork provider history (claude/codex/opencode/droid); cursor has no fork surface so ADE replays the transcript into a fresh agent; stays in source lane ade chat models --provider codex --json # model order + supported reasoning tiers ade code ade code --embedded diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f19e7c27d..22608cb7e 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -4164,6 +4164,57 @@ describe("ADE CLI", () => { }); }); + it("passes chat steer --dispatch through without restating provider rules", () => { + // The host owns which providers honor which active-turn dispatch mode + // (`ACTIVE_TURN_DISPATCH_MODES`), so the CLI forwards the mode verbatim for + // every provider — Cursor's "interrupt" must not be filtered out here. + const interrupt = expectExecutePlan(buildCliPlan([ + "chat", + "steer", + "chat-1", + "--text", + "switch to the other repro", + "--dispatch", + "interrupt", + ])); + expect(interrupt.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "steer", + args: { sessionId: "chat-1", text: "switch to the other repro", dispatchMode: "interrupt" }, + }, + }); + + const inline = expectExecutePlan(buildCliPlan([ + "chat", "steer", "chat-1", "--text", "context", "--dispatch-mode", "inline", + ])); + expect(inline.steps[0]?.params).toMatchObject({ + arguments: { args: { dispatchMode: "inline" } }, + }); + + const personal = expectExecutePlan(buildCliPlan([ + "chat", "steer", "personal-1", "--personal", "--text", "context", "--dispatch", "interrupt", + ])); + expect(personal.steps[0]).toMatchObject({ + params: { action: "steer", args: { sessionId: "personal-1", text: "context", dispatchMode: "interrupt" } }, + }); + + // Omitting the flag stages the message, so no mode reaches the host. + const staged = expectExecutePlan(buildCliPlan([ + "chat", "steer", "chat-1", "--text", "context", + ])); + expect( + (staged.steps[0]?.params as { arguments?: { args?: Record } })?.arguments?.args, + ).not.toHaveProperty("dispatchMode"); + + expect(() => buildCliPlan([ + "chat", "steer", "chat-1", "--text", "context", "--dispatch", "queue", + ])).toThrow(/stages the message for the next turn/); + expect(() => buildCliPlan([ + "chat", "steer", "chat-1", "--text", "context", "--dispatch", "later", + ])).toThrow(/must be inline or interrupt/); + }); + it("routes queue-aware interruption and recovery for project and personal chats", () => { const stopOnly = expectExecutePlan(buildCliPlan([ "chat", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c8b76da65..169d12d41 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -57,6 +57,7 @@ import { machineStatusLine, } from "../../desktop/src/shared/machinePresence"; import { SEARCH_DOC_KINDS } from "../../desktop/src/shared/types/search"; +import type { AgentChatDispatchSteerMode } from "../../desktop/src/shared/types/chat"; import type { TerminalSessionSummary } from "../../desktop/src/shared/types/sessions"; import { formatWorkingDuration, @@ -1927,6 +1928,13 @@ const HELP_BY_COMMAND: Record = { $ ade chat message --kind auto --text "status" Deliver via auto | queue | wake | interrupt-replace $ ade chat steer --text "context" Steer/queue context into an active turn + $ ade chat steer --text "context" --dispatch interrupt + Deliver into the running turn: inline | interrupt. + Omit --dispatch to stage for the next turn. + Claude takes inline and interrupt; Cursor takes + interrupt (cancel + resend on the same thread). + Other providers reject the flag outright and nothing + is sent; omit --dispatch to stage the message. $ ade chat wait --for idle --timeout-ms 600000 Wait for idle, active, awaiting-input, or terminal $ ade chat recover --turn --action nudge @@ -2012,8 +2020,9 @@ const HELP_BY_COMMAND: Record = { fork stays on the source provider and in the source lane; brief summarizes the chat, can switch provider, and accepts --target-lane. Claude, Codex, OpenCode, and Droid fork through the provider's own fork. - Cursor has no fork surface, so ADE forks it by seeding a fresh Cursor agent - with this conversation's context instead of copying a provider thread. + Cursor has no fork surface, so ADE forks it by replaying this conversation + into a fresh Cursor agent instead of copying a provider thread; the oldest + turns drop if the transcript exceeds the target model's context window. Personal chats attach to the machine-owned ADE brain and never register a project. They work with a desktopless brain and through the same @@ -3473,6 +3482,28 @@ function normalizeChatMessageKind(value: string | null): "auto" | "queue" | "wak ); } +/** + * `chat steer --dispatch` asks for atomic delivery into the turn that is + * already running instead of staging the message for the next one. Which + * providers honor which mode is the host's call — the canonical table lives in + * desktop `shared/types/chat.ts` (`ACTIVE_TURN_DISPATCH_MODES`) and the chat + * service rejects an unsupported mode with a templated message — so the CLI + * only validates the shape and never restates the per-provider rules. + */ +function normalizeChatSteerDispatchMode(value: string | null): AgentChatDispatchSteerMode | null { + if (value == null) return null; + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return null; + if (normalized === "inline" || normalized === "now" || normalized === "send") return "inline"; + if (normalized === "interrupt" || normalized === "replace") return "interrupt"; + if (normalized === "queue" || normalized === "stage" || normalized === "next") { + throw new CliUsageError( + "chat steer stages the message for the next turn by default; omit --dispatch instead of passing 'queue'.", + ); + } + throw new CliUsageError("chat steer --dispatch must be inline or interrupt."); +} + function normalizeChatWaitTarget(value: string | null): ChatWaitTarget { const normalized = (value ?? "idle").trim().toLowerCase(); if (normalized === "idle" || normalized === "done" || normalized === "complete") return "idle"; @@ -7652,6 +7683,9 @@ function buildChatPlan(args: string[]): CliPlan { } if (sub === "steer") { const imageUrl = readValue(args, ["--image-url"]); + const dispatchMode = normalizeChatSteerDispatchMode( + readValue(args, ["--dispatch", "--dispatch-mode"]), + ); const steerText = requireValue( readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text", @@ -7667,6 +7701,7 @@ function buildChatPlan(args: string[]): CliPlan { withSession({ sessionId: requireValue(sessionId, "sessionId"), text: steerText, + ...(dispatchMode ? { dispatchMode } : {}), ...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}), }), ), @@ -8346,6 +8381,9 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { }; } if (sub === "steer") { + const dispatchMode = normalizeChatSteerDispatchMode( + readValue(args, ["--dispatch", "--dispatch-mode"]), + ); const text = requireValue(readValue(args, ["--text", "--message"]) ?? args.join(" "), "message text"); const imageUrl = readValue(args, ["--image-url"]); return { @@ -8354,6 +8392,7 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { steps: [personalChatStep("steer", collectGenericObjectArgs(args, { sessionId, text, + ...(dispatchMode ? { dispatchMode } : {}), ...(imageUrl ? { attachments: [{ type: "image-url", url: imageUrl, path: imageUrl }] } : {}), }))], }; @@ -12224,6 +12263,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--depth", "--desc", "--device", + "--dispatch", + "--dispatch-mode", "--disk", "--disk-size", "--display", diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 5c974fb02..9e87268d0 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -371,6 +371,22 @@ describe("commands", () => { ]); }); + it("offers each /steer dispatch command exactly where the provider accepts that mode", () => { + // Gating is derived from ACTIVE_TURN_DISPATCH_MODES, not restated: Claude + // takes inline + interrupt, Cursor only interrupt, everything else stages. + const steerRows = (provider: string) => paletteCommands("/steer", [], { provider }) + .map((row) => row.name); + expect(steerRows("claude")).toEqual(expect.arrayContaining(["/steer send", "/steer interrupt"])); + expect(steerRows("cursor")).toContain("/steer interrupt"); + expect(steerRows("cursor")).not.toContain("/steer send"); + for (const provider of ["codex", "droid", "opencode"]) { + expect(steerRows(provider)).not.toContain("/steer send"); + expect(steerRows(provider)).not.toContain("/steer interrupt"); + // The provider-agnostic staging commands stay available everywhere. + expect(steerRows(provider)).toEqual(expect.arrayContaining(["/steer edit", "/steer cancel"])); + } + }); + it("filters provider-specific ADE commands outside supported chats", () => { expect(paletteCommands("/context", [], { provider: "codex" })).toContainEqual( expect.objectContaining({ name: "/context" }), diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 280853e83..57d8e7243 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -14,6 +14,12 @@ import { import { resolveStableLaneBaseBranch } from "../../../desktop/src/shared/laneBaseResolution"; import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; +import { + activeTurnInterruptContinues, + supportsActiveTurnDispatchMode, + unsupportedActiveTurnDispatchModeMessage, +} from "../../../desktop/src/shared/types/chat"; +import { providerDisplayLabel } from "../../../desktop/src/shared/pendingInputLabels"; import { composerFileSearchQuery, composerTriggerForSelection, @@ -10336,8 +10342,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // A full steer queue drops the message server-side. Surface it the same way // the primary messageSession path does — throw so submitPrompt restores the // typed text and shows an error — instead of falsely implying it was sent. + // Every queue-bearing runtime can hit this (Claude, Cursor, Droid, + // OpenCode), so the message names the session's own agent. if (result.reason === "queue_full") { - throw new Error("The Claude steer queue is full; the message was not queued."); + const agentLabel = providerDisplayLabel( + sessions.find((session) => session.sessionId === sessionId)?.provider, + "agent", + ); + throw new Error(`The ${agentLabel} steer queue is full; the message was not queued.`); } if (result.queued) { addNotice("Staged message — sends after the current turn.", "info"); @@ -10862,10 +10874,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (name === "/steer") { + // Which dispatch commands this pane advertises comes off the canonical + // per-provider table (desktop shared/types/chat.ts), the same source the + // /steer commands and the desktop staged strip read — Claude offers both, + // Cursor only the interrupt, everything else stages until the turn ends. + const steerProvider = activeSession?.provider; + const dispatchHint = [ + supportsActiveTurnDispatchMode(steerProvider, "inline") ? "/steer send" : null, + supportsActiveTurnDispatchMode(steerProvider, "interrupt") ? "/steer interrupt" : null, + ].filter((entry): entry is string => entry != null); + const hintLine = pendingSteers.length + ? dispatchHint.length + ? `${dispatchHint.join(" · ")} · /steer edit · /steer cancel` + : "Sends when the current turn finishes · /steer edit · /steer cancel" + : null; const body = pendingSteers.length - ? pendingSteers - .map((steer, index) => `${index + 1}. ${steer.text}`) - .join("\n") + ? [ + pendingSteers.map((steer, index) => `${index + 1}. ${steer.text}`).join("\n"), + ...(hintLine ? ["", hintLine] : []), + ].join("\n") : "No staged steer messages are waiting."; setRightPane({ kind: "details", title: "Staged messages", body }); return; @@ -12183,12 +12210,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (name === "/steer send" || name === "/steer interrupt") { - if (activeSession?.provider !== "claude") { - addNotice("Only Claude staged messages support send-now and interrupt dispatch.", "error"); + // Which modes each provider honors lives in one table (desktop + // shared/types/chat.ts); this branch only maps commands onto it. + const provider = activeSession?.provider; + const mode = name === "/steer send" ? "inline" : "interrupt"; + if (!supportsActiveTurnDispatchMode(provider, mode)) { + addNotice(unsupportedActiveTurnDispatchModeMessage(provider, mode), "error"); return; } - await dispatchSteerMessage(conn, sessionId, latestSteer.steerId, name === "/steer send" ? "inline" : "interrupt"); - addNotice(name === "/steer send" ? "Sent staged message into the active Claude turn." : "Interrupting Claude to run the staged message.", "info"); + const agentLabel = providerDisplayLabel(provider, "the agent"); + // Cursor's interrupt cancels the run and resends on the same thread, so + // it continues rather than starting something new — same wording the + // desktop composer and iOS use, off the same shared fact. + const interruptContinues = activeTurnInterruptContinues(provider); + await dispatchSteerMessage(conn, sessionId, latestSteer.steerId, mode); + addNotice( + mode === "inline" + ? `Sent staged message into the active ${agentLabel} turn.` + : interruptContinues + ? `Interrupting ${agentLabel} and continuing with the staged message.` + : `Interrupting ${agentLabel} to run the staged message.`, + "info", + ); await refreshState(); return; } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 2436f418d..fad0a9acc 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -1,4 +1,23 @@ -import type { AgentChatProvider, AgentChatSlashCommand } from "../../../desktop/src/shared/types/chat"; +import { + ACTIVE_TURN_DISPATCH_MODES, + type ActiveTurnSendMode, + type AgentChatProvider, + type AgentChatSlashCommand, +} from "../../../desktop/src/shared/types/chat"; + +/** + * Providers whose backend accepts this atomic active-turn dispatch mode, read + * off the canonical table rather than restated here — adding a provider there + * offers its /steer command in the TUI automatically. + */ +function providersSupporting(mode: ActiveTurnSendMode): AgentChatProvider[] { + return (Object.entries(ACTIVE_TURN_DISPATCH_MODES) as [AgentChatProvider, readonly ActiveTurnSendMode[]][]) + .filter(([, modes]) => modes.includes(mode)) + .map(([provider]) => provider); +} + +const INLINE_STEER_PROVIDERS = providersSupporting("inline"); +const INTERRUPT_STEER_PROVIDERS = providersSupporting("interrupt"); export type CommandPlacement = "inline" | "right" | "overlay" | "chat"; @@ -52,8 +71,8 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/quit", description: "Exit ade code", placement: "inline", category: "System" }, { name: "/steer cancel", description: "Remove the latest staged steer message", placement: "inline", category: "Steer" }, { name: "/steer edit", description: "Edit the latest staged steer message", placement: "inline", argumentHint: "", category: "Steer" }, - { name: "/steer send", description: "Send the latest staged steer into a Claude turn", placement: "inline", providers: ["claude"], category: "Steer" }, - { name: "/steer interrupt", description: "Interrupt Claude and run the latest staged steer", placement: "inline", providers: ["claude"], category: "Steer" }, + { name: "/steer send", description: "Send the latest staged steer into the active agent's turn", placement: "inline", providers: INLINE_STEER_PROVIDERS, category: "Steer" }, + { name: "/steer interrupt", description: "Interrupt the agent and run the latest staged steer", placement: "inline", providers: INTERRUPT_STEER_PROVIDERS, category: "Steer" }, { name: "/steer", description: "Show staged steer messages", placement: "right", category: "Steer" }, { name: "/new lane", description: "Create a new lane", placement: "right", category: "Lanes" }, { name: "/new chat", description: "Create a new chat in the current lane", placement: "right", argumentHint: "[title]", category: "Chats" }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 3970d466b..81e9f92e6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5085,7 +5085,7 @@ describe("createAgentChatService", () => { expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); }); - it("forks a Cursor chat onto a fresh agent seeded with the source conversation", async () => { + it("forks a Cursor chat onto a fresh agent replaying the full source transcript", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; const { service, aiIntegrationService } = createService(); const source = await service.createSession({ @@ -5120,10 +5120,11 @@ describe("createAgentChatService", () => { // The tail is rebuilt by the shared fork path's transcript import, so the // Cursor seeding must not copy the entries as well — that doubled it. expect(persisted.recentConversationEntries ?? []).toEqual([]); - // Same-provider Cursor fork is ADE-side context seeding, not a replay. - // The field is persisted explicitly as null rather than omitted, so a - // restart cannot resurrect a stale replay. - expect(persisted.pendingTranscriptReplay).toBeNull(); + // Cursor has no fork API, so the fork carries the whole conversation as a + // verbatim transcript replay rather than a 20-line tail. + expect(persisted.pendingTranscriptReplay).toContain("verbatim replay"); + expect(persisted.pendingTranscriptReplay).toContain("Investigate the flaky migration test."); + expect(result.replayFork).toBeUndefined(); // No brief was generated — fork carries the conversation, not a summary. expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); }); @@ -5219,14 +5220,92 @@ describe("createAgentChatService", () => { const forkedPrompt = String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? ""); expect(forkedPrompt).toContain("Forked Cursor chat"); + expect(forkedPrompt).toContain("verbatim replay"); expect(forkedPrompt).toContain("Keep going."); - // Exactly once: copying recentConversationEntries on top of the transcript - // import used to repeat every line of the seeded tail. + // Exactly once: the replay and the seeding header must not both carry the + // conversation, or every line of it shows up twice. expect(forkedPrompt.split("Investigate the flaky migration test.").length - 1).toBe(1); // The fresh agent is created rather than resumed. expect(mockState.cursorSdkAcquireCalls.at(-1)?.agentId).toBeNull(); }); + it("regression: replays a forked Cursor transcript exactly once, across a restart", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService(); + const source = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + await service.sendMessage({ + sessionId: source.id, + text: "Replay this Cursor transcript exactly once.", + }, { awaitDispatch: true }); + await vi.waitFor(() => { + expect(source.status).toBe("idle"); + }); + + const result = await service.handoffSession({ + sourceSessionId: source.id, + targetModelId: "cursor/composer-2", + mode: "fork", + }); + expect(readPersistedChatState(result.session.id).pendingTranscriptReplay) + .toContain("Replay this Cursor transcript exactly once."); + + mockState.cursorSdkSendCalls = []; + await service.sendMessage({ + sessionId: result.session.id, + text: "Continue from there.", + }, { awaitDispatch: true }); + expect(String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? "")) + .toContain("Replay this Cursor transcript exactly once."); + // Consumption must be durable, not just in memory. + expect(readPersistedChatState(result.session.id).pendingTranscriptReplay).toBeNull(); + + const restarted = createService().service; + mockState.cursorSdkSendCalls = []; + await restarted.sendMessage({ + sessionId: result.session.id, + text: "Keep going.", + }, { awaitDispatch: true }); + const secondPrompt = String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? ""); + expect(secondPrompt).not.toContain("verbatim replay"); + expect(secondPrompt).not.toContain("Replay this Cursor transcript exactly once."); + }); + + it("discloses truncation when a forked Cursor transcript exceeds the context window", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService(); + const source = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + for (let index = 0; index < 6; index += 1) { + await service.sendMessage({ + sessionId: source.id, + text: `Turn ${index}: ${"x".repeat(220_000)}`, + }, { awaitDispatch: true }); + await vi.waitFor(() => { + expect(source.status).toBe("idle"); + }); + } + + const result = await service.handoffSession({ + sourceSessionId: source.id, + targetModelId: "cursor/composer-2", + mode: "fork", + }); + + expect(result.replayFork?.truncated).toBe(true); + expect(result.replayFork?.truncatedTurnCount).toBeGreaterThan(0); + expect(readPersistedChatState(result.session.id).pendingTranscriptReplay) + .toContain("verbatim replay"); + }); + it("regression: does not replay the forked transcript again after a restart", async () => { const { service } = createService(); const source = await service.createSession({ @@ -15361,6 +15440,278 @@ describe("createAgentChatService", () => { expect(mockState.cursorSdkPoisonCalls).toHaveLength(0); }); + it("recovers a Cursor stale access token on the same agent thread, invisibly, before any output", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + // The SDK exchanges the API key for a short-lived access token once per + // worker and never refreshes it, so ~60 min in every send dies instantly + // with this exact text. The key is fine; only the worker is spent. + const staleTokenMessage = "Authentication error If you are logged in, try logging out and back in."; + mockState.onCursorSendPrompt = (pooled) => { + if (mockState.cursorSdkSendCalls.length > 1) { + mockState.cursorSendPromptResult = null; + return; + } + mockState.cursorSendPromptResult = { + id: "cursor-sdk-run-1", + status: "error", + error: { message: staleTokenMessage, code: staleTokenMessage }, + }; + pooled.bridge.onEvent?.({ + type: "status", + status: "ERROR", + adeErrorCode: staleTokenMessage, + adeErrorDetail: { message: staleTokenMessage, requestId: "req-stale-1" }, + }, { runtime: "local", runId: "cursor-sdk-run-1" }); + }; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Send whose token just expired.", + }, { awaitDispatch: true }); + await vi.waitFor(() => { + expect(events.some((event) => event.event.type === "done")).toBe(true); + }); + + // Exactly one recycle and one re-send... + expect(mockState.cursorSdkSendCalls).toHaveLength(2); + expect(mockState.cursorSdkPoisonCalls).toHaveLength(1); + // ...onto the SAME agent id, so the whole conversation survives. This is + // what separates it from the transport recycle, which rotates to a new + // agent and re-seeds it with a continuity summary. + expect(mockState.cursorSdkAcquireCalls).toHaveLength(2); + expect(mockState.cursorSdkAcquireCalls[1]?.agentId).toBe("cursor-sdk-agent-1"); + expect(mockState.cursorSdkSendCalls[1]?.forceExpireActiveRun).toBe(true); + // Nothing had streamed yet, so the original prompt is re-sent verbatim — + // byte-identical to attempt 1, not a "continue" instruction. Accepted + // trade-off: `forceExpireActiveRun` expires the wedged run but does not + // unregister the message it already recorded, so the resumed thread can + // hold this prompt twice. A silent, correct answer beats a deduped no-op. + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")) + .toContain("Send whose token just expired."); + expect(mockState.cursorSdkSendCalls[1]?.promptText) + .toBe(mockState.cursorSdkSendCalls[0]?.promptText); + // And the user sees none of it: no error card, no notice. + expect(events.filter((event) => event.event.type === "error")).toHaveLength(0); + expect(events.filter((event) => event.event.type === "system_notice")).toHaveLength(0); + expect(events.filter((event) => event.event.type === "done").at(-1)?.event) + .toMatchObject({ status: "completed" }); + }); + + it("ignores visible output from an abandoned run when deciding to resume mid-turn", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + // Same stale-token recovery, but the only "real output" belongs to a run + // this turn already walked away from. Crediting it would tell Cursor to + // continue from work that never happened on this turn, so the retry must + // be the verbatim replay instead. + const staleTokenMessage = "Authentication error If you are logged in, try logging out and back in."; + mockState.onCursorSendPrompt = (pooled) => { + if (mockState.cursorSdkSendCalls.length > 1) { + mockState.cursorSendPromptResult = null; + return; + } + mockState.cursorSendPromptResult = { + id: "cursor-sdk-run-1", + status: "error", + error: { message: staleTokenMessage, code: staleTokenMessage }, + }; + // This turn's run, which scopes the watchdog... + pooled.bridge.onRunStarted?.({ + agentId: "cursor-sdk-agent-1", + runId: "cursor-sdk-run-1", + modelSdkId: "composer-2", + }, { runtime: "local" }); + // ...and a late frame from the previous, abandoned one. + pooled.bridge.onEvent?.( + { type: "assistant", message: { content: [{ type: "text", text: "Output from the old run." }] } }, + { runtime: "local", runId: "cursor-sdk-run-0" }, + ); + pooled.bridge.onEvent?.({ + type: "status", + status: "ERROR", + adeErrorCode: staleTokenMessage, + adeErrorDetail: { message: staleTokenMessage }, + }, { runtime: "local", runId: "cursor-sdk-run-1" }); + }; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Refactor the composer.", + }, { awaitDispatch: true }); + await vi.waitFor(() => { + expect(events.some((event) => event.event.type === "done")).toBe(true); + }); + + expect(mockState.cursorSdkSendCalls).toHaveLength(2); + const retryPrompt = String(mockState.cursorSdkSendCalls[1]?.promptText ?? ""); + expect(retryPrompt).not.toContain("Continue where you left off"); + expect(retryPrompt).toBe(String(mockState.cursorSdkSendCalls[0]?.promptText ?? "")); + // Nothing visible happened on this turn, so the recovery stays silent. + expect(events.filter((event) => event.event.type === "system_notice")).toHaveLength(0); + expect(events.filter((event) => event.event.type === "error")).toHaveLength(0); + }); + + it("continues the resumed Cursor thread when the access token expires mid-turn", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const staleTokenMessage = "Authentication error If you are logged in, try logging out and back in."; + mockState.onCursorSendPrompt = (pooled) => { + if (mockState.cursorSdkSendCalls.length > 1) { + mockState.cursorSendPromptResult = null; + return; + } + mockState.cursorSendPromptResult = { + id: "cursor-sdk-run-1", + status: "error", + error: { message: staleTokenMessage, code: staleTokenMessage }, + }; + // Real output first: the token ages out an hour into a working turn. + pooled.bridge.onEvent?.( + { type: "assistant", message: { content: [{ type: "text", text: "Started the refactor." }] } }, + { runtime: "local", runId: "cursor-sdk-run-1" }, + ); + pooled.bridge.onEvent?.({ + type: "status", + status: "ERROR", + adeErrorCode: staleTokenMessage, + adeErrorDetail: { message: staleTokenMessage }, + }, { runtime: "local", runId: "cursor-sdk-run-1" }); + }; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Refactor the composer.", + }, { awaitDispatch: true }); + await vi.waitFor(() => { + expect(events.some((event) => event.event.type === "done")).toBe(true); + }); + + expect(mockState.cursorSdkSendCalls).toHaveLength(2); + expect(mockState.cursorSdkAcquireCalls[1]?.agentId).toBe("cursor-sdk-agent-1"); + // The thread was resumed, so re-sending the original prompt would restart + // work Cursor had already done — it is asked to continue instead. + const retryPrompt = String(mockState.cursorSdkSendCalls[1]?.promptText ?? ""); + expect(retryPrompt).toContain("Continue where you left off"); + expect(retryPrompt).not.toContain("Refactor the composer."); + // The reply visibly stopped, so this one case says something — quietly. + const notices = events.filter((event) => event.event.type === "system_notice"); + expect(notices).toHaveLength(1); + expect(notices[0]?.event).toMatchObject({ + noticeKind: "info", + message: "Reconnected to Cursor and continued.", + }); + expect(events.filter((event) => event.event.type === "error")).toHaveLength(0); + }); + + it("surfaces the Cursor stale-token failure once when the recovery re-send fails the same way", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const staleTokenMessage = "Authentication error If you are logged in, try logging out and back in."; + mockState.cursorSendPromptResult = { + id: "cursor-sdk-run-1", + status: "error", + error: { message: staleTokenMessage, code: staleTokenMessage }, + }; + mockState.onCursorSendPrompt = (pooled) => { + pooled.bridge.onEvent?.({ + type: "status", + status: "ERROR", + adeErrorCode: staleTokenMessage, + adeErrorDetail: { message: staleTokenMessage, requestId: "req-stale-2" }, + }, { runtime: "local", runId: "cursor-sdk-run-1" }); + }; + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Send that stays unauthenticated.", + }, { awaitDispatch: true }).catch(() => undefined); + await vi.waitFor(() => { + expect(events.some((event) => event.event.type === "error")).toBe(true); + }); + + // One recovery, no loop. + expect(mockState.cursorSdkSendCalls).toHaveLength(2); + const errorEvents = events.filter((event) => event.event.type === "error"); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.event).toMatchObject({ + message: "Cursor's session expired. ADE reconnected and retried, but Cursor rejected the request " + + "again — sign in to Cursor again in Settings, then resend.", + errorInfo: { category: "auth", provider: "Cursor" }, + }); + // The raw SDK text never reaches the transcript, but the request id does. + expect(String((errorEvents[0]?.event as { detail?: string }).detail ?? "")) + .toContain("req-stale-2"); + }); + + it("does not recycle the Cursor worker for a genuinely bad API key", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + // A fresh worker would fail identically: this is the user's problem to + // fix, so it must surface immediately rather than burn a recovery. + mockState.cursorSendPromptError = new Error("Authentication failed: Invalid API key"); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Send with a bad key.", + }, { awaitDispatch: true }).catch(() => undefined); + await vi.waitFor(() => { + expect(events.some((event) => event.event.type === "error")).toBe(true); + }); + + expect(mockState.cursorSdkSendCalls).toHaveLength(1); + expect(mockState.cursorSdkPoisonCalls).toHaveLength(0); + const errorEvents = events.filter((event) => event.event.type === "error"); + expect(errorEvents).toHaveLength(1); + expect(String((errorEvents[0]?.event as { message?: string }).message ?? "")) + .toContain("Check your Cursor credentials"); + }); + it("carries a queued Cursor steer across a thread recycle and delivers it after recovery", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; const events: AgentChatEventEnvelope[] = []; @@ -15406,6 +15757,377 @@ describe("createAgentChatService", () => { } }); + /** + * Cursor "interrupt & continue". The Cursor SDK has no mid-run message + * API, so the redirect can only be cancel + resend on the same agent — + * these cover that the cancel happens, the resend lands on the same + * thread, and nothing the user already queued is thrown away. + */ + const startBusyCursorSession = async (events: AgentChatEventEnvelope[]) => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + let releaseTurn: (() => void) | null = null; + mockState.cursorSendPromptGate = new Promise((resolve) => { releaseTurn = resolve; }); + void service.sendMessage({ + sessionId: session.id, + text: "Original turn.", + }, { awaitDispatch: true }).catch(() => undefined); + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(1); + }); + // The worker's cancel is what actually settles the in-flight run. + mockState.onCursorCancel = () => { + mockState.onCursorCancel = null; + mockState.cursorSendPromptGate = null; + releaseTurn?.(); + }; + return { service, session }; + }; + + it("dispatches a Cursor steer with dispatchMode interrupt by cancelling the run and resending on the same agent", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + await service.steer({ + sessionId: session.id, + text: "Actually, do the migration first.", + dispatchMode: "interrupt", + }); + + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")) + .toContain("Actually, do the migration first."); + // The previous turn is reported as interrupted, not failed. + expect(events.some((event) => + event.event.type === "status" && event.event.turnStatus === "interrupted")).toBe(true); + // Same agent, same thread: no rotation and no new worker. + expect(mockState.cursorSdkPoisonCalls).toHaveLength(0); + expect(readPersistedChatState(session.id).cursorSdkAgentId).toBe("cursor-sdk-agent-1"); + }); + + it("keeps already-queued Cursor steers across an interrupt-and-continue", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + await service.steer({ sessionId: session.id, text: "Then update the docs." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + + await service.steer({ + sessionId: session.id, + text: "Actually, do the migration first.", + dispatchMode: "interrupt", + }); + + // Redirect turn, then the message the user had already staged. + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(3); + }); + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")) + .toContain("Actually, do the migration first."); + expect(String(mockState.cursorSdkSendCalls[2]?.promptText ?? "")) + .toContain("Then update the docs."); + expect(events.some((event) => + event.event.type === "system_notice" + && typeof event.event.steerId === "string" + && event.event.message.includes("cancelled"))).toBe(false); + }); + + it("routes messageSession kind interrupt-replace on Cursor through interrupt-and-continue", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + const result = await service.messageSession({ + sessionId: session.id, + text: "Stop and take this instead.", + kind: "interrupt-replace", + }); + + expect(result.routedAction).toBe("interrupt-replace"); + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")) + .toContain("Stop and take this instead."); + expect(events.some((event) => + event.event.type === "status" && event.event.turnStatus === "interrupted")).toBe(true); + expect(readPersistedChatState(session.id).cursorSdkAgentId).toBe("cursor-sdk-agent-1"); + }); + + it("still clears queued Droid steers on interrupt-replace, unlike Cursor", async () => { + // The Cursor redirect softens the stop to `stop_only` so the user's other + // queued messages ride through. That is Cursor-only: every other provider + // keeps the pre-existing `interrupt-replace` contract, which clears. + const events: AgentChatEventEnvelope[] = []; + let finishTurn = () => {}; + mockState.droidPromptGate = new Promise((resolve) => { finishTurn = resolve; }); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "droid", + model: "custom:claude-sonnet-5-thinking-32000", + modelId: "droid/custom:claude-sonnet-5-thinking-32000", + }); + try { + void service.sendMessage({ + sessionId: session.id, + text: "Original Droid turn.", + }, { awaitDispatch: true }).catch(() => undefined); + await vi.waitFor(() => { + expect(mockState.droidPromptCalls.length).toBeGreaterThanOrEqual(1); + }); + // The cancel is what settles the in-flight prompt, as on the real SDK. + mockState.droidPooled?.cancel.mockImplementation(async () => { + mockState.droidPromptGate = null; + finishTurn(); + }); + + await service.steer({ sessionId: session.id, text: "Then update the docs." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + + await service.messageSession({ + sessionId: session.id, + text: "Stop and take this instead.", + kind: "interrupt-replace", + }); + + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "system_notice" + && typeof event.event.steerId === "string" + && event.event.message.includes("cancelled"))).toBe(true); + }); + await vi.waitFor(() => { + expect(mockState.droidPromptCalls.some((call) => + String(call.prompt ?? call.promptText ?? "").includes("Stop and take this instead."))).toBe(true); + }); + } finally { + mockState.droidPromptGate = null; + finishTurn(); + } + }); + + it("promotes a staged Cursor steer to interrupt-and-continue through dispatchSteer", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + const staged = await service.steer({ sessionId: session.id, text: "Run this one instead." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + + await expect(service.dispatchSteer({ + sessionId: session.id, + steerId: staged.steerId, + mode: "inline", + })).rejects.toThrow(/only the "interrupt" active-turn dispatch mode/); + + await service.dispatchSteer({ sessionId: session.id, steerId: staged.steerId, mode: "interrupt" }); + + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")).toContain("Run this one instead."); + // The staged chip has to be resolved, or it stays parked in the composer. + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.steerId === staged.steerId + && event.event.message.includes("Delivering"))).toBe(true); + }); + + it("rejects an inline steer dispatch on Cursor instead of silently downgrading it", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService({ onEvent: () => {} }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await expect(service.steer({ + sessionId: session.id, + text: "Fold this into the live run.", + dispatchMode: "inline", + })).rejects.toThrow(/only the "interrupt" active-turn dispatch mode/); + }); + + // Regression (quality A2): the redirect rebuilds the send from scratch, so + // the per-message overrides the user picked for THIS message have to be + // carried across it. Before the fix they were dropped and the redirect ran + // on whatever the session already had. + it("carries per-message reasoning and execution overrides through the Cursor interrupt redirect", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + const before = await service.getSessionSummary(session.id); + expect(before?.reasoningEffort ?? null).toBeNull(); + expect(before?.executionMode).toBe("focused"); + + await service.steer({ + sessionId: session.id, + text: "Actually, do the migration first.", + dispatchMode: "interrupt", + reasoningEffort: "high", + executionMode: "parallel", + }); + + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + const after = await service.getSessionSummary(session.id); + expect(after?.reasoningEffort).toBe("high"); + expect(after?.executionMode).toBe("parallel"); + expect(readPersistedChatState(session.id).reasoningEffort).toBe("high"); + }); + + // Regression (quality A2): the same three overrides ride the staged row + // when the user promotes it, exactly as `deliverNextQueuedSteer` applies + // them at a natural turn boundary. + it("carries a staged Cursor steer's overrides through the promotion redirect", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + const staged = await service.steer({ + sessionId: session.id, + text: "Run this one instead.", + reasoningEffort: "high", + executionMode: "subagents", + }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + // Staging must not apply them: they belong to the message, not the session. + expect((await service.getSessionSummary(session.id))?.executionMode).toBe("focused"); + + await service.dispatchSteer({ sessionId: session.id, steerId: staged.steerId, mode: "interrupt" }); + + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + const after = await service.getSessionSummary(session.id); + expect(after?.reasoningEffort).toBe("high"); + expect(after?.executionMode).toBe("subagents"); + }); + + // Regression (quality A8): `steerWithOptions` already expanded the chips, + // so the redirect's `sendMessage` must not expand them a second time — + // expanded file content can itself contain chip syntax. + it("expands @-mention chips exactly once on the Cursor interrupt redirect", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + await service.steer({ + sessionId: session.id, + text: "apply the fix from @chat:other-session-id", + dispatchMode: "interrupt", + }); + + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + const promptText = String(mockState.cursorSdkSendCalls[1]?.promptText ?? ""); + expect(promptText).toContain(" { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + await service.steer({ sessionId: session.id, text: "Then update the docs." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + + // The stop reaches the worker, but the run does not settle: the redirect + // gives up after its 30 s wait and reports that, instead of hanging. + const releaseTurn = mockState.onCursorCancel; + mockState.onCursorCancel = () => {}; + vi.useFakeTimers(); + try { + const redirect = service.steer({ + sessionId: session.id, + text: "Actually, do the migration first.", + dispatchMode: "interrupt", + }).then(() => "resolved", (error: unknown) => (error instanceof Error ? error.message : String(error))); + await vi.advanceTimersByTimeAsync(31_000); + await expect(redirect).resolves.toMatch(/still stopping/); + } finally { + vi.useRealTimers(); + } + + // The run settles late, and its tail runs the cancel the stop earned. + // The flag is still armed, so the user's other message survives it. + releaseTurn?.(); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "status" && event.event.turnStatus === "interrupted")).toBe(true); + }); + expect(events.some((event) => + event.event.type === "system_notice" + && typeof event.event.steerId === "string" + && event.event.message.includes("cancelled"))).toBe(false); + }); + + // Regression (quality R6): the promotion splices the row out of the queue + // before the redirect completes, so a cancel arriving in that window must + // say the message is going out, not that it was never queued. + it("tells the user a promoted Cursor steer is already being dispatched, not that it is gone", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + const staged = await service.steer({ sessionId: session.id, text: "Run this one instead." }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + + // The worker's cancel runs inside the redirect, while the row is spliced + // out but the dispatch has not landed yet. + let cancelDuringDispatch: Promise | null = null; + const releaseTurn = mockState.onCursorCancel; + mockState.onCursorCancel = () => { + cancelDuringDispatch = service + .cancelSteer({ sessionId: session.id, steerId: staged.steerId, requireQueued: true }) + .then(() => "resolved", (error: unknown) => (error instanceof Error ? error.message : String(error))); + releaseTurn?.(); + }; + + await service.dispatchSteer({ sessionId: session.id, steerId: staged.steerId, mode: "interrupt" }); + + expect(cancelDuringDispatch, "the cancel must land inside the dispatch window").toBeTruthy(); + await expect(cancelDuringDispatch!).resolves.toBe("This message is already being dispatched."); + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")).toContain("Run this one instead."); + }); + it("settles a re-queued Cursor steer exactly once when the recovery re-send also goes silent", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; const events: AgentChatEventEnvelope[] = []; @@ -16079,7 +16801,7 @@ describe("createAgentChatService", () => { expect(secondAcquire.stateKey).toBe(firstAcquire.stateKey); }); - it("injects recent ADE context when Cursor SDK resume opens a new agent", async () => { + it("replays the full transcript when Cursor SDK resume opens a new agent", async () => { process.env.CURSOR_API_KEY = "cursor-test-key"; const { service } = createService(); const session = await service.createSession({ @@ -16110,8 +16832,11 @@ describe("createAgentChatService", () => { expect(promptText).toContain("Cursor SDK continuity recovery"); expect(promptText).toContain("cursor-sdk-agent-1"); expect(promptText).toContain("cursor-sdk-agent-2"); - expect(promptText).toContain("Recent Conversation Tail"); - expect(promptText).toContain("User: Inspect the mobile files tab parity work."); + // The rotated agent gets the whole conversation replayed verbatim, not a + // 20-line tail. + expect(promptText).toContain("verbatim replay"); + expect(promptText).not.toContain("Recent Conversation Tail"); + expect(promptText).toContain("Inspect the mobile files tab parity work."); expect(promptText).toContain("Did you finish the prior work?"); // Prompts are prepared before the runtime is acquired, so the rotation // turn itself stays deduped — but the rotated agent is brand new, so the @@ -16124,6 +16849,10 @@ describe("createAgentChatService", () => { }); const postRotationPrompt = String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? ""); expect(postRotationPrompt).toContain("[ADE launch directive]"); + // One staged replay, consumed exactly once: the rotation stage is durable, + // so a turn that did not trigger a rotation must not replay it again. + expect(postRotationPrompt).not.toContain("verbatim replay"); + expect(postRotationPrompt).not.toContain("Cursor SDK continuity recovery"); }); it("recreates the Cursor SDK agent with recovery context when resume state is missing", async () => { @@ -16174,8 +16903,9 @@ describe("createAgentChatService", () => { expect(promptText).toContain("Cursor SDK continuity recovery"); expect(promptText).toContain("cursor-sdk-agent-1"); expect(promptText).toContain("cursor-sdk-agent-2"); - expect(promptText).toContain("Recent Conversation Tail"); - expect(promptText).toContain("User: Inspect the runtime compaction lane crash."); + expect(promptText).toContain("verbatim replay"); + expect(promptText).not.toContain("Recent Conversation Tail"); + expect(promptText).toContain("Inspect the runtime compaction lane crash."); expect(promptText).toContain("Did the SDK resume bug come back?"); }); @@ -35494,9 +36224,11 @@ describe("createAgentChatService", () => { const { service } = createService({ onEvent: () => {} }); const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5-codex" }); + // Rejection now comes from the canonical per-provider table, so the copy + // names the provider and the mode instead of the method. await expect( service.dispatchSteer({ sessionId: session.id, steerId: "any", mode: "inline" }), - ).rejects.toThrow(/not supported on Codex/i); + ).rejects.toThrow(/Codex sessions don't support the "inline" active-turn dispatch mode/i); }); it("cancelDispatchedSteer cancels an SDK-queued Claude steer by its command UUID", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ce1141a2e..08e24beee 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -364,9 +364,13 @@ import { isPtySendPreDeliveryError, isTrackedAgentCliToolType, providerSupportsCrossMachineHandoffFork, - providerForkIsContextSeeded, + providerForkReplaysTranscript, providerSupportsHandoffFork, } from "../../../shared/types"; +import { + supportsActiveTurnDispatchMode, + unsupportedActiveTurnDispatchModeMessage, +} from "../../../shared/types/chat"; import { providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { flattenAnswerForSingleStringProvider, @@ -661,7 +665,11 @@ import { resolveCursorSdkPolicy, } from "./cursorSdkPolicy"; import { + CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT, classifyCursorSdkErrorText, + isCursorSdkStaleAccessTokenText, + readCursorSdkStaleTokenFailure, + type CursorSdkStaleTokenFailure, type CursorSdkErrorKind, type CursorSdkAgentMode, type CursorSdkCloudArtifactDescriptor, @@ -1866,6 +1874,23 @@ type CursorRuntime = { availableModelIds: string[]; /** Set when the user switches Cursor models during a live run; flushed when idle. */ pendingModelSwitchReset?: boolean; + /** + * Armed only for the "interrupt & continue" redirect. That stop exists purely + * to resend on the same thread, so the user's other queued messages must + * survive it; every other interrupt still clears the queue. + * + * A runtime flag rather than an `interrupt()` parameter because the cancel it + * suppresses runs later, in the interrupted turn's own tail, not inside + * `interrupt()`. Armed until consumed: `cancelQueuedSteers` clears it when it + * honors it, so a settle that outlives the redirect call is still covered. + */ + preserveQueuedSteersOnInterrupt?: boolean; + /** + * Steer ids whose explicit dispatch is mid-flight. Same contract as + * ClaudeRuntime's: while one is promoted, the turn boundary must not + * auto-deliver another row underneath it (see deliverNextQueuedSteer). + */ + dispatchingSteerIds: Set; pendingSteers: QueuedSteer[]; permissionWaiters: Map; modeConfigId: string | null; @@ -1880,6 +1905,12 @@ type CursorRuntime = { pendingDispatchAck?: { turnId: string; resolve: () => void }; /** First-event watchdog bookkeeping for the in-flight local turn. */ sdkSilenceWatch: CursorSdkSilenceWatch | null; + /** + * Set when the bridge swallows a terminal stale-access-token error for the + * in-flight turn. The turn body owns what the user sees from there: a silent + * recycle-and-resume, or the terminal copy if recovery is already spent. + */ + sdkStaleTokenFailure?: CursorSdkStaleTokenFailure | null; }; /** @@ -1891,6 +1922,13 @@ type CursorSdkSilenceWatch = { /** Bound when the run starts; scopes late events from an abandoned run out. */ runId: string | null; seen: boolean; + /** + * Narrower than `seen`: flips only when the run produced something the model + * actually did — assistant text, reasoning, a tool call or its result. Status + * and activity frames do not count. This is what decides whether a resumed + * turn is asked to *continue* rather than to redo the request. + */ + sawVisibleOutput: boolean; disarm: (() => void) | null; }; @@ -4873,8 +4911,15 @@ function isCursorSdkRuntimeProcessAlive(runtime: CursorRuntime): boolean { */ const CURSOR_SDK_SILENT_RUN_ERROR_NAME = "CursorSdkSilentRunError"; -/** Why ADE decided a Cursor agent thread had to be thrown away. */ -type CursorSdkRecycleReason = "silent_run" | "transport_error"; +/** + * Why ADE decided a Cursor agent thread had to be thrown away. + * + * `stale_token` is the odd one out: the *worker* is dead (its access token + * expired and the SDK never re-exchanges it) but the *thread* is perfectly + * healthy, so that recycle keeps the agent id and resumes it in the fresh + * worker instead of rotating to a new one. + */ +type CursorSdkRecycleReason = "silent_run" | "transport_error" | "stale_token"; /** * Steer ids that have already had a resolution notice emitted — delivered or @@ -4976,7 +5021,7 @@ function armCursorSdkSilenceWatch( // A previous turn's watch on this runtime (settlement can leave one behind) // must not keep a live timer once it can no longer be reached. runtime.sdkSilenceWatch?.disarm?.(); - const watch: CursorSdkSilenceWatch = { turnId, runId: null, seen: false, disarm: null }; + const watch: CursorSdkSilenceWatch = { turnId, runId: null, seen: false, sawVisibleOutput: false, disarm: null }; runtime.sdkSilenceWatch = watch; const guard = new Promise((_resolve, reject) => { let timer: NodeJS.Timeout | null = setTimeout(() => { @@ -5021,6 +5066,41 @@ function noteCursorSdkStreamActivity( disarm?.(); } +/** + * Chat events that prove the model did visible work on this turn, as opposed to + * the run merely existing. Status, activity, token and lifecycle frames are + * deliberately excluded: after those alone there is nothing for a resumed turn + * to "pick up from". + */ +const CURSOR_SDK_VISIBLE_OUTPUT_EVENT_TYPES: ReadonlySet = new Set([ + "text", + "reasoning", + "tool_call", + "tool_result", + "command", + "subagent_started", + "subagent_result", +]); + +/** + * Narrower companion to `noteCursorSdkStreamActivity`; see `sawVisibleOutput`. + * Run-scoped for the same reason its sibling is: a late frame from a run the + * turn already abandoned must not be credited to the turn now in flight. + */ +function noteCursorSdkVisibleOutput( + runtime: CursorRuntime, + turnId: string | null, + runId: string | null, + eventType: string, +): void { + const watch = runtime.sdkSilenceWatch; + if (!watch || watch.sawVisibleOutput) return; + if (turnId && watch.turnId !== turnId) return; + if (watch.runId && runId && watch.runId !== runId) return; + if (!CURSOR_SDK_VISIBLE_OUTPUT_EVENT_TYPES.has(eventType)) return; + watch.sawVisibleOutput = true; +} + /** * True when a thrown Cursor SDK error is a transport failure — the signal the * automatic recycle-and-resend recovery keys on. @@ -5044,6 +5124,75 @@ function isCursorSdkTransportErrorResult(result: unknown): boolean { return isCursorSdkTransportError(asRecord(record.error)); } +/** + * User-facing copy for the only stale-token failure that ever reaches the + * transcript: the silent reconnect already happened and Cursor rejected the + * retry too, so the key itself has to be re-authorized. + */ +const CURSOR_SDK_STALE_TOKEN_MESSAGE = + "Cursor's session expired. ADE reconnected and retried, but Cursor rejected the request again — " + + "sign in to Cursor again in Settings, then resend."; + +/** + * Prompt for the recovery re-send when the token expired *after* the run had + * started working. The thread is resumed, so the original request is already in + * the model's context; repeating it would restart finished work. + */ +const CURSOR_SDK_STALE_TOKEN_CONTINUATION = + "Continue where you left off; the previous run was cut off by a Cursor session refresh. " + + "Do not start over — pick up from the last step you completed."; + +/** + * True when a thrown Cursor SDK error is the expired-access-token signature — + * the worker's token aged out mid-session and the SDK will never refresh it. + * See `isCursorSdkStaleAccessTokenText` for why this is matched exactly rather + * than folded into the generic `auth` kind. + */ +function isCursorSdkStaleTokenError(error: unknown): boolean { + return isCursorSdkStaleAccessTokenText( + readErrorMessage(error), + readCursorSdkStructuredErrorText(error) ?? readErrorDetail(error), + readErrorCodeString(error), + ); +} + +/** Same check against a settled run result that reports `status: "error"`. */ +function isCursorSdkStaleTokenResult(result: unknown): boolean { + const record = asRecord(result); + if (record?.status !== "error") return false; + return isCursorSdkStaleTokenError(asRecord(record.error)); +} + +/** + * Reads and clears the stale-token failure the bridge parked for this turn. + * A function rather than an inline read, so the turn body's own + * `sdkStaleTokenFailure = null` reset cannot narrow the property to `never`. + */ +function takeCursorSdkStaleTokenFailure( + runtime: CursorRuntime, + turnId: string, +): CursorSdkStaleTokenFailure | null { + const failure = runtime.sdkStaleTokenFailure; + if (!failure || failure.turnId !== turnId) return null; + runtime.sdkStaleTokenFailure = null; + return failure; +} + +/** + * Rebuilds the suppressed stale-token failure as a throwable error, so the one + * path that has to surface it (recovery already spent) goes through the same + * classifier as every other Cursor turn failure and keeps the request id. + */ +function cursorSdkStaleTokenError(detail: CursorSdkStaleTokenFailure): Error { + const error = new Error(detail.message) as Error & { cursorSdk?: Record }; + error.cursorSdk = { + message: detail.message, + ...(detail.code ? { code: detail.code } : {}), + ...(detail.requestId ? { requestId: detail.requestId } : {}), + }; + return error; +} + function clearCursorSdkSilenceWatch(runtime: CursorRuntime, turnId?: string): void { const watch = runtime.sdkSilenceWatch; if (!watch) return; @@ -5059,7 +5208,26 @@ function classifyCursorSdkChatError( message: string; detail?: string; errorInfo: { category: ChatErrorCategory; provider?: string; model?: string }; + /** + * Machine-readable marker for the expired-access-token failure. The category + * stays `auth` so renderers keep treating it like any other auth error; this + * flag is what the recycle-and-resume recovery keys on. + */ + cursorSdkStaleToken?: true; } { + if (isCursorSdkStaleTokenError(error)) { + const detail = readCursorSdkStructuredErrorText(error) ?? readErrorDetail(error); + return { + message: CURSOR_SDK_STALE_TOKEN_MESSAGE, + ...(detail ? { detail } : {}), + errorInfo: { + category: "auth", + provider: args.cloud ? "Cursor Cloud" : "Cursor", + ...(args.modelDisplayName ? { model: args.modelDisplayName } : {}), + }, + cursorSdkStaleToken: true, + }; + } if (isCursorSdkAgentBusyError(error)) { const raw = readErrorMessage(error); return { @@ -10905,37 +11073,23 @@ export function createAgentChatService(args: { * on an agent that has never seen it: an agent rotation (target === source) * and an ADE-side fork (target is the new chat). * - * The continuity summary is passed in rather than read off `target`, so each - * caller states where it came from instead of depending on having copied it - * onto the target first. + * Only the short header lands here. The conversation itself travels as the + * full fitted transcript replay (`pendingTranscriptReplay`), so nothing here + * repeats a conversation tail — that produced a doubled, and much thinner, + * view of the same chat. * - * Skips staging entirely when there is nothing to carry — a header that - * announces restored context with no context attached just lies to the model. * The lane directive key is cleared regardless, because the agent is new and * must receive the lane execution directive rather than have it deduped away. */ - const stageCursorSdkContinuityContext = ( + const stageCursorSdkContinuityHeader = ( target: ManagedChatSession, - source: ManagedChatSession, headerSection: string, - continuitySummary: string | null, ): void => { - const summary = continuitySummary?.trim() ?? ""; - const recentConversation = buildRecentConversationContext(source); - if (summary.length || recentConversation.length) { - const sections = [headerSection]; - if (summary.length) sections.push(["Continuity Summary", summary].join("\n")); - if (recentConversation.length) { - sections.push(["Recent Conversation Tail", recentConversation].join("\n")); - } - const existing = target.pendingReconstructionContext?.trim(); - if (existing) sections.push(existing); - const nextContext = sections - .map((section) => section.trim()) - .filter((section) => section.length > 0) - .join("\n\n"); - target.pendingReconstructionContext = nextContext.length ? nextContext : null; - } + const existing = target.pendingReconstructionContext?.trim() ?? ""; + const nextContext = [headerSection.trim(), existing] + .filter((section) => section.length > 0) + .join("\n\n"); + target.pendingReconstructionContext = nextContext.length ? nextContext : null; clearLaneDirectiveKey(target); }; @@ -10989,28 +11143,69 @@ export function createAgentChatService(args: { }; }; + /** + * Agent rotation recovery. The Cursor SDK opened a different agent than the + * one ADE asked to resume, so the new agent has none of the thread. It is + * seeded with the full fitted transcript replay (bounded by the session + * model's context window) rather than a 20-line tail, so the turn that + * triggered the rotation continues with the conversation intact. + */ const stageCursorSdkAgentRotationRecovery = ( managed: ManagedChatSession, previousAgentId: string, nextAgentId: string, ): void => { - stageCursorSdkContinuityContext(managed, managed, [ + const fit = buildFittedTranscriptReplay( + readTranscriptEnvelopes(managed, { includeBuffered: true }), + resolveSessionModelDescriptor(managed.session)?.contextWindow ?? null, + ); + if (!fit.turnCount || !fit.text.trim().length) { + // Nothing to carry: a header that announces restored context with no + // context attached just lies to the model. + clearLaneDirectiveKey(managed); + return; + } + stageCursorSdkContinuityHeader(managed, [ "Cursor SDK continuity recovery", `ADE attempted to resume Cursor SDK agent ${previousAgentId}, but the Cursor SDK opened agent ${nextAgentId} instead.`, + "The conversation so far was replayed into this agent in full.", CURSOR_CONTINUITY_DISCLAIMER, - ].join("\n"), managed.continuitySummary); + ].join("\n")); + // Same guard as the recycle restage: a replay already staged (a fork seed, + // or a previous rotation the turn has not consumed yet) is not overwritten. + // Both fits derive from the same transcript, so the incumbent is never + // thinner — and clobbering it would drop a fork's disclosure staging. + if (!managed.pendingTranscriptReplay) { + managed.pendingTranscriptReplay = fit.text; + } + persistChatState(managed); + if (fit.truncated) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: `Replayed the full prior transcript onto a new Cursor agent. Oldest ${fit.truncatedTurnCount} turn${fit.truncatedTurnCount === 1 ? " was" : "s were"} dropped to fit the context window.`, + }); + } + logger.info("agent_chat.cursor_sdk_rotation_transcript_replay_staged", { + sessionId: managed.session.id, + keptTurnCount: fit.keptTurnCount, + truncatedTurnCount: fit.truncatedTurnCount, + }); }; /** - * Cursor's ADE-side fork. `@cursor/sdk` 1.0.23 exposes no fork/clone/branch + * Cursor's ADE-side fork. `@cursor/sdk` exposes no fork/clone/branch * operation on an agent, and a Cursor thread cannot be resumed twice, so the - * forked chat necessarily starts on a fresh agent. Rather than starting cold, - * it inherits the source conversation the same way an agent rotation does. + * forked chat necessarily starts on a fresh agent. It is seeded with the same + * full fitted transcript replay the cross-provider fork uses, staged by the + * caller right after the transcript is copied into the new chat. * - * Only the continuity summary is copied across: the shared fork path replays - * the source transcript into the new session via `appendImportedChatEvents`, - * which rebuilds `recentConversationEntries` on its own. Copying them here as - * well produced a doubled conversation tail. + * Only the header and the continuity summary fields are handled here. The + * summary is carried over for the forked chat's own identity-continuity + * reconstruction (`refreshReconstructionContext`, identity-keyed chats only); + * agent rotation no longer reads it, and it is deliberately not injected into + * the seeding context here: the verbatim transcript replay strictly contains + * everything the tail-derived summary would have said. * * The new session deliberately has no `cursorSdkAgentId`, so its first send * creates a brand-new Cursor agent instead of resuming the source's thread. @@ -11023,11 +11218,11 @@ export function createAgentChatService(args: { created.continuitySummary = source.continuitySummary ?? persistedSource?.continuitySummary ?? null; created.continuitySummaryUpdatedAt = source.continuitySummaryUpdatedAt ?? persistedSource?.continuitySummaryUpdatedAt ?? null; - stageCursorSdkContinuityContext(created, source, [ + stageCursorSdkContinuityHeader(created, [ "Forked Cursor chat", - "This chat was forked from an earlier ADE chat. Cursor threads cannot be resumed twice, so this is a brand-new Cursor agent seeded with the previous conversation.", + "This chat was forked from an earlier ADE chat. Cursor threads cannot be resumed twice, so this is a brand-new Cursor agent and the earlier conversation was replayed into it in full.", CURSOR_CONTINUITY_DISCLAIMER, - ].join("\n"), created.continuitySummary); + ].join("\n")); }; const detectAuth = async () => { @@ -29108,9 +29303,24 @@ export function createAgentChatService(args: { const cancelQueuedSteers = ( managed: ManagedChatSession, - runtime: Pick, + runtime: Pick + & { preserveQueuedSteersOnInterrupt?: boolean }, reason: "interrupted" | "failed" | "disposed", ): void => { + // "Interrupt & continue" stops the run only so the redirect can be resent + // on the same thread. The user's other queued messages are not part of + // that stop, so they ride through and are delivered once the redirect turn + // finishes. Every other interrupt still clears them. + // + // Consume-once, and consumed on ANY terminal cancel: the flag covers + // exactly the one cancel the redirect's own stop produces, however long the + // turn takes to settle. Clearing it even when the turn ends as "failed" + // (the interrupt landed, but the turn tail reported a failure instead) + // stops a stale flag from silently preserving the queue on the user's next, + // unrelated Stop. + const preserveQueuedSteers = runtime.preserveQueuedSteersOnInterrupt === true; + runtime.preserveQueuedSteersOnInterrupt = false; + if (reason === "interrupted" && preserveQueuedSteers) return; const cancelled = runtime.pendingSteers.splice(0); if (!cancelled.length) return; @@ -29476,7 +29686,10 @@ export function createAgentChatService(args: { // message is being built. Do not auto-deliver another row at the parent // turn boundary; the idle reader will consume the explicit dispatch, then // remaining staged rows can proceed in order. - if (runtime.kind === "claude" && runtime.dispatchingSteerIds.size > 0) return false; + if ( + (runtime.kind === "claude" || runtime.kind === "cursor") + && runtime.dispatchingSteerIds.size > 0 + ) return false; const nextSteer = runtime.pendingSteers.shift(); if (!nextSteer) return false; @@ -31186,7 +31399,12 @@ export function createAgentChatService(args: { const nativeFork = handoffMode === "fork" && providerSupportsHandoffFork(sourceProvider) && targetProvider === sourceProvider; - const replayFork = handoffMode === "fork" && !nativeFork; + // Cursor's "native" fork is native only in the sense that it stays on the + // same provider — there is no SDK fork API, so the new chat starts on a + // fresh agent and needs the same full transcript replay a cross-provider + // fork gets. + const cursorReplayFork = nativeFork && sourceProvider === "cursor"; + const replayFork = handoffMode === "fork" && (!nativeFork || cursorReplayFork); const sourceClaudeRuntime = nativeFork && managed.session.provider === "claude" ? ensureClaudeSessionRuntime(managed) : null; @@ -31332,8 +31550,9 @@ export function createAgentChatService(args: { sessionService.setResumeCommand(created.id, `chat:droid:${created.id}`); } if (createdManaged.session.provider === "cursor") { - // No provider-side pointer to seed: the fork is context-only, on a - // fresh Cursor agent created by the new session's first send. + // No provider-side pointer to seed: a Cursor thread cannot be resumed + // twice, so the fork starts on a fresh agent and the source + // conversation reaches it as a full transcript replay. stageCursorSdkForkContinuity(managed, createdManaged); } } @@ -31934,7 +32153,7 @@ export function createAgentChatService(args: { } // Cursor forks locally, so "can't fork history" would contradict the // Fork tab the user just used. - if (providerForkIsContextSeeded(managed.session.provider)) { + if (providerForkReplaysTranscript(managed.session.provider)) { throw new Error("Cursor forks can't move between machines. Use a brief handoff instead."); } throw new Error("This chat's provider can't fork history. Use a brief handoff instead."); @@ -35240,6 +35459,25 @@ export function createAgentChatService(args: { const turnId = isCloud ? (runtime.cloudRuns.get(meta?.runId ?? "")?.turnId ?? runtime.activeTurnId ?? "") : (runtime.activeTurnId ?? ""); + // An expired access token is ADE's problem, not the user's: swallow the + // raw "Cursor run failed: Authentication error …" card here and let the + // turn body either recover silently or surface the plain-English copy + // once. Recorded rather than dropped, because the settled run result does + // not always carry the same detail. + const staleTokenFailure = !isCloud && turnId && isCursorSdkTerminalErrorEvent(event) + ? readCursorSdkStaleTokenFailure(event, turnId) + : null; + if (staleTokenFailure) { + runtime.sdkStaleTokenFailure = staleTokenFailure; + logger.warn("agent_chat.cursor_sdk_stale_token_detected", { + sessionId: managed.session.id, + turnId, + agentId: runtime.sdkAgentId, + runId: meta?.runId ?? null, + requestId: staleTokenFailure.requestId ?? null, + }); + return; + } const events = mapCursorSdkMessageToChatEvents(event, { turnId, cwd: managed.laneWorktreePath, @@ -35247,6 +35485,7 @@ export function createAgentChatService(args: { ...(meta?.runId ? { runId: meta.runId } : {}), }); for (const ev of events) { + if (!isCloud && turnId) noteCursorSdkVisibleOutput(runtime, turnId, meta?.runId ?? null, ev.type); emitCursorSdkMappedEvent(managed, runtime, ev); } }; @@ -35502,6 +35741,7 @@ export function createAgentChatService(args: { modelConfigId: null, currentModelId: launchModelSdkId, availableModelIds: [launchModelSdkId], + dispatchingSteerIds: new Set(), pendingSteers: [], permissionWaiters: new Map(), modeConfigId: null, @@ -35533,12 +35773,17 @@ export function createAgentChatService(args: { * Recycle a wedged Cursor agent thread: stop whatever is still nominally * running, evict the pooled worker (its process is alive but useless), drop * the live runtime, and arm a forced rotation so the next - * `ensureCursorSdkRuntime` opens a brand-new agent seeded with the - * conversation tail via `stageCursorSdkAgentRotationRecovery`. + * `ensureCursorSdkRuntime` opens a brand-new agent seeded with the full + * transcript replay via `stageCursorSdkAgentRotationRecovery`. * * Silence-watch teardown and pending-approval cancellation are left to * `teardownRuntime`'s cursor branch rather than duplicated here. * + * @param options.preserveAgentId keep the current `cursorSdkAgentId` so the + * fresh worker resumes the same thread instead of rotating to a new agent. + * Only correct when the thread itself is healthy and the worker is not — the + * expired-access-token case. + * * @returns the queued steers lifted off the dying runtime. They are attached * to nothing once this resolves, so the caller must settle every one of them * — re-queue onto a live runtime, or cancel with a notice. @@ -35547,6 +35792,7 @@ export function createAgentChatService(args: { managed: ManagedChatSession, runtime: CursorRuntime, reason: CursorSdkRecycleReason, + options: { preserveAgentId?: boolean } = {}, ): Promise => { // Bounded: the pool's `request()` only rejects once the worker is disposed, // which happens below — an unbounded await here would hang in exactly the @@ -35574,8 +35820,14 @@ export function createAgentChatService(args: { // Arm the rotation only on the branch that actually tears the runtime down. // Otherwise the flag would sit unconsumed and later rotate a healthy agent // out from under an unrelated turn, with a bogus recovery preamble. - cursorSdkForcedAgentRotation.set(managed, { previousAgentId: runtime.sdkAgentId }); - if (runtime.sdkAgentId) managed.cursorSdkPendingRotationPreviousAgentId = runtime.sdkAgentId; + // + // `preserveAgentId` skips it entirely: an expired access token kills the + // worker, not the thread, so the fresh worker resumes the same agent id and + // the conversation survives intact — no rotation, no continuity preamble. + if (options.preserveAgentId !== true) { + cursorSdkForcedAgentRotation.set(managed, { previousAgentId: runtime.sdkAgentId }); + if (runtime.sdkAgentId) managed.cursorSdkPendingRotationPreviousAgentId = runtime.sdkAgentId; + } // Cursor steers live only on the runtime object and are not persisted, so // the rebuilt runtime starts empty. Lift them off before teardown and hand // them back, so a message the user typed during the outage is re-queued on @@ -35641,6 +35893,16 @@ export function createAgentChatService(args: { turn: CursorTurnRef; consumedTurnContext: ConsumedTurnContextPrefix | null; resolveDispatch: (() => void) | null; + /** + * True when the failed attempt had already emitted visible output — + * assistant text, reasoning, a tool call — not merely any stream frame. + * Only `stale_token` can reach recovery in that state (a token can + * expire an hour into a working turn), and it changes what the re-send + * says: pick up where the run was cut off rather than repeat the whole + * prompt. Asking a run that produced nothing to "continue" would name a + * last completed step that does not exist. + */ + sawVisibleOutput: boolean; }; /** Identity of one Cursor turn, for the terminal-event emitters. */ @@ -35724,6 +35986,8 @@ export function createAgentChatService(args: { const turnModelId = managed.session.modelId; runtime.interrupted = false; runtime.busy = true; + // Never let a previous turn's swallowed auth failure decide this one. + runtime.sdkStaleTokenFailure = null; runtime.activeTurnId = turnId; runtime.sdkPolicy = resolveCursorSdkPolicy(managed.session); setSessionActive(managed); @@ -35825,12 +36089,16 @@ export function createAgentChatService(args: { && !managed.closed && !runtime.interrupted && managed.runtime === runtime; - const cursorRecoverySentinel = (reason: CursorSdkRecycleReason): CursorSdkTurnOutcome => { + const cursorRecoverySentinel = ( + reason: CursorSdkRecycleReason, + sawVisibleOutput = false, + ): CursorSdkTurnOutcome => { const pendingAck = runtime.pendingDispatchAck?.turnId === turnId ? runtime.pendingDispatchAck : null; if (pendingAck) runtime.pendingDispatchAck = undefined; return { kind: "recover", reason, + sawVisibleOutput, runtime, turn: { turnId, turnModel, turnModelId }, consumedTurnContext, @@ -35857,7 +36125,21 @@ export function createAgentChatService(args: { // re-send, or a send after settlement dismissal). A normal send must // never do this — it would discard a turn still genuinely working. ...(forceExpireActiveRun ? { forceExpireActiveRun: true } : {}), - idempotencyKey: cursorLocalIdempotencyKey(managed, turnId), + // The recovery re-send is a distinct message to Cursor: on the + // stale-token path it lands on the *same* resumed agent, where reusing + // the first attempt's key could be deduped away into a silent no-op. + // + // Accepted trade-off of that distinctness: when the token died BEFORE + // any visible output, attempt 2 re-sends the prompt verbatim onto the + // resumed thread. `forceExpireActiveRun` expires the wedged run but + // does not unregister the message the first run already recorded in the + // SDK's local agent store, so the thread can hold the user prompt + // twice. Silent, correct recovery is worth one duplicated prompt line; + // the alternative (deduping) is the silent no-op that leaves the user + // with no answer at all. + idempotencyKey: args.cursorRecoveryAttempt === true + ? `${cursorLocalIdempotencyKey(managed, turnId)}:recovery` + : cursorLocalIdempotencyKey(managed, turnId), mode: cursorSdkModeForPolicy(policy), }); let result: unknown; @@ -35869,9 +36151,18 @@ export function createAgentChatService(args: { // The abandoned send rejects once the worker is disposed; swallow it so // it never lands as an unhandled rejection. if (silent) void sendPromise.catch(() => {}); - const transport = !silent && !silenceWatch.seen && isCursorSdkTransportError(error); - if ((silent || transport) && canRecoverCursorThread()) { - return cursorRecoverySentinel(silent ? "silent_run" : "transport_error"); + // Unlike the transport branch, a stale token recovers even after the + // run streamed output: the token ages out roughly an hour in, so the + // common shape is a turn cut off mid-work. Checked before `transport` + // because the two signatures are disjoint by construction (this text + // classifies as `auth`, never `network`) and this one is the narrower. + const staleToken = !silent && isCursorSdkStaleTokenError(error); + const transport = !silent && !staleToken && !silenceWatch.seen && isCursorSdkTransportError(error); + if ((silent || transport || staleToken) && canRecoverCursorThread()) { + return cursorRecoverySentinel( + silent ? "silent_run" : staleToken ? "stale_token" : "transport_error", + silenceWatch.sawVisibleOutput, + ); } if (silent) { // Terminal silence: the rotated agent stopped answering too. Recycle @@ -35886,6 +36177,21 @@ export function createAgentChatService(args: { throw error; } clearCursorSdkSilenceWatch(runtime, turnId); + // An expired token usually arrives as the run's own terminal error rather + // than a thrown send: the bridge swallowed the raw card and parked the + // detail here, and the settled result echoes it. Either way the worker is + // now permanently unauthenticated, so the turn cannot continue on it. + const staleTokenFailure = takeCursorSdkStaleTokenFailure(runtime, turnId); + if (staleTokenFailure || isCursorSdkStaleTokenResult(result)) { + if (canRecoverCursorThread()) return cursorRecoverySentinel("stale_token", silenceWatch.sawVisibleOutput); + // Recovery already spent (or the session went away): surface it once, + // in plain English, through the shared failure classifier. + throw cursorSdkStaleTokenError(staleTokenFailure ?? { + turnId, + message: readErrorMessage(asRecord(result)?.error) + || CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT, + }); + } if (!silenceWatch.seen && isCursorSdkTransportErrorResult(result) && canRecoverCursorThread()) { // The run failed on the wire before emitting anything: recycling and // re-sending cannot duplicate visible output. @@ -36035,17 +36341,28 @@ export function createAgentChatService(args: { const { runtime, turn } = first; const { turnId } = turn; + // The expired-token recovery keeps the thread: same agent id, resumed in a + // fresh worker. `resumedMidTurn` is the sub-case where the token died after + // real output — the prompt was delivered and partly executed, so attempt 2 + // asks Cursor to continue rather than replaying the whole request. + const staleToken = first.reason === "stale_token"; + const resumedMidTurn = staleToken && first.sawVisibleOutput; logger.warn("agent_chat.cursor_sdk_thread_recycling", { sessionId: managed.session.id, turnId, reason: first.reason, previousAgentId: runtime.sdkAgentId, + preservedAgentId: staleToken, + resumedMidTurn, watchdogMs: CURSOR_SDK_FIRST_EVENT_WATCHDOG_MS, }); // Restage each consumed bucket on the rotated agent. Flattening both into // pendingReconstructionContext would wrap the verbatim replay in a // continuity header and double-wrap the continuity half on the next consume. - if (first.consumedTurnContext) { + // + // Skipped for a mid-turn token refresh: the thread is resumed intact and the + // model already received this context, so restaging would replay it twice. + if (first.consumedTurnContext && !resumedMidTurn) { if (first.consumedTurnContext.replay && !managed.pendingTranscriptReplay) { managed.pendingTranscriptReplay = first.consumedTurnContext.replay; } @@ -36055,7 +36372,9 @@ export function createAgentChatService(args: { persistChatState(managed); } - const carriedSteers = await recycleCursorSdkAgentThread(managed, runtime, first.reason); + const carriedSteers = await recycleCursorSdkAgentThread(managed, runtime, first.reason, { + preserveAgentId: staleToken, + }); // Re-checked after the recycle, not before it: cancelling the wedged run // and tearing the worker down is exactly when a user is most likely to hit @@ -36074,14 +36393,35 @@ export function createAgentChatService(args: { // Emitted only once the re-send is actually going to happen — announcing it // before the interrupt re-check promised a resend that never came. - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - message: first.reason === "silent_run" - ? "Cursor stopped responding. ADE opened a fresh Cursor thread and is resending your message." - : "Cursor's connection dropped. ADE opened a fresh Cursor thread and is resending your message.", - turnId, - }); + // + // A token refresh caught before any output is invisible by design: nothing + // reached the user, so there is nothing to explain. Only the mid-turn case + // says anything, because the reply visibly stopped and then continues. + if (staleToken) { + logger.info("agent_chat.cursor_sdk_stale_token_recovered", { + sessionId: managed.session.id, + turnId, + agentId: runtime.sdkAgentId, + resumedMidTurn, + }); + if (resumedMidTurn) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Reconnected to Cursor and continued.", + turnId, + }); + } + } else { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: first.reason === "silent_run" + ? "Cursor stopped responding. ADE opened a fresh Cursor thread and is resending your message." + : "Cursor's connection dropped. ADE opened a fresh Cursor thread and is resending your message.", + turnId, + }); + } const { onDispatched: _dispatchedOnAttemptOne, onBackendDispatched: _ackOnAttemptOne, ...retryArgs } = args; let requeuedOn: CursorRuntime | null = null; @@ -36095,6 +36435,20 @@ export function createAgentChatService(args: { } await runCursorSdkTurnOnce(managed, { ...retryArgs, + // Mid-turn token refresh: the resumed thread already holds the prompt + // and everything the model did with it, so re-sending it verbatim would + // make Cursor start the same work twice. Attachments rode along with + // the original message and are dropped for the same reason. + ...(resumedMidTurn + ? { + promptText: CURSOR_SDK_STALE_TOKEN_CONTINUATION, + displayText: CURSOR_SDK_STALE_TOKEN_CONTINUATION, + userText: CURSOR_SDK_STALE_TOKEN_CONTINUATION, + attachments: [], + contextAttachments: [], + resolvedAttachments: [], + } + : {}), turnId, // The user bubble, turn-started status, activity and before-SHA all // landed on attempt 1; the re-send must not duplicate them. @@ -38604,7 +38958,7 @@ export function createAgentChatService(args: { dispatchMode, } = expandedArgs; if (dispatchMode !== undefined && dispatchMode !== "inline" && dispatchMode !== "interrupt") { - throw new Error(`Unsupported Claude steer dispatch mode: ${String(dispatchMode)}`); + throw new Error(`Unsupported steer dispatch mode: ${String(dispatchMode)}`); } const trimmed = text.trim(); const steerId = randomUUID(); @@ -38616,8 +38970,10 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(sessionId); assertContinuityDispatchAllowed(managed); - if (dispatchMode && managed.session.provider !== "claude") { - throw new Error("Atomic steer dispatch modes are only supported on Claude sessions."); + // One guard against the canonical per-provider table, rather than the rules + // restated here. Reject rather than silently downgrading the user's choice. + if (dispatchMode && !supportsActiveTurnDispatchMode(managed.session.provider, dispatchMode)) { + throw new Error(unsupportedActiveTurnDispatchModeMessage(managed.session.provider, dispatchMode)); } if (hasLivePendingInput(managed) && !metadata?.scheduledWake && !options?.allowPendingInput) { throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE); @@ -38727,6 +39083,26 @@ export function createAgentChatService(args: { if (managed.session.provider === "cursor") { if (managed.runtime?.kind === "cursor" && managed.runtime.busy) { const rt = managed.runtime; + // Interrupt & continue: stop the live run, wait for it to settle, then + // resend on the same agent. Nothing is staged, so this never enters the + // pending-steer queue. + if (dispatchMode === "interrupt") { + await interruptAndContinueTurn(managed, { + sessionId, + text: trimmed, + ...(displayText != null && displayText !== trimmed ? { displayText } : {}), + attachments, + contextAttachments, + metadata, + // Per-message overrides ride the redirect, exactly as they would on + // a staged steer delivered at the turn boundary. + reasoningEffort, + executionMode, + interactionMode, + mentionsAlreadyExpanded: true, + }); + return { steerId, queued: false }; + } const preparedSteer = prepareSendMessage({ sessionId, text: trimmed, @@ -39096,6 +39472,88 @@ export function createAgentChatService(args: { return result; }; + /** + * Mid-turn redirect for the providers that have no inline steer channel + * (Cursor, Droid). The Cursor SDK exposes no way to push a message into a + * live run, so "interrupt & continue" is cancel + resend on the same agent: + * the SDK's local agent store keeps the thread, so the resend continues the + * same conversation with no context injection. + * + * Only Cursor's redirect softens the stop. Cursor uses `stop_only` and arms + * `preserveQueuedSteersOnInterrupt`, so anything the user had already queued + * survives and is delivered after the redirect turn completes. Every other + * provider keeps the pre-existing `stop_and_clear` contract for + * `interrupt-replace`, which clears the queue. + */ + const interruptAndContinueTurn = async ( + managed: ManagedChatSession, + args: { + sessionId: string; + text: string; + displayText?: string; + attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; + metadata?: AgentChatEventMetadata | null | undefined; + reasoningEffort?: string | null; + executionMode?: AgentChatExecutionMode | null; + interactionMode?: AgentChatInteractionMode | null; + /** + * Set by callers that already ran `applyChatMentionExpansion` on `text` + * (steerWithOptions, and the staged rows dispatchSteer promotes). Without + * it `sendMessage` expands a second time, and expanded file content that + * itself contains chip syntax would be expanded again. + */ + mentionsAlreadyExpanded?: boolean; + }, + ): Promise => { + const runtime = managed.runtime; + const preserveQueuedSteers = managed.session.provider === "cursor"; + const queueOwner = preserveQueuedSteers && runtime?.kind === "cursor" ? runtime : null; + // Armed until consumed, not for the duration of this call: the cancel this + // protects against happens later, inside the interrupted turn's own tail + // (`cancelQueuedSteers`), not inside `interrupt()` — which is why it is a + // runtime flag rather than an `interrupt()` parameter. A blanket `finally` + // would disarm it while the turn is still stopping (the settle wait can + // time out with the run still live) and the user's other queued messages + // would then be wiped by exactly the stop this call issued. + const armedHere = Boolean(queueOwner && !queueOwner.preserveQueuedSteersOnInterrupt); + if (armedHere && queueOwner) queueOwner.preserveQueuedSteersOnInterrupt = true; + try { + await interrupt({ sessionId: args.sessionId, mode: preserveQueuedSteers ? "stop_only" : "stop_and_clear" }); + } catch (error) { + // The stop never reached the runtime, so no cancel will come for it. + // Disarm rather than leaving the flag to swallow an unrelated interrupt's + // cancel later. + if (armedHere && queueOwner) queueOwner.preserveQueuedSteersOnInterrupt = false; + throw error; + } + await waitForCursorDroidTurnToSettleAfterInterrupt(managed, args.sessionId); + // Settled, so the turn's tail has already run `cancelQueuedSteers` and + // consumed the flag; this is the no-op that closes the case where it did + // not (nothing queued, or a runtime that never reached its tail). + if (armedHere && queueOwner) queueOwner.preserveQueuedSteersOnInterrupt = false; + logger.info("agent_chat.interrupt_and_continue", { + sessionId: args.sessionId, + provider: managed.session.provider, + preservedQueuedSteers: queueOwner?.pendingSteers.length ?? 0, + }); + const sendArgs: AgentChatSendArgs = { + sessionId: args.sessionId, + text: args.text, + ...(args.displayText != null ? { displayText: args.displayText } : {}), + attachments: args.attachments ?? [], + contextAttachments: args.contextAttachments ?? [], + metadata: args.metadata, + ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args.executionMode !== undefined ? { executionMode: args.executionMode } : {}), + ...(args.interactionMode !== undefined ? { interactionMode: args.interactionMode } : {}), + }; + await sendMessage( + args.mentionsAlreadyExpanded ? markChatMentionsExpanded(sendArgs) : sendArgs, + { awaitDispatch: false }, + ); + }; + const normalizeMessageSessionKind = ( kind: AgentChatMessageSessionArgs["kind"], ): AgentChatMessageSessionKind => { @@ -39218,18 +39676,13 @@ export function createAgentChatService(args: { if (normalizedKind === "interrupt-replace") { if (managed.session.provider !== "claude") { - await interrupt({ sessionId }); - await waitForCursorDroidTurnToSettleAfterInterrupt(managed, sessionId); - await sendMessage( - { - sessionId, - text, - attachments, - contextAttachments, - metadata, - }, - { awaitDispatch: false }, - ); + await interruptAndContinueTurn(managed, { + sessionId, + text, + attachments, + contextAttachments, + metadata, + }); } else if (statusBefore === "active") { await steer({ sessionId, @@ -39290,7 +39743,14 @@ export function createAgentChatService(args: { } const queue = runtime.pendingSteers; - if (requireQueued && runtime.kind === "claude" && runtime.dispatchingSteerIds.has(steerId)) { + // Both runtimes that track in-flight dispatches splice the row out of the + // queue before the dispatch completes, so without this the user would be + // told the message is "no longer queued" while it is in fact being sent. + if ( + requireQueued + && (runtime.kind === "claude" || runtime.kind === "cursor") + && runtime.dispatchingSteerIds.has(steerId) + ) { throw new Error("This message is already being dispatched."); } const idx = queue.findIndex((s) => s.steerId === steerId); @@ -39357,18 +39817,93 @@ export function createAgentChatService(args: { mode, }: AgentChatDispatchSteerArgs): Promise => { if (mode !== "inline" && mode !== "interrupt") { - throw new Error(`Unsupported Claude steer dispatch mode: ${String(mode)}`); + throw new Error(`Unsupported steer dispatch mode: ${String(mode)}`); } const managed = ensureManagedSession(sessionId); assertContinuityDispatchAllowed(managed); - if (managed.session.provider === "codex") { - throw new Error("dispatchSteer is not supported on Codex sessions."); + // One guard against the canonical per-provider table (shared/types/chat.ts) + // instead of a per-provider ladder: Codex and every other queue-only + // provider reject here, and Cursor rejects "inline". + if (!supportsActiveTurnDispatchMode(managed.session.provider, mode)) { + throw new Error(unsupportedActiveTurnDispatchModeMessage(managed.session.provider, mode)); } if (hasLivePendingInput(managed)) { throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE); } const runtime = managed.runtime; if (!runtime) return { dispatchedAt: null }; + // Cursor: a staged row can be promoted to the interrupt-and-continue + // redirect. There is no inline channel to promote it into. + if (runtime.kind === "cursor") { + const cursorQueue = runtime.pendingSteers; + const cursorIdx = cursorQueue.findIndex((s) => s.steerId === steerId); + if (cursorIdx === -1) return { dispatchedAt: null }; + const [promoted] = cursorQueue.splice(cursorIdx, 1); + claimSteerSettlement(managed, steerId); + // The staged row is resolved by this notice; without it the chip would + // stay parked in the composer's staging area after the redirect. + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: "Delivering your queued message...", + turnId: runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + // Held across the whole redirect: the parent turn can complete on its own + // while the interrupt is settling, and its tail must not shift the NEXT + // staged row off the queue into a turn this redirect then cancels. + runtime.dispatchingSteerIds.add(steerId); + try { + await interruptAndContinueTurn(managed, { + sessionId, + text: promoted.text, + ...(promoted.displayText != null && promoted.displayText !== promoted.text + ? { displayText: promoted.displayText } + : {}), + attachments: promoted.attachments, + contextAttachments: promoted.contextAttachments, + ...(promoted.metadata ? { metadata: promoted.metadata } : {}), + // The staged row's per-message overrides, the same ones + // deliverNextQueuedSteer would apply at the turn boundary. + reasoningEffort: promoted.reasoningEffort, + executionMode: promoted.executionMode, + interactionMode: promoted.interactionMode, + // Staged text was expanded when it entered the queue. + mentionsAlreadyExpanded: true, + }); + } catch (error) { + // Put the row back so the user's message is never silently lost. + if (!cursorQueue.some((entry) => entry.steerId === steerId)) { + cursorQueue.splice(Math.min(cursorIdx, cursorQueue.length), 0, promoted); + reopenSteerSettlement(managed, steerId); + emitChatEvent(managed, { + type: "user_message", + text: promoted.text, + ...(promoted.displayText && promoted.displayText !== promoted.text + ? { displayText: promoted.displayText } + : {}), + ...(promoted.attachments.length ? { attachments: promoted.attachments } : {}), + ...(promoted.contextAttachments.length ? { contextAttachments: promoted.contextAttachments } : {}), + ...(promoted.metadata ? { metadata: promoted.metadata } : {}), + steerId, + deliveryState: "queued", + }); + persistChatState(managed); + } + logger.warn("agent_chat.dispatch_steer_failed", { + sessionId, + steerId, + mode, + provider: "cursor", + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + runtime.dispatchingSteerIds.delete(steerId); + } + return { dispatchedAt: Date.now() }; + } if (runtime.kind !== "claude") { throw new Error(`dispatchSteer is not supported on ${runtime.kind} sessions.`); } diff --git a/apps/desktop/src/main/services/chat/cursorSdkErrors.test.ts b/apps/desktop/src/main/services/chat/cursorSdkErrors.test.ts index 691de6bf1..a27c4b2e6 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkErrors.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkErrors.test.ts @@ -1,6 +1,46 @@ import { describe, expect, it } from "vitest"; import { cursorSdkResultWithStreamFailure, isCursorSdkSandboxUnsupportedError } from "./cursorSdkErrors"; -import { classifyCursorSdkErrorText, isCursorSdkTransportErrorText } from "./cursorSdkProtocol"; +import { + CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT, + classifyCursorSdkErrorText, + isCursorSdkStaleAccessTokenText, + isCursorSdkTransportErrorText, + readCursorSdkStaleTokenFailure, +} from "./cursorSdkProtocol"; + +describe("readCursorSdkStaleTokenFailure", () => { + it("reads the worker's terminal ERROR event into the failure the turn re-throws", () => { + expect(readCursorSdkStaleTokenFailure({ + type: "status", + status: "ERROR", + adeErrorCode: CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT, + adeErrorDetail: { + message: `${CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT} Cursor request ID: req-9`, + code: "unauthenticated", + requestId: "req-9", + }, + }, "turn-1")).toEqual({ + turnId: "turn-1", + message: `${CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT} Cursor request ID: req-9`, + code: "unauthenticated", + requestId: "req-9", + }); + }); + + it("falls back to the code, then to the canonical text, when the detail is thin", () => { + expect(readCursorSdkStaleTokenFailure({ + adeErrorCode: CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT, + }, "turn-2")).toEqual({ turnId: "turn-2", message: CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT }); + }); + + it("returns null for any other terminal error, so it is surfaced normally", () => { + expect(readCursorSdkStaleTokenFailure({ + adeErrorCode: "Invalid API key", + adeErrorDetail: { message: "Invalid API key", code: "unauthenticated" }, + }, "turn-3")).toBeNull(); + expect(readCursorSdkStaleTokenFailure(null, "turn-4")).toBeNull(); + }); +}); describe("isCursorSdkSandboxUnsupportedError", () => { it("matches the SDK ConfigurationError when sandboxing is unavailable", () => { @@ -40,6 +80,31 @@ describe("classifyCursorSdkErrorText", () => { }); }); +describe("isCursorSdkStaleAccessTokenText", () => { + it("matches the SDK's expired-access-token signature", () => { + // Verbatim from the incidents: the SDK never re-exchanges the token for + // this shape, so every later send on the same worker repeats it. + const message = "Authentication error If you are logged in, try logging out and back in."; + expect(isCursorSdkStaleAccessTokenText(message)).toBe(true); + // It arrives as the structured `code` as often as the message, and the + // detail carries a request id alongside it. + expect(isCursorSdkStaleAccessTokenText(null, `Code: ${message}`, "Cursor request ID: abc-123")).toBe(true); + // The category renderers see is unchanged. + expect(classifyCursorSdkErrorText(message)).toBe("auth"); + }); + + it("does not match auth failures a fresh worker cannot fix", () => { + for (const text of [ + "Authentication failed: Invalid API key", + "unauthorized", + "403 Forbidden", + "Authentication error", + ]) { + expect(isCursorSdkStaleAccessTokenText(text)).toBe(false); + } + }); +}); + describe("cursorSdkResultWithStreamFailure", () => { it("marks successful wait results as errors after a stream failure", () => { const result = cursorSdkResultWithStreamFailure( diff --git a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts index 9708b04b7..23ca5d651 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkProtocol.ts @@ -378,6 +378,88 @@ export function isCursorSdkBackoffErrorText(text: string | null | undefined): bo || lower.includes("429"); } +/** + * The exact text the Cursor SDK surfaces for an expired short-lived access + * token. Kept here so the matcher, the bridge's fallback and the terminal + * copy all read one greppable literal. + */ +export const CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT = + "Authentication error If you are logged in, try logging out and back in."; + +/** + * The two halves of that sentence, split around the clause the SDK sometimes + * reflows and stripped of the trailing period — matching both independently is + * what keeps the check robust to the request-id suffix and to casing, without + * widening it to "authentication error" alone. + */ +const STALE_ACCESS_TOKEN_FRAGMENTS = CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT + .toLowerCase() + .replace(/\.$/, "") + .split(" if you are "); + +/** + * True for the one auth failure ADE can fix on its own: the SDK's short-lived + * access token (exchanged once per executor from the user API key) expired + * mid-session, and the SDK only re-exchanges on a Connect `Unauthenticated` + * fault — never on this in-stream shape. Every later send on the same worker + * then fails instantly with the identical text until the worker is replaced. + * + * Deliberately narrow: it must not match a genuinely bad API key ("Invalid API + * key", 401/403), where retrying on a fresh worker would fail the same way. + * The SDK spells this one exactly, as the error message and often again as the + * structured `code`: + * "Authentication error If you are logged in, try logging out and back in." + */ +export function isCursorSdkStaleAccessTokenText( + ...texts: Array +): boolean { + const joined = texts.filter(Boolean).join("\n").toLowerCase(); + if (!joined) return false; + return STALE_ACCESS_TOKEN_FRAGMENTS.every((fragment) => joined.includes(fragment)); +} + +/** One turn's suppressed stale-token failure, kept so it can be re-thrown. */ +export type CursorSdkStaleTokenFailure = { + turnId: string; + message: string; + code?: string; + requestId?: string; +}; + +/** + * Reads the worker's synthetic terminal `status: ERROR` event as a stale-token + * failure, or returns null when it is any other error. One pass over + * `adeErrorCode` / `adeErrorDetail` yields both the decision and the payload, + * so the caller never re-reads the same four fields to build one. + */ +export function readCursorSdkStaleTokenFailure( + event: unknown, + turnId: string, +): CursorSdkStaleTokenFailure | null { + const record = (event && typeof event === "object" ? event : null) as Record | null; + if (!record) return null; + const detail = (record.adeErrorDetail && typeof record.adeErrorDetail === "object" + ? record.adeErrorDetail + : null) as Record | null; + const readString = (value: unknown): string | null => ( + typeof value === "string" && value.trim().length ? value.trim() : null + ); + const errorCode = readString(record.adeErrorCode); + const eventMessage = readString(record.message); + const detailMessage = readString(detail?.message); + const detailCode = readString(detail?.code); + const requestId = readString(detail?.requestId); + if (!isCursorSdkStaleAccessTokenText(errorCode, eventMessage, detailMessage, detailCode)) { + return null; + } + return { + turnId, + message: detailMessage ?? errorCode ?? CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT, + ...(detailCode ? { code: detailCode } : {}), + ...(requestId ? { requestId } : {}), + }; +} + export function classifyCursorSdkErrorText( ...texts: Array ): CursorSdkErrorKind { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 7c55f9705..449561675 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -574,6 +574,80 @@ describe("AgentChatComposer", () => { expect(menu.textContent).toContain("Stop and redirect Claude now."); }); + const CURSOR_STEER_OVERRIDES = { + sessionProvider: "cursor" as const, + modelId: "cursor/composer-2", + availableModelIds: ["cursor/composer-2"], + }; + + it("offers Cursor only interrupt-and-continue plus queue, with interrupt selected by default", () => { + const onSendSteerInterrupt = vi.fn(); + renderComposer({ + ...CURSOR_STEER_OVERRIDES, + // Cursor has no inline dispatch: the host would reject it. + onSendSteerNow: undefined, + onSendSteerInterrupt, + }); + + // The default mode is the redirect, so it is what the primary button runs. + fireEvent.click(screen.getByRole("button", { name: "Interrupt & continue" })); + expect(onSendSteerInterrupt).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "More send options" })); + const options = screen.getAllByRole("menuitemradio").map((item) => item.textContent ?? ""); + expect(options).toHaveLength(2); + expect(options[0]).toContain("Interrupt & continue"); + expect(options[1]).toContain("Send after turn"); + expect(screen.queryByRole("menuitemradio", { name: /Send during turn/ })).toBeNull(); + expect(screen.getByRole("menu", { name: "Send options" }).textContent) + .toContain("Stop and redirect Cursor now."); + }); + + it("falls back to queueing when the picked model's provider offers a mode the live session cannot dispatch", () => { + // A Cursor session with a Claude model picked mid-turn: the composer's + // capability follows the *picked* provider (Claude, whose default is + // "send during turn") while the wired handlers follow the *session* + // (Cursor, which has no inline dispatch). The draft must still go + // somewhere — it queues rather than silently disappearing. + const onSubmit = vi.fn(); + const onSendSteerInterrupt = vi.fn(); + renderComposer({ + ...CLAUDE_STEER_OVERRIDES, + onSubmit, + onSendSteerNow: undefined, + onSendSteerInterrupt, + }); + + // No dead "Send during turn" affordance: the mode downgraded to queue. + expect(screen.queryByRole("button", { name: "Send during turn" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Send after turn" })); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSendSteerInterrupt).not.toHaveBeenCalled(); + + // Enter takes the same route. + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + expect(onSubmit).toHaveBeenCalledTimes(2); + + // And the menu never offers the mode that has nowhere to go. + fireEvent.click(screen.getByRole("button", { name: "More send options" })); + expect(screen.queryByRole("menuitemradio", { name: /Send during turn/ })).toBeNull(); + }); + + it("keeps all three delivery modes on Claude", () => { + renderComposer({ + ...CLAUDE_STEER_OVERRIDES, + onSendSteerNow: vi.fn(), + onSendSteerInterrupt: vi.fn(), + }); + + expect(screen.getByRole("button", { name: "Send during turn" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "More send options" })); + const options = screen.getAllByRole("menuitemradio").map((item) => item.textContent ?? ""); + expect(options).toHaveLength(3); + expect(options[0]).toContain("Send during turn"); + expect(options[2]).toContain("Interrupt & send"); + }); + it("disables the active-turn send actions when the draft is whitespace-only", () => { const onSendSteerNow = vi.fn(); renderComposer({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 2f70de95a..b7ef80a07 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -58,6 +58,12 @@ import { parseChatMentions, } from "../../../shared/chatMentions"; import type { ChatMentionSuggestion } from "../../../shared/types/chatMentions"; +import { + activeTurnDispatchModes, + activeTurnInterruptContinues, + defaultActiveTurnDispatchMode, + type ActiveTurnSendMode, +} from "../../../shared/types/chat"; import { cn } from "../ui/cn"; import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { @@ -104,7 +110,7 @@ import { hydrateChatOutputContextChipsInEditor } from "./composerChatOutputConte import { SmartTooltip } from "../ui/SmartTooltip"; import { VoiceDictationButton } from "./VoiceDictationButton"; import { ProviderLogo } from "../shared/ProviderLogos"; -import { pendingInputHeaderLabel } from "../../../shared/pendingInputLabels"; +import { pendingInputHeaderLabel, providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { useAppStore, useRootAppStore, rootAppStoreApi } from "../../state/appStore"; import { useVoiceModelInstalled } from "../../hooks/useVoiceModelInstalled"; import { @@ -1079,6 +1085,7 @@ function resolveCursorModeOption(snapshot: AgentChatCursorModeSnapshot | null | /** Inline display of a single pending (queued) steer message with cancel and edit controls. */ function PendingSteerItem({ steer, + capability, onCancel, onEdit, onSendNow, @@ -1090,11 +1097,13 @@ function PendingSteerItem({ attachments: AgentChatFileRef[]; contextAttachments: AgentChatContextAttachment[]; }; + capability: ActiveTurnSendCapability; onCancel: () => void; onEdit: () => void; onSendNow?: () => void; onInterrupt?: () => void; }) { + const interruptCopy = activeTurnSendCopy("interrupt", capability); return (
@@ -1108,7 +1117,7 @@ function PendingSteerItem({
{onSendNow ? ( - + @@ -1161,23 +1178,52 @@ function PendingSteerItem({ * delivery mode, while the primary button and Enter execute that mode. All * controls carry force-enabled tooltips so hover explains the action. */ -type ActiveTurnSendMode = "inline" | "queue" | "interrupt"; - -const ACTIVE_TURN_SEND_COPY: Record = { - inline: { - label: "Send during turn", - description: "After the current tool step.", - }, - queue: { - label: "Send after turn", - description: "When this turn finishes.", - }, - interrupt: { - label: "Interrupt & send", - description: "Stop and redirect Claude now.", - }, +export type ActiveTurnSendCapability = { + /** Modes this provider can honor, in menu order. */ + modes: readonly ActiveTurnSendMode[]; + /** Pre-selected mode for a fresh session on this provider. */ + defaultMode: ActiveTurnSendMode; + /** Agent name used in the mode descriptions. */ + agentLabel: string; + /** + * True when interrupting continues the same thread rather than injecting + * into the live turn — the label says "continue" instead of "send". + */ + interruptContinues: boolean; }; +/** + * The copy layer over the canonical `ACTIVE_TURN_DISPATCH_MODES` table in + * `shared/types/chat.ts`. Modes and default come from there; only the labels + * are decided here. Cursor's interrupt continues the same thread (cancel + + * resend on the same agent) rather than injecting into the live run, so its + * button says "continue". + */ +export function activeTurnSendModesForProvider(provider: string | undefined): ActiveTurnSendCapability { + return { + modes: activeTurnDispatchModes(provider), + defaultMode: defaultActiveTurnDispatchMode(provider), + agentLabel: providerDisplayLabel(provider, "the agent"), + interruptContinues: activeTurnInterruptContinues(provider), + }; +} + +function activeTurnSendCopy( + mode: ActiveTurnSendMode, + capability: ActiveTurnSendCapability, +): { label: string; description: string } { + if (mode === "inline") { + return { label: "Send during turn", description: "After the current tool step." }; + } + if (mode === "queue") { + return { label: "Send after turn", description: "When this turn finishes." }; + } + return { + label: capability.interruptContinues ? "Interrupt & continue" : "Interrupt & send", + description: `Stop and redirect ${capability.agentLabel} now.`, + }; +} + function ActiveTurnSendIcon({ mode, size = 14 }: { mode: ActiveTurnSendMode; size?: number }) { if (mode === "interrupt") return ; if (mode === "queue") return ; @@ -1221,18 +1267,30 @@ function composerSplitMenuPosition(anchor: HTMLButtonElement): React.CSSProperti function ActiveTurnSendButton({ enabled, mode, + capability, + allowInline, allowInterrupt, onModeChange, onSend, }: { enabled: boolean; mode: ActiveTurnSendMode; + capability: ActiveTurnSendCapability; + allowInline: boolean; allowInterrupt: boolean; onModeChange: (mode: ActiveTurnSendMode) => void; onSend: () => void; }) { const { caretRef, menuOpen, setMenuOpen } = useComposerSplitMenu("[data-active-send-menu]"); - const selectedCopy = ACTIVE_TURN_SEND_COPY[mode]; + const selectedCopy = activeTurnSendCopy(mode, capability); + // The table says what the provider can do; the wired handlers say what this + // pane can dispatch right now (they follow the *session's* provider, which + // differs from `capability` while a model for another provider is picked + // mid-turn). Only offer a mode both agree on, so the menu can never select a + // dispatch that has nowhere to go. + const offeredModes = capability.modes.filter((option) => ( + (option !== "interrupt" || allowInterrupt) && (option !== "inline" || allowInline) + )); return (
@@ -1263,7 +1321,7 @@ function ActiveTurnSendButton({ forceEnabled content={{ label: "More send options", - description: "Choose how the next message should reach Claude during this turn.", + description: `Choose how the next message should reach ${capability.agentLabel} during this turn.`, }} >
{pendingSteers.map((steer) => ( onCancelSteer?.(steer.steerId)} onEdit={() => onEditSteer?.( steer.steerId, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 8558ca43d..f9ab8785d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -58,7 +58,8 @@ import { } from "../../../shared/types"; import { isUnsupportedAgentChatRecoveryActionError, - providerForkIsContextSeeded, + providerForkReplaysTranscript, + supportsActiveTurnDispatchMode, } from "../../../shared/types/chat"; import { providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { resolveSubagentCapability } from "../../../shared/subagentCapabilities"; @@ -3934,6 +3935,13 @@ export function AgentChatPane({ () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), [sessions, selectedSessionId] ); + // Which atomic active-turn dispatch modes this session's backend accepts, + // read off the canonical table in shared/types/chat.ts rather than restated + // here. + const activeTurnInterruptSupported = + supportsActiveTurnDispatchMode(selectedSession?.provider, "interrupt"); + const activeTurnInlineSupported = + supportsActiveTurnDispatchMode(selectedSession?.provider, "inline"); // Which machine is THIS chat on, and what does its lane look like there. One // derivation, shared with the panel/drawer subtree through // `ChatRuntimeScopeProvider` below, so the pane and its tools cannot disagree. @@ -5731,7 +5739,7 @@ export function AgentChatPane({ // Cursor's same-family fork reseeds context rather than copying a provider // thread, so the panel must not promise the whole conversation comes along // verbatim on that path. Cross-family targets still get the full replay. - const handoffForkIsContextSeeded = providerForkIsContextSeeded(selectedSession?.provider); + const handoffForkReplaysTranscript = providerForkReplaysTranscript(selectedSession?.provider); const handoffForkModelFilter = useCallback((_descriptor: ModelDescriptor) => true, []); const handoffForkAvailableModelIds = handoffAvailableModelIds; const handoffNativeControlState = useMemo((): NativeControlState => ({ @@ -10816,7 +10824,15 @@ export function AgentChatPane({ displayText: finalDisplayText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), - ...(sessionProvider === "claude" && activeTurnDispatchMode ? { dispatchMode: activeTurnDispatchMode } : {}), + // Only send a dispatch mode the session's own backend accepts (see + // the canonical table in shared/types/chat.ts); otherwise this + // stages. The gate reads selectedSession.provider — the provider that + // actually receives the IPC call — not the picked model's provider, + // which diverges while a different model is selected mid-turn. + ...(activeTurnDispatchMode + && supportsActiveTurnDispatchMode(selectedSession?.provider, activeTurnDispatchMode) + ? { dispatchMode: activeTurnDispatchMode } + : {}), }, chatRuntimePinRef.current); }; @@ -11711,10 +11727,10 @@ export function AgentChatPane({ const handoffTurnGate = turnActive || selectedSessionAwaitingInput; const handoffSourceProviderLabel = handoffProviderDisplayName(selectedSession?.provider); - const handoffForkCopy = useMemo(() => (handoffForkIsContextSeeded + const handoffForkCopy = useMemo(() => (handoffForkReplaysTranscript ? { - subtitle: "Fork carries this conversation into a new chat. Brief summarizes it and starts fresh.", - body: <>{handoffSourceProviderLabel} models start a new chat seeded with this conversation’s context — {handoffSourceProviderLabel} threads can’t be resumed twice. Any other model starts a new chat with the full transcript replayed verbatim{laneId ? <> in this lane ({laneDisplayLabel}) : null}., + subtitle: "Fork carries this whole conversation into a new chat. Brief summarizes it and starts fresh.", + body: <>{handoffSourceProviderLabel} models start a new {handoffSourceProviderLabel} agent — {handoffSourceProviderLabel} threads can’t be resumed twice — with the full transcript replayed verbatim. Any other model does the same{laneId ? <> in this lane ({laneDisplayLabel}) : null}., footnote: <>Pick any catalog model. Oldest turns drop only if the transcript exceeds the target context window., } : { @@ -11722,7 +11738,7 @@ export function AgentChatPane({ body: <>Same-family models use {handoffSourceProviderLabel}’s native fork. Any other model starts a new chat with the full transcript replayed verbatim{laneId ? <> in this lane ({laneDisplayLabel}) : null}., footnote: <>Pick any catalog model. Oldest turns drop only if the transcript exceeds the target context window., } - ), [handoffForkIsContextSeeded, handoffSourceProviderLabel, laneId, laneDisplayLabel]); + ), [handoffForkReplaysTranscript, handoffSourceProviderLabel, laneId, laneDisplayLabel]); if (!laneId) { return ( @@ -13088,20 +13104,20 @@ export function AgentChatPane({ setError(`Couldn't move the queued message back to the composer: ${error instanceof Error ? error.message : String(error)}`); }); }} - onDispatchSteerInline={selectedSession?.provider === "claude" ? (steerId) => { + onDispatchSteerInline={activeTurnInlineSupported ? (steerId) => { if (selectedSessionId) { dispatchSteerSafely({ sessionId: selectedSessionId, steerId, mode: "inline" }); } } : undefined} - onDispatchSteerInterrupt={selectedSession?.provider === "claude" ? (steerId) => { + onDispatchSteerInterrupt={activeTurnInterruptSupported ? (steerId) => { if (selectedSessionId) { dispatchSteerSafely({ sessionId: selectedSessionId, steerId, mode: "interrupt" }); } } : undefined} - onSendSteerNow={selectedSession?.provider === "claude" ? () => { + onSendSteerNow={activeTurnInlineSupported ? () => { void submit("inline"); } : undefined} - onSendSteerInterrupt={selectedSession?.provider === "claude" ? () => { + onSendSteerInterrupt={activeTurnInterruptSupported ? () => { void submit("interrupt"); } : undefined} sessionId={composerSessionId} diff --git a/apps/desktop/src/shared/types/chat.test.ts b/apps/desktop/src/shared/types/chat.test.ts index 87545d2b0..efb93d161 100644 --- a/apps/desktop/src/shared/types/chat.test.ts +++ b/apps/desktop/src/shared/types/chat.test.ts @@ -1,13 +1,53 @@ import { describe, expect, it } from "vitest"; import { + activeTurnDispatchModes, + activeTurnInterruptContinues, + defaultActiveTurnDispatchMode, inferAttachmentType, mergeAttachments, providerSupportsCrossMachineHandoffFork, providerSupportsHandoffFork, + supportsActiveTurnDispatchMode, + unsupportedActiveTurnDispatchModeMessage, type AgentChatFileRef, type AgentChatModelsArgs, } from "./chat"; +describe("active-turn dispatch modes", () => { + it("is the one table every surface reads: Claude all three, Cursor no inline, others queue-only", () => { + expect(activeTurnDispatchModes("claude")).toEqual(["inline", "queue", "interrupt"]); + expect(activeTurnDispatchModes("cursor")).toEqual(["interrupt", "queue"]); + for (const provider of ["codex", "opencode", "droid", "pi", "unknown-provider", undefined]) { + expect(activeTurnDispatchModes(provider)).toEqual(["queue"]); + } + }); + + it("defaults to the first mode in menu order", () => { + expect(defaultActiveTurnDispatchMode("claude")).toBe("inline"); + expect(defaultActiveTurnDispatchMode("cursor")).toBe("interrupt"); + expect(defaultActiveTurnDispatchMode("droid")).toBe("queue"); + }); + + it("says which providers' interrupt continues the same thread, so all three surfaces label it alike", () => { + // Cursor's interrupt is cancel + resend on the same agent thread, so the + // affordance reads "continue"; Claude's redirects a live query. + expect(activeTurnInterruptContinues("cursor")).toBe(true); + expect(activeTurnInterruptContinues("claude")).toBe(false); + expect(activeTurnInterruptContinues(undefined)).toBe(false); + }); + + it("rejects an inline dispatch on Cursor rather than downgrading it", () => { + expect(supportsActiveTurnDispatchMode("cursor", "interrupt")).toBe(true); + expect(supportsActiveTurnDispatchMode("cursor", "inline")).toBe(false); + expect(supportsActiveTurnDispatchMode("codex", "interrupt")).toBe(false); + // The host rejection the renderer and TUI both surface verbatim. + expect(unsupportedActiveTurnDispatchModeMessage("cursor", "inline")) + .toBe("Cursor sessions support only the \"interrupt\" active-turn dispatch mode."); + expect(unsupportedActiveTurnDispatchModeMessage("codex", "interrupt")) + .toContain("don't support"); + }); +}); + describe("AgentChatModelsArgs", () => { it("allows typed callers to request the aggregated model catalog", () => { const args: AgentChatModelsArgs = {}; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 6c68fc7aa..7912e97c0 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -12,6 +12,7 @@ import type { AdeRecoveryErrorCode } from "./recovery"; import type { SessionBackgroundWork } from "../sessionCanonicalState"; import type { RuntimeProcessSummary } from "./sessions"; import type { SubagentCapability } from "../subagentCapabilities"; +import { providerDisplayLabel } from "../pendingInputLabels"; export type AgentChatProvider = "codex" | "claude" | "cursor" | "droid" | "opencode" | "pi" | (string & {}); @@ -2188,10 +2189,10 @@ export type AgentChatRuntimeMode = "interactive" | "print"; * * Cursor is the odd one out: `@cursor/sdk` has no fork/clone surface at all and * a thread cannot be resumed twice, so ADE forks it at the ADE layer instead — - * the new chat starts on a fresh Cursor agent seeded with the source - * conversation's context (the same seeding used when an agent rotates). Fork - * requires source and target on the same provider; the model may still change - * within that provider. + * the new chat starts on a fresh Cursor agent with the source conversation + * replayed into it verbatim (bounded by the target model's context window), + * the same replay used when an agent rotates. Fork requires source and target + * on the same provider; the model may still change within that provider. */ export const HANDOFF_FORK_PROVIDERS = ["claude", "codex", "opencode", "droid", "cursor"] as const; @@ -2200,24 +2201,26 @@ export function providerSupportsHandoffFork(provider: AgentChatProvider | null | } /** - * True when a provider's fork is ADE-side context seeding rather than a native - * provider fork, so the UI must not promise a copied thread. Cursor is the only - * one today: its SDK has no fork surface and a thread cannot be resumed twice. + * True when a provider's fork is an ADE-side transcript replay onto a fresh + * provider thread rather than a native provider fork, so the UI must not + * promise a copied provider thread. Cursor is the only one today: its SDK has + * no fork surface and a thread cannot be resumed twice, so the forked chat + * starts on a new agent with the whole conversation replayed into it. */ -export function providerForkIsContextSeeded(provider: AgentChatProvider | null | undefined): boolean { +export function providerForkReplaysTranscript(provider: AgentChatProvider | null | undefined): boolean { return provider === "cursor"; } /** * Droid can fork locally, but its session index is machine-local, so the * relocated-file resume path is not portable across ADE machines yet. Cursor's - * fork is ADE-side context seeding with no transportable provider artifact at - * all, so there is nothing to package for another machine. Derived from the + * fork is an ADE-side transcript replay with no transportable provider artifact + * at all, so there is nothing to package for another machine. Derived from the * local set rather than restated, so adding a provider to one list cannot * silently leave the other behind. */ export const CROSS_MACHINE_HANDOFF_FORK_PROVIDERS = HANDOFF_FORK_PROVIDERS - .filter((provider) => provider !== "droid" && !providerForkIsContextSeeded(provider)); + .filter((provider) => provider !== "droid" && !providerForkReplaysTranscript(provider)); export function providerSupportsCrossMachineHandoffFork(provider: string | null | undefined): boolean { return provider != null @@ -2262,8 +2265,8 @@ export type AgentChatHandoffResult = { usedFallbackSummary: boolean; /** * Present when the fork seeded the target via full-transcript replay - * (cross-provider, or a provider without a native fork) and oldest turns - * were dropped to fit the target context window. + * (cross-provider, or a provider without a native fork such as Cursor) and + * oldest turns were dropped to fit the target context window. */ replayFork?: AgentChatReplayForkDisclosure; }; @@ -2540,6 +2543,82 @@ export type AgentChatSendArgs = { export type AgentChatDispatchSteerMode = "inline" | "interrupt"; +/** + * How a message typed during a live turn reaches the agent. "queue" stages it + * for the next turn (every provider can do that); the other two are the atomic + * active-turn dispatch modes and map 1:1 to `AgentChatDispatchSteerMode`. + */ +export type ActiveTurnSendMode = "queue" | AgentChatDispatchSteerMode; + +/** + * THE canonical per-provider active-turn delivery matrix, in menu order (the + * first entry is the provider's default). Every surface reads this rather than + * restating the rules: the composer's split send button, the chat pane's + * dispatch wiring, the main service's steer/dispatch guards, and the `ade code` + * TUI. iOS mirrors it by hand (it cannot import TS) — keep the two in step. + * + * Claude folds a message into the live query, so it has all three. Cursor's SDK + * has no mid-run message API: its interrupt cancels the run and resends on the + * same agent thread, so it has no "inline". Everything else is queue-only. + */ +export const ACTIVE_TURN_DISPATCH_MODES: Partial> = { + claude: ["inline", "queue", "interrupt"], + cursor: ["interrupt", "queue"], +}; + +const QUEUE_ONLY_ACTIVE_TURN_MODES: readonly ActiveTurnSendMode[] = ["queue"]; + +/** Modes `provider` can honor during a live turn, in menu order. */ +export function activeTurnDispatchModes( + provider: AgentChatProvider | null | undefined, +): readonly ActiveTurnSendMode[] { + return ACTIVE_TURN_DISPATCH_MODES[provider ?? ""] ?? QUEUE_ONLY_ACTIVE_TURN_MODES; +} + +/** Pre-selected mode for a fresh session on `provider`. */ +export function defaultActiveTurnDispatchMode( + provider: AgentChatProvider | null | undefined, +): ActiveTurnSendMode { + return activeTurnDispatchModes(provider)[0] ?? "queue"; +} + +/** True when `provider` accepts this atomic active-turn dispatch mode. */ +export function supportsActiveTurnDispatchMode( + provider: AgentChatProvider | null | undefined, + mode: AgentChatDispatchSteerMode, +): boolean { + return activeTurnDispatchModes(provider).includes(mode); +} + +/** + * True when the provider's "interrupt" cancels the live run and resends on the + * same thread (so the turn continues from the new message) rather than folding + * the message into the running query. Lives beside the table because it is the + * same per-provider fact, and every surface that labels the interrupt affordance + * needs it. iOS mirrors it by hand alongside the table. + */ +export function activeTurnInterruptContinues(provider: AgentChatProvider | null | undefined): boolean { + return provider === "cursor"; +} + +/** + * The one rejection message for a mode the provider cannot honor, templated off + * the table so adding a provider never leaves prose behind that contradicts it. + */ +export function unsupportedActiveTurnDispatchModeMessage( + provider: AgentChatProvider | null | undefined, + mode: string, +): string { + const accepted = activeTurnDispatchModes(provider).filter((entry) => entry !== "queue"); + const name = providerDisplayLabel(provider, "These"); + if (!accepted.length) { + return `${name} sessions don't support the "${mode}" active-turn dispatch mode; it can only be staged for the next turn.`; + } + return `${name} sessions support only the ${ + accepted.map((entry) => `"${entry}"`).join(" and ") + } active-turn dispatch mode${accepted.length > 1 ? "s" : ""}.`; +} + export type AgentChatSteerArgs = { sessionId: string; text: string; @@ -2551,9 +2630,11 @@ export type AgentChatSteerArgs = { executionMode?: AgentChatExecutionMode | null; interactionMode?: AgentChatInteractionMode | null; /** - * Claude-only atomic active-turn delivery. Omit to stage the message for the - * next turn; "inline" maps to SDK priority "next" and "interrupt" maps to - * SDK priority "now". + * Atomic active-turn delivery. Omit to stage the message for the next turn. + * Claude: "inline" maps to SDK priority "next" and "interrupt" to "now". + * Cursor: only "interrupt" is accepted — the Cursor SDK has no mid-run + * message API, so the redirect is cancel + resend on the same agent thread. + * Every other provider rejects the field. */ dispatchMode?: AgentChatDispatchSteerMode; }; diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index e9de85203..39850ff68 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2425,6 +2425,54 @@ private struct WorkChatComposerDraftInput: View { draftState.hasSendableText || !workChatInputReadyAttachments(inputAttachments).isEmpty } + /// One capability lookup for the whole active-turn send affordance. See + /// `WorkActiveSendCapability` for the table it mirrors. + private var activeSendCapability: WorkActiveSendCapability { + WorkActiveSendCapability.forProvider(chatSummary.provider) + } + + /// Derived rather than stored, so switching providers can never leave a mode + /// selected that the new provider cannot honor. + private var effectiveActiveSendMode: WorkActiveSendMode { + activeSendCapability.modes.contains(activeSendMode) + ? activeSendMode + : activeSendCapability.defaultMode + } + + /// A single mode is not a choice: queue-only providers get the plain send + /// button, matching the desktop composer. + private var activeSendModePickerVisible: Bool { + activeSendModesAvailable && activeSendCapability.modes.count > 1 + } + + private var activeSendAgentLabel: String { activeSendCapability.agentLabel } + + private var activeSendInterruptContinues: Bool { activeSendCapability.interruptContinues } + + private func activeSendModeTitle(_ mode: WorkActiveSendMode) -> String { + switch mode { + case .queue: return "Send after turn" + case .interrupt: return activeSendInterruptContinues ? "Interrupt & continue" : "Interrupt & send" + case .inline: return "Send during turn" + } + } + + private func activeSendModeDetail(_ mode: WorkActiveSendMode) -> String { + switch mode { + case .queue: return "Keep this message staged until the turn finishes." + case .interrupt: return "Stop and redirect \(activeSendAgentLabel) now." + case .inline: return "\(activeSendAgentLabel) picks this up after the current tool step." + } + } + + private func activeSendModeIcon(_ mode: WorkActiveSendMode) -> String { + switch mode { + case .queue: return "clock" + case .interrupt: return "bolt.fill" + case .inline: return "arrow.turn.down.right" + } + } + private var stashAvailable: Bool { !isPersonalChat && syncService.canInvokeRemoteAction("chat.listPromptStashes") } @@ -2520,7 +2568,7 @@ private struct WorkChatComposerDraftInput: View { stopMode = UserDefaults.standard.string(forKey: "\(draftPersistenceKey).stopMode") == AgentChatStopMode.stopOnly.rawValue ? .stopOnly : .stopAndClear - activeSendMode = .inline + activeSendMode = activeSendCapability.defaultMode sendOptionsPresented = false stopOptionsPresented = false draftState.bind(persistenceKey: draftPersistenceKey) @@ -2532,12 +2580,12 @@ private struct WorkChatComposerDraftInput: View { // backing out of a chat mid-sentence keeps the sentence. .onDisappear { draftState.flushDraft() } .onChange(of: showInterrupt) { _, _ in - activeSendMode = .inline + activeSendMode = activeSendCapability.defaultMode sendOptionsPresented = false stopOptionsPresented = false } .onChange(of: chatSummary.provider) { _, _ in - activeSendMode = .inline + activeSendMode = activeSendCapability.defaultMode sendOptionsPresented = false stopOptionsPresented = false configureSuggestionController() @@ -2583,7 +2631,7 @@ private struct WorkChatComposerDraftInput: View { if showInterrupt { if hasSendableDraftOrAttachment { stopButton() - if chatSummary.provider.lowercased() == "claude" && activeSendModesAvailable { + if activeSendModePickerVisible { activeTurnSendButton() } else { WorkChatComposerSendButton( @@ -2626,11 +2674,11 @@ private struct WorkChatComposerDraftInput: View { canSend: canSend, canUploadAttachments: canUploadAttachments, sending: sending, - accessibilityLabelText: activeSendModeTitle, - systemImageName: activeSendModeIcon, + accessibilityLabelText: activeSendModeTitle(effectiveActiveSendMode), + systemImageName: activeSendModeIcon(effectiveActiveSendMode), minimumTapTargetSize: 32, onSend: { text, attachments in - await onSend(text, attachments, activeSendMode) + await onSend(text, attachments, effectiveActiveSendMode) }, onSent: onSent ) @@ -2646,30 +2694,19 @@ private struct WorkChatComposerDraftInput: View { } .buttonStyle(.plain) .accessibilityLabel("More send options") - .accessibilityValue(activeSendModeTitle) - .accessibilityHint("Choose whether this message sends during, after, or by interrupting the active Claude turn") + .accessibilityValue(activeSendModeTitle(effectiveActiveSendMode)) + .accessibilityHint("Choose how this message reaches the active \(activeSendAgentLabel) turn") .popover(isPresented: $sendOptionsPresented, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 0) { - activeSendOption( - mode: .inline, - title: "Send during turn", - detail: "Claude picks this up after the current tool step.", - systemImage: "arrow.turn.down.right" - ) - Divider() - activeSendOption( - mode: .queue, - title: "Send after turn", - detail: "Keep this message staged until the turn finishes.", - systemImage: "clock" - ) - Divider() - activeSendOption( - mode: .interrupt, - title: "Interrupt & send", - detail: "Stop the current model step and redirect Claude now.", - systemImage: "bolt.fill" - ) + ForEach(Array(activeSendCapability.modes.enumerated()), id: \.element) { index, mode in + if index > 0 { Divider() } + activeSendOption( + mode: mode, + title: activeSendModeTitle(mode), + detail: activeSendModeDetail(mode), + systemImage: activeSendModeIcon(mode) + ) + } } .frame(width: 270) .presentationCompactAdaptation(.popover) @@ -2678,30 +2715,14 @@ private struct WorkChatComposerDraftInput: View { .clipShape(Capsule()) } - private var activeSendModeTitle: String { - switch activeSendMode { - case .queue: return "Send after turn" - case .interrupt: return "Interrupt and send" - default: return "Send during turn" - } - } - - private var activeSendModeIcon: String { - switch activeSendMode { - case .queue: return "clock" - case .interrupt: return "bolt.fill" - default: return "arrow.turn.down.right" - } - } - private var activeTurnSendHint: String { - guard chatSummary.provider.lowercased() == "claude", activeSendModesAvailable else { + guard activeSendModePickerVisible else { return "Message will stage behind the active turn." } - switch activeSendMode { + switch effectiveActiveSendMode { case .queue: return "Message will send after the active turn." - case .interrupt: return "Message will interrupt and redirect Claude." - default: return "Message will reach Claude during the active turn." + case .interrupt: return "Message will interrupt and redirect \(activeSendAgentLabel)." + case .inline: return "Message will reach \(activeSendAgentLabel) during the active turn." } } @@ -2725,7 +2746,7 @@ private struct WorkChatComposerDraftInput: View { .foregroundStyle(ADEColor.textSecondary) } Spacer(minLength: 4) - if activeSendMode == mode { + if effectiveActiveSendMode == mode { Image(systemName: "checkmark") .font(.caption.weight(.bold)) .foregroundStyle(ADEColor.accent) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index d2809fcd8..28ab99499 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -371,6 +371,42 @@ enum WorkActiveSendMode: String, Equatable { case interrupt } +/// Hand-mirrored copy of `ACTIVE_TURN_DISPATCH_MODES` in the desktop's +/// `src/shared/types/chat.ts` — iOS cannot import the TS table, so the two are +/// kept in step by hand. Modes are in menu order; the first is the default. +/// +/// Claude folds a message into the live query, so it has all three. Cursor's +/// SDK has no mid-run message API: its interrupt cancels the run and resends on +/// the same agent thread, so it has no "send during turn" and its button says +/// "continue". Everything else is queue-only, which leaves nothing to pick +/// between, so the picker stays hidden. +struct WorkActiveSendCapability: Equatable { + let modes: [WorkActiveSendMode] + let agentLabel: String + let interruptContinues: Bool + + var defaultMode: WorkActiveSendMode { modes.first ?? .queue } + + /// The atomic active-turn dispatch modes — everything except plain staging. + /// These are the ones `chat.dispatchSteer` accepts, so they are also the set + /// the staged-message strip can offer as buttons. + var atomicDispatchModes: [WorkActiveSendMode] { modes.filter { $0 != .queue } } + + static func forProvider(_ provider: String) -> WorkActiveSendCapability { + // Normalized through the same family collapse the rest of Work uses, so a + // session labelled "claude-code" or "cursor-agent" is not silently demoted + // to the queue-only default. + switch providerFamilyKey(provider) { + case "claude": + return WorkActiveSendCapability(modes: [.inline, .queue, .interrupt], agentLabel: "Claude", interruptContinues: false) + case "cursor": + return WorkActiveSendCapability(modes: [.interrupt, .queue], agentLabel: "Cursor", interruptContinues: true) + default: + return WorkActiveSendCapability(modes: [.queue], agentLabel: "the agent", interruptContinues: false) + } + } +} + struct WorkQueueRecoveryModel: Equatable { let recoveryId: String let messageCount: Int diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index a9a56ed78..4ab2077b1 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -75,13 +75,20 @@ func workChatShouldSteerActiveTurn( normalizedWorkChatSessionStatus(session: session, summary: summary) == "active" } -func workChatSupportsManualSteerDispatch( +/// Which atomic dispatch modes an already-staged message can be promoted into +/// on this session. Read off `WorkActiveSendCapability` — the hand mirror of +/// the desktop's `ACTIVE_TURN_DISPATCH_MODES` — rather than restated here, so +/// the staged strip and the composer's split send button can never disagree. +/// Claude can fold a staged row into the live turn or interrupt with it; Cursor +/// has no mid-run message API, so it gets interrupt only; everything else has +/// nothing to promote into and keeps the plain staged row. +func workChatManualSteerDispatchModes( session: TerminalSessionSummary?, summary: AgentChatSessionSummary? -) -> Bool { +) -> [WorkActiveSendMode] { let provider = summary?.provider ?? workChatProviderFamilyFromToolType(session?.toolType) - guard let provider else { return false } - return providerFamilyKey(provider) == "claude" + guard let provider else { return [] } + return WorkActiveSendCapability.forProvider(provider).atomicDispatchModes } func latestActiveTurnId(from transcript: [WorkChatEnvelope]) -> String? { @@ -884,8 +891,8 @@ struct WorkSessionDestinationView: View { syncService.chatTurnActiveHint(sessionId: sessionId) } - var supportsManualSteerDispatch: Bool { - workChatSupportsManualSteerDispatch(session: session, summary: chatSummary) + var manualSteerDispatchModes: [WorkActiveSendMode] { + workChatManualSteerDispatchModes(session: session, summary: chatSummary) } /// Lane id the header menu acts on. Resolved against the loaded lane list so @@ -1444,13 +1451,28 @@ struct WorkSessionDestinationView: View { ? "Viewing subagent transcript. Return to main chat to send." : nil let openLaneAction: (() -> Void)? = showsLaneActions ? { openSessionLane() } : nil + // Wired per mode, not per provider: Cursor accepts the interrupt promotion + // but has no inline channel, so it gets the Interrupt button and not + // "Send now". Matches the desktop pane, which gates each handler on the + // same table. + // Also host-gated: a brain that predates `chat.dispatchSteer` cannot + // promote a staged row at all, so the buttons would only ever produce an + // error toast. + let activeSendModesAvailable = syncService.supportsChatRemoteAction( + "chat.dispatchSteer", + sessionId: session.id + ) + let manualDispatchModes = activeSendModesAvailable ? manualSteerDispatchModes : [] let dispatchSteerInlineAction: (@MainActor (String) async -> Void)? - let dispatchSteerInterruptAction: (@MainActor (String) async -> Void)? - if supportsManualSteerDispatch { - dispatchSteerInlineAction = { text in await dispatchSteerInline(text) } - dispatchSteerInterruptAction = { text in await dispatchSteerInterrupt(text) } + if manualDispatchModes.contains(.inline) { + dispatchSteerInlineAction = { steerId in await dispatchSteerInline(steerId) } } else { dispatchSteerInlineAction = nil + } + let dispatchSteerInterruptAction: (@MainActor (String) async -> Void)? + if manualDispatchModes.contains(.interrupt) { + dispatchSteerInterruptAction = { steerId in await dispatchSteerInterrupt(steerId) } + } else { dispatchSteerInterruptAction = nil } let resolvedSessionStatus: String? = viewingSubagent ? "ended" : sessionStatus @@ -1480,10 +1502,6 @@ struct WorkSessionDestinationView: View { "chat.interruptWithQueueMode", sessionId: session.id ) - let activeSendModesAvailable = syncService.supportsChatRemoteAction( - "chat.dispatchSteer", - sessionId: session.id - ) let canWriteSpawnKind = !viewingSubagent && syncService.supportsSpawnKindUpdate let restoreCancelledQueueAction: (@MainActor (String) async -> Void)? if syncService.supportsChatRemoteAction( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 80fbf4753..824c3a8e6 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -13323,7 +13323,7 @@ final class ADETests: XCTestCase { ) } - func testWorkChatActiveTurnUsesSteerAndClaudeOnlyManualDispatch() { + func testWorkChatActiveTurnUsesSteerAndPerProviderManualDispatch() { let activeSummary = makeAgentChatSessionSummary(provider: "codex", status: "active") XCTAssertTrue(workChatShouldSteerActiveTurn(session: nil, summary: activeSummary)) @@ -13333,11 +13333,50 @@ final class ADETests: XCTestCase { let runningTerminal = makeTerminalSessionSummary(toolType: "codex-chat", runtimeState: "running", status: "running") XCTAssertTrue(workChatShouldSteerActiveTurn(session: runningTerminal, summary: nil)) + // Claude can promote a staged row either way; Cursor's SDK has no mid-run + // message API, so it gets interrupt only; Codex has neither. let claudeSummary = makeAgentChatSessionSummary(provider: "claude", status: "active") - XCTAssertTrue(workChatSupportsManualSteerDispatch(session: nil, summary: claudeSummary)) - XCTAssertTrue(workChatSupportsManualSteerDispatch(session: makeTerminalSessionSummary(toolType: "claude-chat"), summary: nil)) - XCTAssertFalse(workChatSupportsManualSteerDispatch(session: nil, summary: activeSummary)) - XCTAssertFalse(workChatSupportsManualSteerDispatch(session: makeTerminalSessionSummary(toolType: "cursor"), summary: nil)) + XCTAssertEqual(workChatManualSteerDispatchModes(session: nil, summary: claudeSummary), [.inline, .interrupt]) + XCTAssertEqual( + workChatManualSteerDispatchModes(session: makeTerminalSessionSummary(toolType: "claude-chat"), summary: nil), + [.inline, .interrupt] + ) + XCTAssertEqual(workChatManualSteerDispatchModes(session: nil, summary: activeSummary), []) + XCTAssertEqual( + workChatManualSteerDispatchModes( + session: makeTerminalSessionSummary(toolType: "cursor"), + summary: nil + ), + [.interrupt] + ) + XCTAssertEqual(workChatManualSteerDispatchModes(session: nil, summary: nil), []) + } + + /// Guards the hand mirror of the desktop's `ACTIVE_TURN_DISPATCH_MODES` table + /// in `shared/types/chat.ts`. Menu order is load-bearing: the first entry is + /// the provider's default, and a queue-only provider hides the picker. + func testWorkActiveSendCapabilityMirrorsDesktopDispatchTable() { + let claude = WorkActiveSendCapability.forProvider("claude") + XCTAssertEqual(claude.modes, [.inline, .queue, .interrupt]) + XCTAssertEqual(claude.defaultMode, .inline) + XCTAssertFalse(claude.interruptContinues) + + let cursor = WorkActiveSendCapability.forProvider("cursor") + XCTAssertEqual(cursor.modes, [.interrupt, .queue]) + XCTAssertEqual(cursor.defaultMode, .interrupt) + XCTAssertTrue(cursor.interruptContinues) + XCTAssertEqual(cursor.agentLabel, "Cursor") + + // Family collapse: a labelled variant must not fall through to queue-only. + XCTAssertEqual(WorkActiveSendCapability.forProvider("claude-code").modes, [.inline, .queue, .interrupt]) + XCTAssertEqual(WorkActiveSendCapability.forProvider("anthropic").modes, [.inline, .queue, .interrupt]) + XCTAssertEqual(WorkActiveSendCapability.forProvider("cursor-agent").modes, [.interrupt, .queue]) + + for provider in ["codex", "droid", "opencode", "pi", ""] { + let capability = WorkActiveSendCapability.forProvider(provider) + XCTAssertEqual(capability.modes, [.queue], "expected queue-only for \(provider)") + XCTAssertEqual(capability.atomicDispatchModes, [], "expected no atomic dispatch for \(provider)") + } } func testSyncChatMessageDeliveryParsesQueuedSteerResult() { diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index b07e99f5d..0360240a5 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -247,8 +247,8 @@ Inline (acts immediately in the TUI): | `/quit` | Exit `ade code`. | | `/steer cancel` | Remove the latest staged steer message from the local queue. | | `/steer edit ` | Edit the latest staged steer message. | -| `/steer send` | Claude only: deliver the latest staged steer inline into the active turn (SDK `dispatchSteer mode: "inline"`). | -| `/steer interrupt` | Claude only: interrupt the active turn and run the latest staged steer next (`dispatchSteer mode: "interrupt"`). | +| `/steer send` | Deliver the latest staged steer inline into the active turn (`dispatchSteer mode: "inline"`). Offered only on providers whose `ACTIVE_TURN_DISPATCH_MODES` entry includes `inline` — Claude today; the command list is derived from that table, not restated. | +| `/steer interrupt` | Interrupt the active turn and run the latest staged steer (`dispatchSteer mode: "interrupt"`). Offered on providers whose table entry includes `interrupt` — Claude and Cursor. On Cursor the notice reads "Interrupting Cursor and continuing with the staged message.", because its redirect cancels the run and resends on the same agent thread (`activeTurnInterruptContinues`) instead of folding into a live query; Claude's reads "Interrupting Claude to run the staged message." A mode the session's provider cannot honor is refused with the shared `unsupportedActiveTurnDispatchModeMessage` text rather than TUI-local copy. | Right pane (open contextual content): diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 564b72a05..efcf2ceaf 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -20,9 +20,9 @@ for its separate RPC, sync, storage, and UI contracts. | Path | Role | |---|---| | `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, brief or full-history fork selection, the destination chat's model / reasoning effort / fast mode / permission mode (the shared `PermissionModePicker` and `ReasoningEffortPicker`, each self-hiding when the chosen model can't honor it), optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, a **Fetch & fast-forward there** offer when the destination lane is clean and a strict ancestor of the source commit, transport disclosure, route-pinned final send, and recoverable source-marker completion. Source blockers are `BlockedActionReason` values rendered next to a `BlockedActionButton`, so no blocker can hide behind a disabled control. The modal takes a `runtimePin` naming the machine the **source** chat runs on (`null` = this tab's bound machine) and pins every source-side call to it — lane list, `git.getSyncStatus`, `git.getOriginRemote`, `git.push`, `git.pull`, `agentChat.prepareCrossMachineHandoff`, `validateCrossMachineSource`, `markCrossMachineHandoff` — while destination dispatch keeps routing by target id. The pin lives in a ref and is frozen once per operation, so every await inside one handoff reaches the same runtime; reading it fresh after an await could cross a lane-index change and split one handoff across two machines. Eligibility follows the same rule: the Handoff menu offers the cross-machine card based on the chat's own binding (`isRemoteChat`), so a local chat viewed from a remote-bound tab can still hand off, and a chat pinned to a remote machine cannot. `crossMachineHandoffPresentation.tsx` holds the pure half — stage/mode types, `SourceCheck`, branch/route/readiness copy, permission tone and icon maps, and `CheckRow` — so the copy and lookups that shipped wrong are directly testable. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines (Droid's session index is machine-local, and Cursor's local fork is ADE-side context seeding that produces no provider artifact to send). A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | -| `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`, `cursor`) + `providerSupportsHandoffFork()`, the companion `providerForkIsContextSeeded()` (true only for Cursor, whose fork is ADE-side context seeding rather than a native provider fork, so UI copy must not promise a copied thread), `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). Cross-machine fork has its own narrower list: `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS` + `providerSupportsCrossMachineHandoffFork()`, derived from `HANDOFF_FORK_PROVIDERS` by filtering out Droid (its session index is machine-local) and every context-seeded provider (Cursor produces no transportable artifact at all), so the two lists cannot drift. `validateForkTransport` gates inbound capsules on the cross-machine helper rather than the local one, so a provider whose fork has nothing to package is refused by the provider check instead of by the transport-kind allowlist. The preflight also carries an optional `laneFastForward` (`laneId`, `laneName`, `behindBy`) — the destination's own assertion that its existing lane is clean and a strict ancestor of the source commit. `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` and `laneFastForward` only when present, and rejects a `behindBy` that is not a positive integer because the destination refuses a zero-distance fast-forward. `chat.ts` 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/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`, `cursor`) + `providerSupportsHandoffFork()`, the companion `providerForkReplaysTranscript()` (true only for Cursor, whose fork is an ADE-side full-transcript replay onto a brand-new agent rather than a native provider fork, so UI copy must not promise a copied provider thread — it promises the conversation, bounded by the target model's context window), `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). Cross-machine fork has its own narrower list: `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS` + `providerSupportsCrossMachineHandoffFork()`, derived from `HANDOFF_FORK_PROVIDERS` by filtering out Droid (its session index is machine-local) and every replay-forked provider (Cursor produces no transportable artifact at all), so the two lists cannot drift. `validateForkTransport` gates inbound capsules on the cross-machine helper rather than the local one, so a provider whose fork has nothing to package is refused by the provider check instead of by the transport-kind allowlist. The preflight also carries an optional `laneFastForward` (`laneId`, `laneName`, `behindBy`) — the destination's own assertion that its existing lane is clean and a strict ancestor of the source commit. `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` and `laneFastForward` only when present, and rejects a `behindBy` that is not a positive integer because the destination refuses a zero-distance fast-forward. `chat.ts` also owns `ACTIVE_TURN_DISPATCH_MODES` — THE per-provider active-turn delivery matrix, in menu order with the first entry as the provider's default (`claude`: `inline`, `queue`, `interrupt`; `cursor`: `interrupt`, `queue`; everything else queue-only) — read through `activeTurnDispatchModes()`, `defaultActiveTurnDispatchMode()` and `supportsActiveTurnDispatchMode()`, with the companion facts `activeTurnInterruptContinues()` (true only for Cursor, whose interrupt cancels and resends on the same thread instead of folding into the live query, so the affordance says "continue") and `unsupportedActiveTurnDispatchModeMessage()` (the one rejection string, templated off the table). Every surface reads it rather than restating the rules — the composer's split send button, the chat pane's dispatch wiring, `agentChatService`'s steer/dispatch guards, and the `ade code` TUI's `/steer` commands; iOS mirrors it by hand in `WorkActiveSendCapability` because it cannot import TS. `chat.ts` is also the canonical cross-client contract for context-usage state/sample metadata, Claude result provenance/error/correlation fields, queue-aware interrupt results, the bounded `queue_recovery` lifecycle, and the desktop prompt-stash DTOs plus `MAX_PROMPT_STASHES`. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (cross-machine fork provider support, provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. It gates on `providerSupportsCrossMachineHandoffFork`, not the local-fork predicate, so a provider whose fork produces no transportable artifact (Droid's machine-local index, Cursor's context-only reseed) is refused by the provider check rather than incidentally by the kind allowlist. | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. 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. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)). Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. 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. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)); an expired Cursor access token recycles the worker while resuming the *same* agent id, so the recovery is silent and the thread survives. Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | | `apps/desktop/src/main/services/chat/chatRuntimeBudget.ts` | The process-wide warm-runtime budget. Owns `MAX_CONCURRENT_ACTIVE_RUNTIMES` (5) and `createChatRuntimeBudget()`, which chat services register with as `RuntimeBudgetParticipant`s (`countActiveRuntimes` + `listEvictableRuntimes`). `enforce(excludeSessionId)` releases at most one runtime per call — the globally least-recently-used releasable one across every registered participant — and yields when nothing is releasable. Constructed once per host (`main.ts`, `bootstrap.ts`) and passed to every project scope's `createAgentChatService`; a service constructed without one gets a private budget, which is the old per-service behaviour and the right answer for tests. Deliberately dependency-free of any runtime type so the LRU choice is testable without standing up a chat service. See [Session lifecycle](#session-lifecycle) below. | | `apps/desktop/src/main/services/chat/sessionNaming.ts` | Canonical home for everything the three naming callers share — automatic lane identity, chat auto-title, and the legacy lane-name suggestion — because each used to carry its own hand-copied chain that had already drifted. Owns the three system prompts and the lane-identity JSON schema, `MAX_NAMING_WORDS` (six words, handed to the model as a **guideline**: an over-long answer is clamped, never rejected, because a clamped real name beats a slug), `isProviderLevelNamingFailure` (a missing/unusable CLI, auth, quota, or an account that cannot run the model — including the "model is not supported when using X with a Y account" 400; it deliberately excludes "not supported for/on/by", which describes one model lacking a capability and must still retry a sibling), `buildNamingModelCandidates` (preferred ids → a model from a provider none of them belong to → a sibling on the leading provider, so a cross-provider candidate is always reachable), and `runNamingAcrossProviders` (walks the chain up to three attempts; a provider-level failure condemns every remaining model behind that provider, `run` returning null means "answered unusably" and the next candidate still gets a turn, and `shouldStop` abandons the chain when the user renames mid-flight). | | `apps/desktop/src/main/services/chat/spawnMissionOwnership.ts` | The single statement of who a spawned child chat is currently working for, so the policy is written and tested in one place instead of inline in `reportChildSpawnEnded`. Wake vs quiet is the child's persisted `spawnKind` (`subagent` always wakes; `peer` never does). `isHumanChildMessage` / `countHumanChildMessagesForTurn` / `formatHumanChildMessageAnnotation` name how many human messages landed in a finished turn so the next subagent wake can say `The user also sent N message(s) to this chat.` Parent dispatches, scheduled wakes, relays, host continuations, and any orchestration origin are not human messages. `HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS` / `stripHostAuthoredMessageProvenance` export the same key list to every untrusted entry point (the ADE RPC edge, the automation action bridge) so provenance is always what the host observed, never what a caller asserted. | @@ -69,7 +69,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/cursorCloudMirrorWatch.ts` | Per-session watch refcount + backoff scheduler extracted from `agentChatService`. First watch hydrates immediately; later ticks poll only that session; last unwatch clears the timer. Clients call `ai.watchCursorCloudMirror` (`cursorCloudWatchMirror` in preload). Desktop watches while the selected cloud chat is visible, TUI while that session is active, iOS while the scene is active. The sync host registers `ai.watchCursorCloudMirror` and `ai.openCursorCloudChat` so a web/remote client watching a cloud chat on that machine is a real host command, not an adapter fallback. Cursor Cloud has no create-time webhook, so this poll is the inbound path. | | `apps/desktop/src/main/services/chat/cursorSdkWorker.ts` | Node worker that hosts the official `@cursor/sdk` and bridges it to the main process via the JSON line protocol in `cursorSdkProtocol.ts`. It creates the SDK local agent platform with the lane workspace/state root, configures local agents to use HTTP/1 by default (`ADE_CURSOR_SDK_USE_HTTP1_FOR_AGENT=0` disables it), enables SDK local agent retries, passes ADE mode/idempotency keys on sends, and tolerates stream-iteration failures long enough to call `run.wait()` and emit a structured terminal result. The SDK's `local.force` send option (expire the currently active persisted run before starting this message as a new follow-up) is wired to the explicit `forceExpireActiveRun` payload flag and is set **only** on ADE's automatic recovery re-send — a normal send that expired a genuinely running turn would discard its output. | | `apps/desktop/src/main/services/chat/cursorSdkErrors.ts` | Cursor SDK error normalization helpers shared by the worker: extracts `code`, `status`, `requestId`, `operation`, and `endpoint` from SDK errors/results, reads terminal run details through the public local store API, and classifies resource/backoff vs transport failures without reaching into private SDK run fields. Classification yields a bare `CursorSdkErrorKind`; there is no companion `retryable` bit, because what a caller does about a failure (recycle the thread, surface a rate limit, re-auth) is decided per call site rather than encoded in the classifier. | -| `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, `CursorSdkModelParameterValue`, `CursorSdkWorkerInit`, local/cloud send payloads, SDK request ids, and `CursorSdkErrorDetail`. It exports Cursor-specific error classifiers for transport (`nghttp2`, dropped sockets, stream closures, plus the socket-side cousins `ECANCELED` / `EPIPE` / `write after end`, which poison the server-side agent thread the same way) and backoff/resource exhaustion (`resource_exhausted`, `rate_limited`, `NGHTTP2_ENHANCE_YOUR_CALM`, 429-style text) so UI/service paths present rate-limit and network failures consistently. `classifyCursorSdkErrorText` returns a bare `CursorSdkErrorKind` (`auth` / `rate_limit` / `network` / `busy` / `not_found` / `unknown`). `CursorSdkPermissionPolicy.fullAuto` is a permission-mode marker only — it separates full-auto sessions into their own worker pool and labels logs, and deliberately does **not** map onto the SDK's `local.force`; run expiry is the separate recovery-only `CursorSdkSendPrompt.forceExpireActiveRun`. | +| `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, `CursorSdkModelParameterValue`, `CursorSdkWorkerInit`, local/cloud send payloads, SDK request ids, and `CursorSdkErrorDetail`. It exports Cursor-specific error classifiers for transport (`nghttp2`, dropped sockets, stream closures, plus the socket-side cousins `ECANCELED` / `EPIPE` / `write after end`, which poison the server-side agent thread the same way) and backoff/resource exhaustion (`resource_exhausted`, `rate_limited`, `NGHTTP2_ENHANCE_YOUR_CALM`, 429-style text) so UI/service paths present rate-limit and network failures consistently. `classifyCursorSdkErrorText` returns a bare `CursorSdkErrorKind` (`auth` / `rate_limit` / `network` / `busy` / `not_found` / `unknown`). The expired-short-lived-access-token signature lives here too, as one greppable literal (`CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT`) plus `isCursorSdkStaleAccessTokenText` (matches the sentence's two halves independently, so a reflowed clause or a request-id suffix still matches, while a genuinely bad API key does not) and `readCursorSdkStaleTokenFailure`, which reads the worker's synthetic terminal `status: ERROR` event into a `CursorSdkStaleTokenFailure` (`turnId`, message, optional code and request id) in one pass, or returns `null` for any other error. `CursorSdkPermissionPolicy.fullAuto` is a permission-mode marker only — it separates full-auto sessions into their own worker pool and labels logs, and deliberately does **not** map onto the SDK's `local.force`; run expiry is the separate recovery-only `CursorSdkSendPrompt.forceExpireActiveRun`. | | `apps/desktop/src/main/services/chat/cursorSdkPolicy.ts` | Maps ADE permission modes onto Cursor SDK chat mode + approval policy + sandbox mode (`ade` / `cursor-native` / `off`) plus the `fullAuto` marker; decides which tool calls auto-approve and which require a user prompt. `fullAuto` names ADE's full-auto permission mode and only affects pool partitioning and log labels — it is not a Cursor SDK option. | | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. SDK `task` messages remain parent-run activity summaries; typed `Task` tool calls/results produce subagent start/result events keyed by tool call id, including the returned child agent id when available. Cursor MCP calls retain provider/tool identity in `event.mcp`; generated-image tools become compact image-generation rows. On a terminal `ERROR` status it reads the worker-injected `adeErrorCode` / `adeErrorDetail`, emits stable user-facing headlines for rate-limit and transport failures (a transport failure reads **Cursor's connection dropped mid-run.** rather than leaking `NGHTTP2_INTERNAL_ERROR` or `[internal] write ECANCELED` into the transcript), preserves exact Cursor request ids/details in `detail`, and sets `errorInfo.category` to `rate_limit`, `network`, `busy`, or `auth` when classification is known. Whenever the friendly headline replaces the raw code, that code is kept as the first `detail` line so the underlying failure is still recoverable from the transcript. | @@ -541,7 +541,20 @@ Controls and summaries project this runtime state rather than owning it: parent agent consumes it after the current tool step and before its next model call; **Interrupt & send** uses `priority: "now"` plus `shouldQuery: true`, so Claude redirects the current model step without ADE - closing the query or killing background work. Choosing an item in the split + closing the query or killing background work. + **Cursor** gets the same split control with two modes — **Interrupt & + continue** (the default) and **Send after turn**. The Cursor SDK has no + mid-run message API, so there is no "send during turn" there: the redirect + cancels the run, waits for the turn to settle, and sends the message as the + next turn on the same agent, which keeps the thread because the SDK's local + agent store holds it. The transcript shows the previous turn marked + interrupted, then the new turn streaming. Messages the user had already + staged survive the redirect (the stop runs in `stop_only` mode with + `preserveQueuedSteersOnInterrupt` armed until the interrupted turn's own tail + consumes it, so a slow settle cannot wipe them) and are delivered after the + redirect turn finishes. This softened stop is Cursor-only: `interrupt-replace` + on OpenCode, Pi and Droid keeps its previous `stop_and_clear` contract. `dispatchMode: "inline"` on a Cursor session is rejected + rather than downgraded. Choosing an item in the split menu only changes the primary action; the primary button or Enter performs the selected action. Every `steer()` call returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); a @@ -1198,6 +1211,26 @@ first-event watchdog and one automatic recovery attempt. and when no recovery is left the turn fails with "Cursor stopped responding. ADE opened a fresh Cursor thread — try sending again." under `errorInfo.category: "network"`. +- **Expired access tokens keep the thread.** The Cursor SDK exchanges the user + API key for a short-lived access token once per worker and only re-exchanges + it when a call faults with a Connect `Unauthenticated`. An in-stream expiry + (~60 min in) never triggers that, so the run dies with "Authentication error + If you are logged in, try logging out and back in." and every later send on + the same worker fails instantly with the same text. The API key is fine; only + the worker is spent. `isCursorSdkStaleAccessTokenText` matches that exact + signature (never a bad key), and the recycle runs with `reason: "stale_token"` + and `preserveAgentId: true` — the worker is replaced, but the same + `cursorSdkAgentId` is resumed in the fresh one, so no rotation, no continuity + preamble, and no lost conversation. The raw error card is swallowed in the + bridge. Recovery is silent when nothing had streamed yet (the original prompt + is re-sent verbatim, logged as + `agent_chat.cursor_sdk_stale_token_recovered`); when the token died mid-turn + the re-send asks Cursor to continue from where it stopped and the transcript + says only "Reconnected to Cursor and continued." If the retry hits the same + signature the turn fails with "Cursor's session expired. ADE reconnected and + retried, but Cursor rejected the request again — sign in to Cursor again in + Settings, then resend." (`errorInfo.category: "auth"`, keeping the Cursor + request id as detail). - **Steers survive the swap.** Steers queued on the dying runtime are lifted off it by the recycle and must be settled by the caller — re-queued onto the rebuilt runtime, or cancelled with a notice ("Queued message cancelled because @@ -1214,13 +1247,17 @@ first-event watchdog and one automatic recovery attempt. consumption; a rotation also clears the expiry flag, since a fresh agent by definition holds no stale run. -Continuity for the fresh agent is staged by the same shared seeding used by -Cursor's fork (`stageCursorSdkContinuityContext`): a header, the session's -continuity summary, and a recent-conversation tail, plus a cleared -lane-directive dedupe key so the new agent receives the lane execution -directive. Seeding is skipped entirely when there is nothing to carry, because -a header announcing restored context with no context attached only misleads the -model. +Continuity for the fresh agent is staged the same way Cursor's fork stages it +(`stageCursorSdkAgentRotationRecovery` → `stageCursorSdkContinuityHeader`): a +short header explaining the rotation, plus the **full transcript replayed +verbatim** as `pendingTranscriptReplay` (`buildFittedTranscriptReplay`, trimmed +oldest-first only when it exceeds the session model's context window), plus a +cleared lane-directive dedupe key so the new agent receives the lane execution +directive. The replay is consumed exactly once, durably — see +`consumePendingTurnContextPrefix`. It replaced a 20-line conversation tail: the +new agent had all of the work and none of the thread. Staging is skipped +entirely when the transcript has no turns, because a header announcing restored +context with no context attached only misleads the model. ### Message delivery, turn health, and quiet diagnostics @@ -1490,11 +1527,11 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.create` | invoke | Create a new session; returns the `AgentChatSession`. Accepts `codexFastMode?: boolean` as the legacy-named Fast Mode bit for any provider/model descriptor that advertises `serviceTiers: ["fast"]`. | | `ade.agentChat.suggestLaneName` | invoke | Derive a slug-safe lane name from a Work launch prompt using the session-intelligence title prompt, with a prompt-slug + optional unique temporary fallback. | | `ade.agentChat.parallelLaunchState.get` / `.set` | invoke | Read/write crash-recovery state for renderer-orchestrated parallel launches. State is scoped by project root and parent lane id. | -| `ade.agentChat.handoff` | invoke | Create a handoff session. `mode: "brief"` sends a compact summarized hidden first message and may use `targetLaneId` to move the new chat to any active lane in the project; unknown, unavailable, and archived lanes are rejected. `mode: "fork"` requires the same provider on both sides while allowing the target model to change within that provider, and always keeps the new chat in the source lane (a differing `targetLaneId` is rejected). Local forks also seed the source ADE transcript into the new chat with fork provenance. `handoffNote` is an optional user-authored addition: brief mode appends it to the hidden handoff prompt, while fork mode sends it as the first user turn. Claude forks through the SDK session pointer; Codex forks the app-server thread with `thread/fork`; OpenCode calls SDK `session.fork` (`POST /session/{id}/fork`); Droid calls SDK `forkSession()` (`droid.fork_session`). Cursor has no provider fork surface at all and a Cursor thread cannot be resumed twice, so ADE forks it at the ADE layer: the new chat carries no `cursorSdkAgentId`, so its first send opens a brand-new Cursor agent seeded with the source conversation's context the same way an agent rotation is. OpenCode and Droid persist the forked provider session as the new chat's resume pointer (`providerSessionId` / `droidSdkSessionId`). Codex targets do not inherit ADE session goals or seed app-server goals during handoff, and forked Codex threads are goal-cleared before any optional note is sent. Cross-machine fork additionally transports and rematerializes provider-native history for Claude, Codex, and OpenCode; cross-machine Cursor and Droid handoffs remain brief-only, Cursor because a context-seeded fork has no artifact to send. Forwards `codexFastMode` when the target model supports Fast Mode. | +| `ade.agentChat.handoff` | invoke | Create a handoff session. `mode: "brief"` sends a compact summarized hidden first message and may use `targetLaneId` to move the new chat to any active lane in the project; unknown, unavailable, and archived lanes are rejected. `mode: "fork"` requires the same provider on both sides while allowing the target model to change within that provider, and always keeps the new chat in the source lane (a differing `targetLaneId` is rejected). Local forks also seed the source ADE transcript into the new chat with fork provenance. `handoffNote` is an optional user-authored addition: brief mode appends it to the hidden handoff prompt, while fork mode sends it as the first user turn. Claude forks through the SDK session pointer; Codex forks the app-server thread with `thread/fork`; OpenCode calls SDK `session.fork` (`POST /session/{id}/fork`); Droid calls SDK `forkSession()` (`droid.fork_session`). Cursor has no provider fork surface at all and a Cursor thread cannot be resumed twice, so ADE forks it at the ADE layer: the new chat carries no `cursorSdkAgentId`, so its first send opens a brand-new Cursor agent and prefixes that send with the full source transcript replayed verbatim (the same `buildFittedTranscriptReplay` path a cross-provider fork uses, and the same replay an agent rotation stages), trimmed oldest-first only if it exceeds the target model's context window — in which case the handoff result carries `replayFork` and the chat shows a truncation notice. OpenCode and Droid persist the forked provider session as the new chat's resume pointer (`providerSessionId` / `droidSdkSessionId`). Codex targets do not inherit ADE session goals or seed app-server goals during handoff, and forked Codex threads are goal-cleared before any optional note is sent. Cross-machine fork additionally transports and rematerializes provider-native history for Claude, Codex, and OpenCode; cross-machine Cursor and Droid handoffs remain brief-only, Cursor because a replay fork has no provider artifact to send. Forwards `codexFastMode` when the target model supports Fast Mode. | | `ade.agentChat.send` | invoke | Dispatch a user message + attachments. If the session has ended, sending is the continuation path. | -| `ade.agentChat.steer` | invoke | Send a follow-up message mid-turn. Claude callers may pass `dispatchMode: "inline" | "interrupt"` for atomic SDK `priority: "next" | "now"` delivery; omitting it stages the message. Returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`) — `queued: false, reason: "queue_full"` when the queue is at its cap. | +| `ade.agentChat.steer` | invoke | Send a follow-up message mid-turn. Claude callers may pass `dispatchMode: "inline" | "interrupt"` for atomic SDK `priority: "next" | "now"` delivery; Cursor callers may pass `dispatchMode: "interrupt"` only (cancel + resend on the same agent thread) and are rejected for `"inline"`; omitting it stages the message. Returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`) — `queued: false, reason: "queue_full"` when the queue is at its cap. | | `ade.agentChat.cancelSteer` / `ade.agentChat.editSteer` | invoke | Queue management for queued steers. `cancelSteer({ requireQueued: true })` rejects if delivery already claimed the row; desktop Edit uses that guarded form before restoring the message and attachments to the composer. | -| `ade.agentChat.dispatchSteer` | invoke | Claude-only: deliver an already staged steer as SDK `priority: "next"` (`mode: "inline"`) or `priority: "now"` (`mode: "interrupt"`), both with `shouldQuery: true`. The staged row is removed only after the input pump accepts the message. Throws on Codex/OpenCode/Cursor. | +| `ade.agentChat.dispatchSteer` | invoke | Deliver an already staged steer immediately. The accepted `mode` per provider comes from `ACTIVE_TURN_DISPATCH_MODES`, so this is one guard rather than a per-provider ladder. Claude pushes an SDK message with `priority: "next"` (`mode: "inline"`) or `priority: "now"` (`mode: "interrupt"`), both with `shouldQuery: true`, and the staged row is removed only after the input pump accepts it. Cursor accepts `mode: "interrupt"` only: the staged row is spliced out, resolved with a "Delivering your queued message..." notice, and promoted to the interrupt-and-continue redirect; the row is put back (and its settlement re-opened) if the redirect throws, so the message is never silently lost. Queue-only providers (Codex, OpenCode, Droid, Pi) and `"inline"` on Cursor are rejected with `unsupportedActiveTurnDispatchModeMessage`. | | `ade.agentChat.cancelDispatchedSteer` | invoke | Claude-only cancellation for an attributed SDK-queued priority message. Resolves the ADE `steerId` to its bounded command UUID, capability-probes the runtime `cancelAsyncMessage` control, and returns `{ cancelled: true }` only after Claude confirms cancellation. | | `ade.agentChat.interrupt` | invoke | Provider-specific interruption of the in-flight turn. Claude accepts optional `mode: "stop_and_clear" \| "stop_only"` (default `stop_and_clear`) and returns the chosen mode, cancelled queue count, and an optional eight-second recovery id/expiry. Other providers retain their existing stop behavior. | | `ade.agentChat.restoreCancelledQueue` | invoke | Claude-only recovery for a recent `stop_and_clear`. Restores the original ADE-attributed queued steers when the matching recovery id is still live; returns `{ restored, restoredCount }` and never recreates expired or foreign-session entries. | @@ -1712,7 +1749,15 @@ Provider connection management lives on the `ade.ai.*` surface (handled in `regi the chat. - **Steer delivery vs. turn completion.** `deliverNextQueuedSteer()` is invoked on every turn-end code path (success, failure, interrupt, - Claude SDK error). Missing any path can strand a queued steer. + Claude SDK error). Missing any path can strand a queued steer. It also + declines to auto-deliver while an explicit dispatch is mid-flight: both + runtimes that promote a staged row out-of-band (Claude's inline/interrupt + push, Cursor's interrupt-and-continue redirect) hold the row's id in + `dispatchingSteerIds` for the whole dispatch, so a parent turn that completes + underneath the redirect cannot shift the *next* staged row into a turn the + redirect is about to cancel. The same set is what makes + `cancelSteer({ requireQueued: true })` report an in-flight row as busy rather + than as "no longer queued". - **Pending steer persistence.** The Claude runtime's `pendingSteers` array is mirrored into `PersistedChatState.pendingSteers` on every state flush and re-hydrated through `hydratePersistedPendingSteers` diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 106a2d3bc..ef2862901 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -530,12 +530,27 @@ discard a turn that was still working. Cursor is also the one provider whose local fork is not a provider fork. `@cursor/sdk` exposes no fork/clone/branch operation and a Cursor thread cannot -be resumed twice, so ADE's fork opens a fresh Cursor agent seeded with the -source conversation's context — the same seeding path used when a wedged thread -is recycled. `providerForkIsContextSeeded` marks it so the handoff UI describes -what actually happens, and so cross-machine fork excludes it (there is no +be resumed twice, so ADE's fork opens a fresh Cursor agent and replays the +whole source transcript into it verbatim (bounded by the target model's context +window) — the same replay staged when a wedged thread is recycled. +`providerForkReplaysTranscript` marks it so the handoff UI describes what +actually happens, and so cross-machine fork excludes it (there is no provider artifact to transport). +Cursor is likewise the one non-Claude provider that can take a message *during* +a live turn, and it takes it differently. The SDK has no mid-run message API, so +`ACTIVE_TURN_DISPATCH_MODES` (`shared/types/chat.ts`) gives Cursor `interrupt` +and `queue` but no `inline`: the redirect stops the run, waits for the turn to +settle, and sends the message as the next turn on the same agent, which keeps +the thread because the SDK's local agent store holds it. That is what +`activeTurnInterruptContinues` records, and why every surface labels Cursor's +affordance "Interrupt & continue" rather than "Interrupt & send". Because that +stop exists only to resend, it runs in `stop_only` mode with +`preserveQueuedSteersOnInterrupt` armed on the Cursor runtime until the +interrupted turn's own tail consumes it, so messages the user had already staged +ride through the redirect instead of being cleared. `interrupt-replace` on +OpenCode, Pi and Droid keeps its `stop_and_clear` contract. + ### Abstract-to-native mapping `AgentChatPermissionMode` is `default | plan | edit | full-auto | config-toml`. diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 97ee08352..51d511705 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -15,7 +15,7 @@ subagents, computer use). The pane derives all visible state from the | `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Draft machine selection and machine-safe attachment movement. Routing reconciles the machine restored by the current project/tab before the composer becomes sendable, resolves its `OpenProjectBinding`, and keeps lane selection scoped to that machine. On a user-requested machine change within one composer scope, `useDraftAttachmentTransfer` preserves portable image URLs and copies local/pasted image bytes from the attachment-owning runtime to the target runtime via pinned `getImageDataUrl` and `saveTempAttachment` calls. It removes non-image files and linked iOS/App Control/built-in-browser context because those machine-owned references are not portable. Pending transfer disables send. If copying fails, the source image references remain visible and sending stays blocked until the user returns to the source machine or removes the images. A project/tab scope change resets ownership only after machine selection has reconciled, so restoring a remote draft cannot be mistaken for an explicit local-to-remote switch. | | `apps/desktop/src/renderer/components/usage/ActivityModule.tsx`, `ActivityHeatmap.tsx`, `activityIntensity.ts` | Tabbed cross-client activity/tokens/code/clients module. `AgentChatPane` mounts the self-fetching `WorkActivityModule` (compact variant) beneath the empty Work draft composer when no app panel is open; the component persists the chosen tab and day/week/month/year range under `ade.activity.module.v1`. `ActivityHeatmap` owns the responsive seven-row grid, viewport fitting, and the intensity ramp, while `activityIntensity` provides the shared daily activity score, non-zero quartile buckets, and leading-inactive-day trimming used by the grid and summary counts. The score (`scoreActivityDays`) is series-relative: each of seven dimensions — tokens, sessions, interactions, commits, PRs, changed lines, changed files, with local and GitHub counterparts summed — is scaled against its own maximum across the visible series before the weighted sum, because the dimensions are not in the same units. A raw sum made every non-token term smaller than the rounding noise of daily token counts, so the "activity" heatmap was a token heatmap under another name. `isActiveDay` stays unweighted so one commit still colours a day. `describeActivityInsight` derives the single sentence rendered above the grid from the same scores — busiest-day record, week-over-week trend, or peak day, in that priority — so the callout and the grid can never disagree. Buckets are quartiles over the non-zero days only, GitHub-contribution-graph style: a linear value/max ramp is useless when one 35.9B-token day is normal, because that outlier flattens every other day into the same near-floor tone. The ramp itself is explicit light/dark pairs rather than one hue at five opacities — an opacity ramp of a single hue is only a lightness ramp, which inverts its ordering between a dark and a light card — so hue and saturation both climb with the level and the scale reads in either theme. It deliberately avoids `--color-accent`, which is violet in dark and green in light. Under `prefers-contrast: more` (`renderer/hooks/usePrefersMoreContrast.ts`) every tile also gains a hairline border so the steps stay separable on a forced-contrast display. A "Less → More" key renders alongside the grid so the ramp explains itself. | | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | -| `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The local handoff surface offers a brief summarized handoff or a fork whenever the source provider is fork-capable (`providerSupportsHandoffFork`: Claude, Codex, OpenCode, Droid, Cursor). Fork keeps the new chat on the same provider and lane while allowing the target model to change; Claude forks the SDK session pointer, Codex the app-server thread (`thread/fork`), OpenCode `session.fork`, and Droid `forkSession()`. Cursor has no fork surface, so ADE seeds a new Cursor chat with the source conversation's context and `providerForkIsContextSeeded` selects the matching panel copy. | +| `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The local handoff surface offers a brief summarized handoff or a fork whenever the source provider is fork-capable (`providerSupportsHandoffFork`: Claude, Codex, OpenCode, Droid, Cursor). Fork keeps the new chat on the same provider and lane while allowing the target model to change; Claude forks the SDK session pointer, Codex the app-server thread (`thread/fork`), OpenCode `session.fork`, and Droid `forkSession()`. Cursor has no fork surface, so ADE starts a new Cursor agent and replays the full source transcript into it; `providerForkReplaysTranscript` selects the matching panel copy. | | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Runtime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | | `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It takes a `runtimePin` naming the machine the *source* chat runs on (`null` = this tab's bound machine), and every source-side call is pinned to it: the lane list, `git.getSyncStatus`, `git.getOriginRemote`, `git.push`, `git.pull`, `agentChat.prepareCrossMachineHandoff`, `validateCrossMachineSource`, and `markCrossMachineHandoff`. Destination dispatch already routes by target id and is unaffected. The pin is held in a ref and **frozen once per operation** so every await inside one handoff reaches the same runtime — reading it fresh after an await could cross a lane-index change and split one handoff across two machines. It verifies the source lane on the pinned machine, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half — stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` — lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | | `ChatRuntimeScope.tsx` | Which machine THIS chat is on, and what its lane looks like there — the single derivation every chat-scoped panel reads instead of a global store selector. `useChatRuntimeScope()` returns `{ pin, binding, laneId, lane, laneWorktreePath, rootPath, isRemote, machineName, online }` from context, `useChatRuntimeScopeForPin(pin, laneId, bindingOverride?)` derives the same from a pin passed as a prop (for surfaces mounted outside a chat pane), `useChatScopeDerivation({...})` answers it from a *session* for `AgentChatPane`, and `ChatRuntimeScopeProvider` carries the resolved scope down the panel/drawer subtree. `pin === null` means, and only means, "this chat lives on the tab's binding". ESLint bans `useAppStore` / `useRootAppStore` reads of `projectBinding` / `lanes`, `project.rootPath` reads, and `selectActiveProjectRoot` imports inside `components/chat/**` so no panel can quietly go back to reading the tab's machine. Full contract in the chat [README](README.md#source-file-map). | @@ -475,12 +475,12 @@ that could not work without it. active lane in the project (via `targetLaneId`) or a freshly created lane. Claude forks the SDK session pointer, Codex the app-server thread (`thread/fork`), OpenCode `session.fork`, and Droid `forkSession()`. - Cursor's fork is ADE-side context seeding rather than a provider fork, - so `providerForkIsContextSeeded` swaps the panel's three copy slots - (subtitle, body, footnote) for wording that says the conversation is - carried into a new Cursor chat instead of promising a copied thread — - the claim has to match what actually happens, because a Cursor thread - cannot be resumed twice. The + Cursor's fork is an ADE-side transcript replay rather than a provider + fork, so `providerForkReplaysTranscript` swaps the panel's three copy + slots (subtitle, body, footnote) for wording that says the whole + conversation is replayed into a new Cursor agent instead of promising a + copied provider thread — the claim has to match what actually happens, + because a Cursor thread cannot be resumed twice. The surface also includes an optional handoff note textarea; blank notes are ignored, brief handoffs append non-empty notes to the hidden handoff prompt, and forks send the note as the first user turn. Codex handoff @@ -688,27 +688,55 @@ that could not work without it. entry: edit (guard-cancel the queued entry with `requireQueued: true`, then merge its text, file attachments, and structured context attachments into the main composer so the user can revise it and choose a delivery mode again), - cancel (`ade.agentChat.cancelSteer`), and — for Claude SDK sessions only — - **send during turn** (`ArrowBendDownRight`) and **interrupt & send** - (`Lightning`). **Send during turn** dispatches the queued message into the - active turn via `ade.agentChat.dispatchSteer({ mode: "inline" })`; - the user message then appears in-transcript with - `deliveryState: "inline"`; the service pushes an SDK message with - `priority: "next"` and `shouldQuery: true`. **Interrupt & send** calls - `dispatchSteer({ mode: "interrupt" })`, which uses SDK priority `now` to - redirect the current model step without tearing down the Claude query. Both buttons are - hidden for non-Claude providers (Codex, OpenCode, Cursor) which only - support post-turn delivery. -- **Mid-turn split Send button.** While a Claude turn is active, the + cancel (`ade.agentChat.cancelSteer`), and the immediate-dispatch actions the + session's provider accepts per `ACTIVE_TURN_DISPATCH_MODES`: **send during + turn** (`ArrowBendDownRight`) and **interrupt** (`Lightning`). **Send during + turn** dispatches the queued message into the active turn via + `ade.agentChat.dispatchSteer({ mode: "inline" })`; the user message then + appears in-transcript with `deliveryState: "inline"`; the service pushes an + SDK message with `priority: "next"` and `shouldQuery: true`. Claude's + **Interrupt & send** calls `dispatchSteer({ mode: "interrupt" })`, which uses + SDK priority `now` to redirect the current model step without tearing down the + Claude query. Cursor sessions get the interrupt action only, labelled + **Interrupt & continue** — `dispatchSteer({ mode: "interrupt" })` there + promotes the staged row to the cancel-and-resend redirect, and `"inline"` is + rejected. The tooltips and the hover hint above the staged list follow the + same table and name the real provider, so a Cursor session reads "Hover to + interrupt with this message, edit, or remove." rather than promising an inline + send. Both buttons are hidden for the remaining providers (Codex, OpenCode, + Droid, Pi), which only support post-turn delivery. +- **Mid-turn split Send button.** While a Claude or Cursor turn is active, the composer's primary send control is a split button - (`ActiveTurnSendButton`, Claude Code parity). The caret selects **Send during - turn**, **Send after turn**, or **Interrupt & send** without sending; the - primary click and Enter execute the selected mode, and the icon, tooltip, - and accessible label follow it. Immediate modes are a single atomic + (`ActiveTurnSendButton`, Claude Code parity). The caret selects a delivery + mode without sending; the primary click and Enter execute the selected mode, + and the icon, tooltip, and accessible label follow it. Which modes appear is + the canonical per-provider table `ACTIVE_TURN_DISPATCH_MODES` in + `apps/desktop/src/shared/types/chat.ts` (read through + `activeTurnDispatchModes` / `defaultActiveTurnDispatchMode`; the composer's + `activeTurnSendModesForProvider` only layers the copy on top, and the chat + pane, the main service's steer/dispatch guards, the `ade code` TUI and the + iOS `WorkActiveSendCapability` mirror all read the same table): + Claude offers **Send during turn** / **Send after turn** / **Interrupt & + send** and defaults to *Send during turn*; Cursor offers **Interrupt & + continue** / **Send after turn** and defaults to *Interrupt & continue*. + Cursor has no *Send during turn* because its SDK exposes no mid-run message + API — the redirect cancels the run and resends on the same agent thread, so + the label says "continue" (that per-provider fact is + `activeTurnInterruptContinues`, beside the table, which the composer, the TUI + and the iOS mirror all read). Mode descriptions name the actual provider + ("Stop and redirect Cursor now."). The selection is held for the session and + re-normalized when the provider changes, so a mode the new provider cannot + honor can never stay selected. A mode this pane has no wired handler for — + reachable while a model for another provider is picked mid-turn, since the + menu follows the picked provider and the handlers follow the live session — + downgrades to queueing rather than dead-ending, so Enter and the primary + button always deliver the draft somewhere. Immediate modes are a single atomic `steer({ dispatchMode })` call rather than queue-then-dispatch. The primary action disables on an empty draft, while the caret remains available so the - user can inspect or change the delivery mode. Providers without inline-steer dispatch - (Codex, OpenCode, Cursor) keep the single queue-on-send affordance. + user can inspect or change the delivery mode. Providers with no atomic + active-turn dispatch (Codex, OpenCode, Droid, Pi) keep the single + queue-on-send affordance, and a queued Cursor message still gets the plain + "Message queued — will be sent when the current turn completes." notice. - **Queue-aware Stop button.** In an active Claude chat, Stop becomes a compact split control. **Stop & clear queue** is the backward-compatible default and uses a trash icon; **Stop only** keeps queued diff --git a/docs/features/sync-and-multi-device/cross-machine-session-handoff.md b/docs/features/sync-and-multi-device/cross-machine-session-handoff.md index 567daeb4e..f95a609ad 100644 --- a/docs/features/sync-and-multi-device/cross-machine-session-handoff.md +++ b/docs/features/sync-and-multi-device/cross-machine-session-handoff.md @@ -102,7 +102,7 @@ If the 18 MiB main-history limit is exceeded, ADE returns a typed “too large Cross-machine fork requires the same provider and a usable destination runtime or CLI. Claude, Codex, and OpenCode support it. Cursor and Droid are brief-only across machines, for different reasons. Local Droid fork works, but its machine-local session index and relocated-file resume behavior are not yet proven portable. Local Cursor fork also works, but it is ADE-side context seeding rather than a provider fork — there is no provider session file, thread id, or artifact of any kind to package — so a Cursor fork has nothing to transport. -That distinction is encoded as `providerSupportsCrossMachineHandoffFork`, separate from the local-fork `providerSupportsHandoffFork`. Its backing list, `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS`, is derived from the local `HANDOFF_FORK_PROVIDERS` by filtering out Droid and every context-seeded provider (`providerForkIsContextSeeded`) rather than being restated, so adding a provider to one list cannot silently leave the other behind. The UI must gate its fork affordance on the cross-machine helper; gating on the local one leaves Droid's and Cursor's fork options selectable and guaranteed to throw at confirm time. `validateForkTransport` on the receiving side applies the same helper, so a provider whose fork produces no transportable artifact is refused by the provider gate rather than by the transport-kind allowlist. The destination applies it again when it computes `forkHandoffSupport`, so a refused provider is named with a plain reason instead of failing late. +That distinction is encoded as `providerSupportsCrossMachineHandoffFork`, separate from the local-fork `providerSupportsHandoffFork`. Its backing list, `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS`, is derived from the local `HANDOFF_FORK_PROVIDERS` by filtering out Droid and every replay-forked provider (`providerForkReplaysTranscript`) rather than being restated, so adding a provider to one list cannot silently leave the other behind. The UI must gate its fork affordance on the cross-machine helper; gating on the local one leaves Droid's and Cursor's fork options selectable and guaranteed to throw at confirm time. `validateForkTransport` on the receiving side applies the same helper, so a provider whose fork produces no transportable artifact is refused by the provider gate rather than by the transport-kind allowlist. The destination applies it again when it computes `forkHandoffSupport`, so a refused provider is named with a plain reason instead of failing late. ## Destination contract diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 07a5448c5..1343c36ed 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -3032,12 +3032,25 @@ the stats and shows update guidance. time without squeezing tool details into the same line. The association is data-driven and never invents file changes for providers that did not emit them. -- **Active-turn send and Stop use dismissing native popovers.** For Claude, - the in-session composer mirrors desktop's three delivery choices: **Send - during turn**, **Send after turn**, and **Interrupt & send**. The primary - button's icon/label communicates the selected behavior, the chevron opens a - custom SwiftUI popover, and selection dismisses it immediately. Non-Claude - providers keep the single stage-behind-turn action. When the host advertises +- **Active-turn send and Stop use dismissing native popovers.** The in-session + composer's delivery choices come from `WorkActiveSendCapability` in + `WorkModels.swift` — a hand-mirrored copy of the desktop's canonical + `ACTIVE_TURN_DISPATCH_MODES` table in `apps/desktop/src/shared/types/chat.ts`, + kept in step by hand because iOS cannot import the TS. Modes are in menu order + and the first is the default, so Claude mirrors desktop's three choices + (**Send during turn**, **Send after turn**, **Interrupt & send**, defaulting to + *Send during turn*) and Cursor gets two (**Interrupt & continue**, **Send after + turn**, defaulting to *Interrupt & continue*). `interruptContinues` is mirrored + alongside the table, so Cursor's button and hint say "continue" — its SDK has + no mid-run message API, and the redirect cancels and resends on the same agent + thread. Every provider name in the option titles, details, hints and VoiceOver + strings comes from the capability's `agentLabel` rather than hard-coded + "Claude". The primary button's icon/label communicates the selected behavior, + the chevron opens a custom SwiftUI popover, and selection dismisses it + immediately. The effective mode is derived from the pick rather than stored, so + switching providers mid-chat can never leave a mode selected that the new + provider cannot honor; queue-only providers (a single mode is not a choice) + keep the plain stage-behind-turn button, matching the desktop composer. When the host advertises additive `chat.interruptWithQueueMode`, Claude Stop likewise becomes a split control for **Stop & clear queue** and **Stop only**; the per-chat choice is stored in `UserDefaults`, carries diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 44784e260..9fa994a78 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -382,13 +382,20 @@ the background. The brain returns the full provider-grouped catalog used by the desktop and TUI ModelPickers and the iOS Work model sheet; only explicit `force` / `refresh-stale` calls trigger a runtime probe. -`chat.dispatchSteer` (Claude SDK only) takes -`{ sessionId, steerId, mode: "inline" | "interrupt" }` and pushes the staged -message through Claude's live input stream with `priority: "next" | "now"` -and `shouldQuery: true`; it returns `{ ok, dispatchedAt }`. Interrupt mode -redirects the current model request without closing the query or stopping its -background work. The queued row is removed only after the input pump accepts -the message. +`chat.dispatchSteer` takes `{ sessionId, steerId, mode: "inline" | "interrupt" }` +and returns `{ ok, dispatchedAt }`. Which modes a session accepts comes from the +canonical `ACTIVE_TURN_DISPATCH_MODES` table in +`apps/desktop/src/shared/types/chat.ts` (Claude: `inline` + `interrupt`; Cursor: +`interrupt` only; every other provider: neither), and a mode the provider cannot +honor is rejected with the shared `unsupportedActiveTurnDispatchModeMessage` +text rather than silently downgraded. On Claude the staged message is pushed +through the live input stream with `priority: "next" | "now"` and +`shouldQuery: true`; interrupt mode redirects the current model request without +closing the query or stopping its background work, and the queued row is removed +only after the input pump accepts the message. On Cursor there is no live input +stream, so `"interrupt"` promotes the staged row to the interrupt-and-continue +redirect — stop the run, wait for it to settle, resend on the same agent +thread — and the row is restored to the queue if that redirect throws. `chat.cancelDispatchedSteer` returns `{ ok, cancelled }`; the current public SDK cannot cancel an already pushed priority message, so `cancelled` is false. The iOS companion uses