From 4ca3b90497ab972f94e1f891bf7a86e2ba0370a4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:18:44 -0400 Subject: [PATCH 1/4] Fix ADE Claude Agent SDK chats to behave like Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused against real chat transcripts and covered by tests. Service (chat/agentChatService.ts): - Stop phantom subagent rows: emit subagent_result only when its subagent_started was emitted — fixes the ~99-subagent over-count and the wall of "stopped — interrupted" cards on interrupt; clear taskId+agentId aliases on terminal. - Query lifecycle: single-flight query start + a queryGeneration token that aborts+reaps a start superseded by reset/interrupt (no twin claude --resume subprocess, no resurrected turn); reap on reset AND interrupt. - Background tasks survive turn boundaries (only interrupt/reset/dispose stop them); sticky titles; run_in_background Bash classified as background. - Fix assistant-text doubling via a durable per-(messageId,index) record. - Fix ghost todo row: ordinal TaskUpdate id remaps onto the Nth created task; never fabricate a row from a bare id. - Steer contract returns { steerId, queued, reason }: queue-full no longer silently drops the message; reasoning effort applied at delivery; interrupt-replace acks only after Claude accepts. - Share isNonAgentTaskRun so idle and foreground classify identically. Renderer: split mid-turn Send button (Send now delivers into the running turn on the exact returned steerId; caret = Queue / Interrupt & replace) with always-on tooltips; interrupt-stopped subagent cards fold into one group card; Chat Info "Completed" bucket + Clear-on-expand; wider 2-line subagent cards. Parity: iOS (grouping, Completed drawer, steer queue-full, local_bash) and the ADE Code TUI kept in lockstep; internal docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ade-cli/src/headlessLinearServices.ts | 1 + .../tuiClient/__tests__/RightPane.test.tsx | 11 +- apps/ade-cli/src/tuiClient/app.tsx | 7 + .../src/tuiClient/components/RightPane.tsx | 20 +- .../services/chat/agentChatService.test.ts | 227 ++++++- .../main/services/chat/agentChatService.ts | 584 ++++++++++++++---- .../chat/claudeAssistantTextDedup.test.ts | 335 ++++++++++ .../chat/claudeQueryLifecycle.test.ts | 315 ++++++++++ .../chat/claudeSubagentResultGate.test.ts | 375 +++++++++++ .../services/chat/claudeTaskTodos.test.ts | 313 ++++++++++ apps/desktop/src/preload/global.d.ts | 3 +- apps/desktop/src/preload/preload.ts | 6 +- apps/desktop/src/renderer/browserMock.ts | 5 +- .../chat/AgentChatComposer.test.tsx | 70 +++ .../components/chat/AgentChatComposer.tsx | 254 +++++++- .../components/chat/AgentChatMessageList.tsx | 14 +- .../components/chat/AgentChatPane.test.tsx | 153 +++-- .../components/chat/AgentChatPane.tsx | 176 ++---- .../chat/ChatSubagentsPanel.test.tsx | 24 +- .../components/chat/ChatSubagentsPanel.tsx | 45 +- .../components/chat/SubagentActivityCards.tsx | 186 ++++-- .../chat/chatTranscriptRows.test.ts | 49 ++ .../components/chat/chatTranscriptRows.ts | 91 ++- .../renderer/webclient/adapter/agentChat.ts | 6 +- apps/desktop/src/shared/chatSubagents.test.ts | 45 ++ apps/desktop/src/shared/chatSubagents.ts | 33 +- apps/desktop/src/shared/types/chat.ts | 1 + apps/ios/ADE/Services/SyncService.swift | 18 +- .../Views/Work/WorkChatRichCardViews.swift | 105 +++- .../Work/WorkChatSessionView+Timeline.swift | 2 + apps/ios/ADE/Views/Work/WorkModels.swift | 15 + .../ADE/Views/Work/WorkNewChatScreen.swift | 5 +- .../WorkSessionDestinationView+Actions.swift | 9 + .../Work/WorkSessionDestinationView.swift | 8 + .../ADE/Views/Work/WorkTimelineHelpers.swift | 65 +- apps/ios/ADETests/ADETests.swift | 98 +++ docs/features/chat/README.md | 44 +- docs/features/chat/composer-and-ui.md | 28 +- docs/features/chat/transcript-and-turns.md | 2 +- .../sync-and-multi-device/ios-companion.md | 8 +- .../sync-and-multi-device/remote-commands.md | 9 +- 41 files changed, 3308 insertions(+), 457 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts create mode 100644 apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts create mode 100644 apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts create mode 100644 apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index ea75bff88..e96df57be 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -178,6 +178,7 @@ type HeadlessLinearServices = { steer: (args: { sessionId: string; text: string }) => Promise<{ steerId: string; queued: boolean; + reason?: "queue_full"; }>; interrupt: (args: { sessionId: string }) => Promise; resumeSession: (args: { diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 86921c426..298abb988 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -202,7 +202,7 @@ describe("RightPane chat info", () => { ); const frame = stripAnsi(result.lastFrame() ?? ""); - expect(frame).toMatch(/↑\s+\d+\s+earlier/); + expect(frame).toMatch(/↑\s+\d+\s+completed/); expect(frame).toContain("agent-07"); }); @@ -229,8 +229,9 @@ describe("RightPane chat info", () => { const collapsedFrame = stripAnsi(collapsed.lastFrame() ?? ""); expect(collapsedFrame).toContain("+ show all (1)"); - expect(collapsedFrame).toContain("▸ earlier (1)"); - expect(collapsedFrame).toMatch(/↑\s+\d+\s+earlier/); + expect(collapsedFrame).toContain("▸ completed (1)"); + expect(collapsedFrame).toMatch(/↑\s+\d+\s+completed/); + expect(collapsedFrame).not.toContain("x clear"); expect(collapsedFrame).not.toContain("completed-agent"); const expanded = render( @@ -242,7 +243,9 @@ describe("RightPane chat info", () => { width={80} />, ); - expect(stripAnsi(expanded.lastFrame() ?? "")).toContain("completed-agent"); + const expandedFrame = stripAnsi(expanded.lastFrame() ?? ""); + expect(expandedFrame).toContain("completed-agent"); + expect(expandedFrame).toContain("x clear"); }); it("separates foreground subagents from background tasks with section headers", () => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 61a6aab32..da8a7a939 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -8852,6 +8852,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (!conn) return; const steerActiveTurn = async (): Promise => { const result = await steerChatMessage(conn, sessionId, text, attachments); + // 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. + if (result.reason === "queue_full") { + throw new Error("The Claude steer queue is full; the message was not queued."); + } if (result.queued) { addNotice("Staged message — sends after the current turn.", "info"); } @@ -13656,6 +13662,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (chatInfoDisclosureKey === "x") { + if (subagentPaneViewState.earlierExpanded?.[focusedSection] !== true) return; const clearIds = paneRows .filter((row): row is Extract => ( row.kind === "snapshot" && row.section === focusedSection && row.group === "earlier" diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index cb34104cd..cbbb8b2a5 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -862,7 +862,8 @@ function ChatInfoRoster({ : null; const disclosureHints = selectedHeader ? [ ...(selectedHeader.collapsible ? ["c section"] : []), - ...(selectedHeader.earlierCount > 0 || selectedHeader.clearedCount > 0 ? ["e earlier"] : []), + ...(selectedHeader.earlierCount > 0 || selectedHeader.clearedCount > 0 ? ["e completed"] : []), + ...(selectedHeader.hasClear && viewState.earlierExpanded?.[selectedHeader.section] === true ? ["x clear"] : []), ...(paneRows.some((row) => row.kind === "show-all" && row.section === selectedSection) ? ["a all"] : []), ] : []; const { visibleRows: visibleSlice, hiddenBefore, hiddenAfter } = windowSubagentPaneRows( @@ -890,7 +891,7 @@ function ChatInfoRoster({ ) : ( <> {hiddenBefore > 0 ? ( - {` ↑ ${hiddenBefore} earlier`} + {` ↑ ${hiddenBefore} completed`} ) : null} {visibleSlice.map((row) => { if (row.kind === "section-header") { @@ -899,7 +900,7 @@ function ChatInfoRoster({ if (row.kind === "earlier-toggle") { return ( - {` ${row.expanded ? "▾" : "▸"} earlier (${row.count})${row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}`} + {` ${row.expanded ? "▾" : "▸"} completed (${row.count})${row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}`} ); } @@ -988,13 +989,10 @@ function rosterFooterHint( // so the mouse-click line-math stays accurate. function RosterSectionHead({ row }: { row: Extract }) { const color = row.section === "background" ? theme.color.tool : theme.color.t4; - const count = row.earlierCount - ? `${row.activeCount} · ${row.earlierCount} earlier` - : `${row.activeCount}`; return ( - {row.collapsible ? (row.collapsed ? "▸ " : "▾ ") : ""}{row.label.toLowerCase()} {count}{row.clearedCount ? ` · ${row.clearedCount} hidden` : ""} + {row.collapsible ? (row.collapsed ? "▸ " : "▾ ") : ""}{row.label.toLowerCase()} {row.activeCount}{row.clearedCount ? ` · ${row.clearedCount} hidden` : ""} ); @@ -1129,7 +1127,7 @@ function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: C }; return ( - + {nextWake ? ( {` ⏰ next wake ${nextWake}`} @@ -1138,7 +1136,7 @@ function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: C {capped.visible.map((item) => renderItem(item, false))} {capped.hiddenCount > 0 ? {` + show all (${capped.hiddenCount})`} : null} {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? ( - {` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})${grouped.clearedCount ? ` · ${grouped.clearedCount} hidden` : ""}`} + {` ${earlierExpanded ? "▾" : "▸"} completed (${grouped.earlier.length})${grouped.clearedCount ? ` · ${grouped.clearedCount} hidden` : ""}`} ) : null} {earlierExpanded ? grouped.earlier.map((item) => renderItem(item, true)) : null} {earlierExpanded && grouped.clearedCount > 0 ? {` restore (${grouped.clearedCount})`} : null} @@ -1174,10 +1172,10 @@ function ChatInfoBackgroundBlock({ info, brandColor, width, viewState }: { info: }; return ( - + {capped.visible.map(renderItem)} {capped.hiddenCount > 0 ? {` + show all (${capped.hiddenCount})`} : null} - {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? {` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})`} : null} + {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? {` ${earlierExpanded ? "▾" : "▸"} completed (${grouped.earlier.length})`} : null} {earlierExpanded ? grouped.earlier.map(renderItem) : null} ); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 430820e97..6eece571b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -8562,7 +8562,10 @@ describe("createAgentChatService", () => { expect(terminalStatuses.length).toBeGreaterThanOrEqual(1); }); - it("stops still-open background ids at turn end when the notification never arrives", async () => { + it("keeps a still-open background task running across a normal turn boundary (no turn-end stop)", async () => { + // A run_in_background shell keeps running across turns: the SDK query + // stays alive and delivers the real completion on a later turn. Turn end + // must NOT falsely settle it as stopped. const events: AgentChatEventEnvelope[] = []; let streamCall = 0; let warmupComplete = false; @@ -8605,17 +8608,233 @@ describe("createAgentChatService", () => { turnDone!(); await expect(sendPromise).resolves.toBeUndefined(); - - // The turn-end sweep must settle the orphan as stopped. + // Wait for the turn to actually settle so any (erroneous) turn-end sweep + // would have fired by now. await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "done" && (e.event as any).status === "completed"); + + // The background row must NOT have been settled at the turn boundary. + const terminalBgRows = events.filter((e) => e.event.type === "scheduled_work_update" && (e.event as any).id === "background:bg-orphan" - && (e.event as any).status === "stopped"); + && ((e.event as any).status === "stopped" || (e.event as any).status === "completed")); + expect(terminalBgRows).toEqual([]); // And no subagent_result leaked for the background shell. expect(events.some((e) => e.event.type === "subagent_result" && (e.event as any).taskId === "bg-orphan")).toBe(false); }); + it("settles a still-open background task as stopped on interrupt (genuine teardown)", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let hangResolve: (() => void) | null = null; + const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-bg-int", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "system", + subtype: "task_started", + task_id: "bg-int", + description: "long lived background", + command: "tail -f log", + task_type: "background", + }; + await hangPromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close: vi.fn(), sessionId: "sdk-bg-int", setPermissionMode, + } as any); + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); + const sendPromise = service.sendMessage({ sessionId: session.id, text: "start bg" }); + + await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bg-int" + && (e.event as any).status === "running"); + + await service.interrupt({ sessionId: session.id }); + + // Interrupt is a genuine teardown — the query is gone, so settle stopped. + await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bg-int" + && (e.event as any).status === "stopped"); + // Still never a subagent_result for a background shell. + expect(events.some((e) => + e.event.type === "subagent_result" && (e.event as any).taskId === "bg-int")).toBe(false); + + hangResolve!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("routes a local_bash run_in_background shell to background_task rows, not subagent events, with background flag", async () => { + // The Claude Agent SDK tags Bash run_in_background with task_type + // "local_bash". It must land in the background pane (never the roster) and + // its scheduled_work row must be a background_task. + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-lbash-1", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "system", + subtype: "task_started", + task_id: "bgo5i8f6y", + description: "Run codex gpt-5.6-sol backend implementation (background)", + command: "codex exec -m gpt-5.6-sol", + task_type: "local_bash", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "bgo5i8f6y", + status: "completed", + summary: "Process exited", + usage: { duration_ms: 9000 }, + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close: vi.fn(), sessionId: "sdk-lbash-1", setPermissionMode, + } as any); + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); + const sendPromise = service.sendMessage({ sessionId: session.id, text: "run codex in background" }); + + const runningRow = await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bgo5i8f6y" + && (e.event as any).status === "running"); + expect((runningRow.event as any).kind).toBe("background_task"); + expect((runningRow.event as any).title).toBe("Run codex gpt-5.6-sol backend implementation (background)"); + + // No subagent_* events for a background shell (this is the background:false + // spawn-flag pollution the classifier now prevents). + const subagentEvents = events.filter((e) => + (e.event.type === "subagent_started" + || e.event.type === "subagent_progress" + || e.event.type === "subagent_result") + && (e.event as any).taskId === "bgo5i8f6y"); + expect(subagentEvents).toEqual([]); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("suppresses subagent rows for a plain Claude Code task run (no agent metadata)", async () => { + // A task run like "Re-run affected test files" carries no agentType / + // agentId and a non-subagent task type — it must never pollute the roster. + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-nonagent-1", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "system", + subtype: "task_started", + task_id: "bwguvejv9", + description: "Re-run affected test files", + task_type: "other", + }; + yield { + type: "system", + subtype: "task_progress", + task_id: "bwguvejv9", + summary: "running vitest", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "bwguvejv9", + status: "completed", + summary: "3 files passed", + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close: vi.fn(), sessionId: "sdk-nonagent-1", setPermissionMode, + } as any); + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); + const sendPromise = service.sendMessage({ sessionId: session.id, text: "run tests" }); + + await vi.waitFor(() => { + expect(events.some((e) => e.event.type === "status")).toBe(true); + }); + + // No subagent_* events AND no background_task row for a plain task run. + const subagentEvents = events.filter((e) => + (e.event.type === "subagent_started" + || e.event.type === "subagent_progress" + || e.event.type === "subagent_result") + && (e.event as any).taskId === "bwguvejv9"); + expect(subagentEvents).toEqual([]); + const bgRows = events.filter((e) => + e.event.type === "scheduled_work_update" && (e.event as any).id === "background:bwguvejv9"); + expect(bgRows).toEqual([]); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("preserves the spawn title on a terminal background row when the hook diff-close omits it", async () => { + // The hook diff-close terminal row carries no title; the sticky per-task + // title must supply the original spawn description instead of a generic + // "Background work" fallback. + const { events, fireSnapshot } = await bootClaudeHooks("sdk-bg-title-1"); + + await fireSnapshot([{ + id: "bg-title", + type: "shell", + status: "running", + description: "Run codex gpt-5.6-sol backend implementation", + }]); + await fireSnapshot([]); + + const terminal = events.find((e) => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bg-title" + && ((e.event as any).status === "completed" || (e.event as any).status === "stopped")); + expect(terminal).toBeDefined(); + expect((terminal!.event as any).title).toBe("Run codex gpt-5.6-sol backend implementation"); + }); + it("does not cross-wire finalSummary between two concurrent subagents on an empty task_notification", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ba0bdea0e..fcff98f43 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -334,6 +334,7 @@ import { getAdeAgentSkillRootsForPrompt } from "../../../shared/agentSkillRoots" import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { isBackgroundShellCommand, + isNonAgentTaskRun, isRealSubagent, preferSubagentSummary, subagentAgentKey, @@ -776,6 +777,14 @@ type ClaudeActiveSubagent = { * symmetrically with the spawn. */ skipTranscript?: boolean; + /** + * A Claude Code task run that is neither a real subagent (no agentType / + * agentId, task_type not "subagent"/"local_workflow") nor a background shell + * — e.g. a plain "Re-run affected test files" tool run. Tracked only so its + * completion can be consumed; it must never emit subagent_started/_result and + * so must never appear as a row in the Subagents roster. + */ + nonAgentTaskRun?: boolean; }; type ClaudeRuntime = { @@ -784,10 +793,15 @@ type ClaudeRuntime = { forkFromSdkSessionId: string | null; query: ClaudeQuery | null; inputPump: ClaudeInputPump | null; + /** In-flight query start. Concurrent ensureClaudeQuery callers latch onto + * one start — two racing starts used to spawn twin subprocesses resuming + * the same SDK session. */ + queryStartPromise: Promise | null; pendingPostResultNext: Promise> | null; pendingPostResultNextSettledAt: number | null; idleReaderPromise: Promise | null; idleReaderGeneration: number; + queryGeneration: number; warmQuery: WarmQuery | null; /** Resolves when startup() has produced a warm query handle. */ warmupDone: Promise | null; @@ -796,6 +810,7 @@ type ClaudeRuntime = { /** Set to true when teardown runs to cancel an in-flight warmup. */ warmupCancelled: boolean; activeSubagents: Map; + emittedSubagentStartIds: Set; /** * Stash for Task-tool inputs captured at the assistant tool_use boundary, * keyed by the Task tool_use_id. Lets the `system:task_*` system-message @@ -817,7 +832,33 @@ type ClaudeRuntime = { */ workflowAgentsByTask: Map>; scheduledWorkSignatures: Map; + /** + * Claude Code TaskCreate/TaskUpdate tracker. The harness assigns ordinal + * task ids ("1", "2", …) in the TaskCreate tool *result*, which this + * input-side tracker never sees, so creates are keyed by their tool_use id + * and remapped onto the ordinal id the first time an update or runtime + * event references it. Insertion order mirrors creation order — the + * ordinal remap depends on it. Lazily seeded from the transcript's latest + * todo_update so cross-turn updates keep resolving. + */ + taskTodos: { seeded: boolean; byId: Map }; + /** + * Cumulative assistant text already emitted per SDK message id + content + * index, shared across foreground and idle turns. The SDK re-delivers full + * assistant snapshots after the per-stream dedup state was reset (message + * interleave, steer, idle handoff), which used to double the transcript — + * this durable record is the guard. Bounded to the most recent messages. + */ + emittedTextByAssistantMessage: Map>; seenBackgroundTaskIds: Set; + /** + * Sticky per-background-task title, keyed by taskId. The first meaningful + * title (the spawn description) is recorded here and reused for every later + * update — including the terminal stopped/completed row — so a background + * task never loses its name to a generic "Background work" fallback or a + * stale progress string once it has been named. + */ + backgroundTaskTitleById: Map; scheduledWorkKindById: Map; scheduledWorkIdByTaskId: Map; scheduledWorkIdByToolUseId: Map; @@ -3329,6 +3370,10 @@ const CLAUDE_TASK_TYPE_SET = new Set(CLAUDE_TASK_TYPES); function normalizeClaudeTaskType(value: unknown): ClaudeTaskType | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); + // The SDK tags a Bash run_in_background shell as "local_bash"; it is a + // background task (background-work pane, never the subagent roster), so fold + // it onto "background" — the id space every downstream branch already keys on. + if (trimmed === "local_bash") return "background"; return CLAUDE_TASK_TYPE_SET.has(trimmed as ClaudeTaskType) ? (trimmed as ClaudeTaskType) : undefined; } @@ -3826,6 +3871,42 @@ function firstNonEmptyString(...values: unknown[]): string | null { return null; } +const CLAUDE_EMITTED_TEXT_MESSAGE_CAP = 8; + +/** Durable per-(message id, content index) record of assistant text already + * emitted to the transcript. Consulted before emitting snapshot text so a + * re-delivered assistant message never doubles the transcript. */ +function claudeEmittedTextRecord(runtime: ClaudeRuntime, messageId: string): Map { + let record = runtime.emittedTextByAssistantMessage.get(messageId); + if (!record) { + record = new Map(); + runtime.emittedTextByAssistantMessage.set(messageId, record); + while (runtime.emittedTextByAssistantMessage.size > CLAUDE_EMITTED_TEXT_MESSAGE_CAP) { + const oldest = runtime.emittedTextByAssistantMessage.keys().next().value; + if (oldest === undefined) break; + runtime.emittedTextByAssistantMessage.delete(oldest); + } + } + return record; +} + +const CLAUDE_ORDINAL_TASK_ID = /^\d{1,6}$/; + +/** Rekey a tracked task without moving it to the end of the map — insertion + * order mirrors creation order and the ordinal remap depends on it. */ +function rekeyClaudeTaskTodoPreservingOrder( + tasksById: Map, + fromId: string, + next: ClaudeTaskTodoState, +): void { + const entries = [...tasksById.entries()]; + tasksById.clear(); + for (const [key, value] of entries) { + if (key === fromId) tasksById.set(next.id, next); + else tasksById.set(key, value); + } +} + function updateClaudeTaskTodosFromToolInput( tasksById: Map, toolName: string, @@ -3849,13 +3930,25 @@ function updateClaudeTaskTodosFromToolInput( } else if (normalizedToolName === "TaskUpdate") { const id = firstNonEmptyString(record.taskId, record.id); if (!id) return null; + let existing = tasksById.get(id); + if (!existing && CLAUDE_ORDINAL_TASK_ID.test(id)) { + // The harness assigns ordinal ids at create time in the tool result, + // which never reaches this tracker — ordinal N is the Nth created task. + // Only remap entries still keyed by their TaskCreate tool_use id. + const entry = [...tasksById.entries()][Number(id) - 1]; + if (entry && entry[0].startsWith("toolu_")) { + existing = { ...entry[1], id }; + rekeyClaudeTaskTodoPreservingOrder(tasksById, entry[0], existing); + } + } const rawStatus = typeof record.status === "string" ? record.status.trim().toLowerCase() : ""; if (rawStatus === "deleted") { - tasksById.delete(id); + if (!tasksById.delete(id)) return null; } else { - const existing = tasksById.get(id); - const description = firstNonEmptyString(record.subject, record.description, record.activeForm, existing?.description) - ?? id; + const description = firstNonEmptyString(record.subject, record.description, record.activeForm, existing?.description); + // Never fabricate a row from a bare id — an update for a task this + // tracker cannot resolve or describe changes nothing user-visible. + if (!description) return null; tasksById.set(id, { id, description, @@ -3879,15 +3972,17 @@ function remapClaudeTaskTodoFromRuntimeEvent( if (!fromId || !toId) return null; const existing = tasksById.get(fromId); if (!existing) return null; - if (fromId !== toId) { - tasksById.delete(fromId); - } - tasksById.set(toId, { + const next: ClaudeTaskTodoState = { ...existing, id: toId, description: firstNonEmptyString(updates?.description, existing.description) ?? existing.description, status: updates?.status ?? existing.status, - }); + }; + if (fromId !== toId) { + rekeyClaudeTaskTodoPreservingOrder(tasksById, fromId, next); + } else { + tasksById.set(toId, next); + } return [...tasksById.values()]; } @@ -7373,6 +7468,24 @@ export function createAgentChatService(args: { return latest; }; + /** Runtime-lifetime TaskCreate/TaskUpdate tracker, lazily seeded from the + * transcript's latest todo_update so updates in later turns (or after a + * host restart) still resolve to the task they reference. */ + const claudeTaskTodoMap = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + ): Map => { + if (!runtime.taskTodos.seeded) { + runtime.taskTodos.seeded = true; + for (const item of readLatestTranscriptTodoItems(managed)) { + if (!runtime.taskTodos.byId.has(item.id)) { + runtime.taskTodos.byId.set(item.id, { ...item }); + } + } + } + return runtime.taskTodos.byId; + }; + const getChatTranscript = async ({ sessionId, limit = DEFAULT_TRANSCRIPT_READ_LIMIT, @@ -10755,19 +10868,30 @@ export function createAgentChatService(args: { turnId?: string; }, ): void => { + const terminal = isTerminalClaudeScheduledStatus(args.status); + const explicitTitle = compactString(args.title) ?? compactString(args.command); + const storedTitle = runtime.backgroundTaskTitleById.get(args.taskId); + // First meaningful title (the spawn description) wins and sticks: record it + // on the first non-terminal update so terminal/stopped rows never fall back + // to a generic label or a stale latest-activity string. + if (!terminal && explicitTitle && !storedTitle) { + runtime.backgroundTaskTitleById.set(args.taskId, explicitTitle); + } + const title = storedTitle ?? explicitTitle ?? "Background work"; emitClaudeScheduledWorkUpdate(managed, runtime, { type: "scheduled_work_update", id: `background:${args.taskId}`, kind: "background_task", status: args.status, origin: "background_task", - title: args.title ?? args.command ?? "Background work", + title, summary: backgroundTaskSummary(args.summary, args.command, args.durationMs), sourceTaskId: args.taskId, ...(args.turnId ? { turnId: args.turnId } : {}), }); - if (isTerminalClaudeScheduledStatus(args.status)) { + if (terminal) { runtime.seenBackgroundTaskIds.delete(args.taskId); + runtime.backgroundTaskTitleById.delete(args.taskId); } else { runtime.seenBackgroundTaskIds.add(args.taskId); } @@ -12099,6 +12223,7 @@ export function createAgentChatService(args: { // are cleared, so teardown never leaves a persisted "running" row. closeOpenClaudeBackgroundTasks(managed, managed.runtime, "stopped", managed.runtime.activeTurnId ?? undefined); managed.runtime.activeSubagents.clear(); + managed.runtime.emittedSubagentStartIds.clear(); managed.runtime.taskToolInputByToolUseId.clear(); managed.runtime.workflowAgentsByTask.clear(); for (const pending of managed.runtime.approvals.values()) { @@ -13444,10 +13569,12 @@ export function createAgentChatService(args: { }); } state.openToolUses.clear(); - // Close any background shell tasks (e.g. Monitor-spawned background shells) - // still open when the idle background turn finalizes — otherwise their - // background_task rows stay "running" forever. - closeOpenClaudeBackgroundTasks(managed, runtime, "stopped", turnId); + // Background shell tasks are NOT closed at the turn boundary: a + // run_in_background shell keeps running across turns, and the SDK query + // stays alive between them and will deliver the real completion + // (system:task_notification) — which auto-starts a fresh idle turn. Closing + // them here would falsely stop still-running work. Genuine orphaning events + // (interrupt, reset/dispose, host-restart rebind) settle them instead. for (const event of finalizeClaudeStructuredActivities(state.structuredActivity, turnId, status)) { emitChatEvent(managed, event); } @@ -13497,7 +13624,10 @@ export function createAgentChatService(args: { if (!taskId) return true; const notificationAgentId = compactString(msg.agent_id); const existing = resolveClaudeActiveSubagent(runtime, taskId, notificationAgentId); - if (existing?.skipTranscript) { + // Ambient (skip_transcript) tasks and non-agent task runs are both tracked + // but never surfaced — swallow every follow-up subtype, deleting on the + // terminal one so the entry does not leak. + if (existing?.skipTranscript || existing?.nonAgentTaskRun) { if (subtype === "task_notification") { runtime.activeSubagents.delete(taskId); if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId); @@ -13590,6 +13720,26 @@ export function createAgentChatService(args: { } if (subtype === "task_started") { const background = taskType === "background" || taskType === "cron" || taskType === "local_workflow" || isBackgroundTask(msg); + // An explicit task_type "other" with no agent metadata is a plain Claude + // Code task run, not a subagent — track it for cleanup but never emit + // subagent rows. Uses the same shared predicate as the foreground path + // (including the stashed Task-tool-input check) so the two never diverge. + if (isNonAgentTaskRun({ + taskType, + agentType, + agentId, + hasStashedToolInput: parentToolUseId ? runtime.taskToolInputByToolUseId.has(parentToolUseId) : false, + })) { + runtime.activeSubagents.set(taskId, { + taskId, + description, + parentToolUseId, + background, + ...(taskType ? { taskType } : {}), + nonAgentTaskRun: true, + }); + return true; + } runtime.activeSubagents.set(taskId, { taskId, description, @@ -13617,7 +13767,7 @@ export function createAgentChatService(args: { }); } } - emitChatEvent(managed, { + emitClaudeSubagentStarted(managed, runtime, { type: "subagent_started", taskId, ...(agentId ? { agentId } : {}), @@ -13660,7 +13810,7 @@ export function createAgentChatService(args: { }); } } - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId, ...(agentId ? { agentId } : {}), @@ -13701,7 +13851,7 @@ export function createAgentChatService(args: { }); } } - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId, ...(agentId ? { agentId } : {}), @@ -13858,12 +14008,21 @@ export function createAgentChatService(args: { } if (block.type === "text") { const text = typeof block.text === "string" ? block.text : ""; - const streamedPrefix = snapshotMatchesCurrentStream - ? state.streamedTextByContentIndex.get(index) ?? "" - : ""; - const textToEmit = streamedPrefix.length > 0 && text.startsWith(streamedPrefix) - ? text.slice(streamedPrefix.length) - : text; + const emittedRecord = providerMessageId ? claudeEmittedTextRecord(runtime, providerMessageId) : null; + const streamedPrefix = emittedRecord?.get(index) + ?? (snapshotMatchesCurrentStream ? state.streamedTextByContentIndex.get(index) ?? "" : ""); + let textToEmit: string; + if (!streamedPrefix.length) { + textToEmit = text; + } else if (text.startsWith(streamedPrefix)) { + textToEmit = text.slice(streamedPrefix.length); + } else if (streamedPrefix === text || streamedPrefix.startsWith(text)) { + // Re-delivered snapshot of text that already went out. + textToEmit = ""; + } else { + // Divergent reuse of a message id — treat as a new response. + textToEmit = text; + } if (textToEmit.length) { state.assistantText += textToEmit; emitChatEvent(managed, { @@ -13873,6 +14032,7 @@ export function createAgentChatService(args: { turnId, }); } + emittedRecord?.set(index, textToEmit.length ? text : streamedPrefix); if (snapshotMatchesCurrentStream) { state.streamedTextByContentIndex.set(index, text); } @@ -13900,6 +14060,12 @@ export function createAgentChatService(args: { turnId, }); maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, toolName, block.input, itemId, turnId); + const todoItems = toolName === "TodoWrite" + ? normalizeClaudeTodoItems(block.input ?? {}) + : updateClaudeTaskTodosFromToolInput(claudeTaskTodoMap(managed, runtime), toolName, block.input ?? {}, itemId); + if (todoItems) { + emitChatEvent(managed, { type: "todo_update", items: todoItems, turnId }); + } } } } @@ -13938,6 +14104,10 @@ export function createAgentChatService(args: { ); } const providerMessageId = state.currentStreamMessageId ?? compactString(streamMsg.uuid); + if (providerMessageId && contentIndex != null) { + const record = claudeEmittedTextRecord(runtime, providerMessageId); + record.set(contentIndex, `${record.get(contentIndex) ?? ""}${text}`); + } emitChatEvent(managed, { type: "text", text, @@ -14327,7 +14497,6 @@ export function createAgentChatService(args: { const toolInputJsonByContentIndex = new Map(); const toolUseMetaByContentIndex = new Map(); const emittedClaudeTodoIds = new Set(); - const claudeTaskTodosById = new Map(); const emitClaudeToolCompletion = ( itemId: string, result: Record, @@ -14394,7 +14563,7 @@ export function createAgentChatService(args: { if (emittedClaudeTodoIds.has(itemId)) return; const todoItems = toolName === "TodoWrite" ? normalizeClaudeTodoItems(input ?? {}) - : updateClaudeTaskTodosFromToolInput(claudeTaskTodosById, toolName, input ?? {}, itemId); + : updateClaudeTaskTodosFromToolInput(claudeTaskTodoMap(managed, runtime), toolName, input ?? {}, itemId); if (!todoItems) return; emittedClaudeTodoIds.add(itemId); emitChatEvent(managed, { type: "todo_update", items: todoItems, turnId }); @@ -15118,9 +15287,10 @@ export function createAgentChatService(args: { const taskId = String(taskMsg.task_id ?? ""); if (!taskId) continue; const existing = runtime.activeSubagents.get(taskId); - // If the spawn was filtered as ambient/housekeeping, drop progress - // notifications too so the panel stays symmetrical. - if (existing?.skipTranscript) continue; + // If the spawn was filtered as ambient/housekeeping or classified as a + // non-agent task run, drop progress notifications too so the panel + // stays symmetrical. + if (existing?.skipTranscript || existing?.nonAgentTaskRun) continue; // Background shell commands never surface as subagents — their spawn // emitted a background scheduled_work row, and progress ticks add // nothing to the background pane. Swallow them. @@ -15185,7 +15355,7 @@ export function createAgentChatService(args: { runtime.workflowAgentsByTask.set(taskId, tracked); } for (const transition of planClaudeWorkflowAgentTransitions(tracked, workflowProgress.agents)) { - emitClaudeWorkflowAgentEvent(managed, transition, { + emitClaudeWorkflowAgentEvent(managed, runtime, transition, { workflowTaskId: taskId, background: existing?.background === true, workflowName, @@ -15201,7 +15371,7 @@ export function createAgentChatService(args: { const taskId = String(taskMsg.task_id ?? ""); if (!taskId) continue; const existing = runtime.activeSubagents.get(taskId); - if (existing?.skipTranscript) continue; + if (existing?.skipTranscript || existing?.nonAgentTaskRun) continue; const patch = asRecord(taskMsg.patch) ?? {}; const status = compactString(patch.status); const description = compactString(patch.description) ?? existing?.description ?? "Task update"; @@ -15253,7 +15423,7 @@ export function createAgentChatService(args: { }); } } - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId, ...(agentId ? { agentId } : {}), @@ -15344,9 +15514,9 @@ export function createAgentChatService(args: { || isBackgroundTask(taskMsg as Record) || stashed?.isBackground === true; const command = compactString(taskMsg.command); - // A background *shell* command (Bash run_in_background) has taskType - // "background" and no real subagent agentType. It must NOT surface as a - // subagent row; it belongs in the background-work pane instead. + // A background *shell* command (Bash run_in_background) has task_type + // "local_bash"/"background" and no real subagent agentType. It must NOT + // surface as a subagent row; it belongs in the background-work pane. if (isBackgroundShellCommand({ taskType, agentType, command, description })) { runtime.activeSubagents.set(taskId, { taskId, @@ -15383,19 +15553,40 @@ export function createAgentChatService(args: { }); } } - runtime.activeSubagents.set(taskId, { - taskId, - description, - parentToolUseId, - background, - ...(agentType ? { agentType } : {}), - ...(agentId ? { agentId } : {}), - ...(parentAgentId ? { parentAgentId } : {}), - ...(taskType ? { taskType } : {}), - ...(workflowName ? { workflowName } : {}), + // An explicit task_type "other" with no agent metadata (no agentType, + // no agentId, no Task/Agent tool stash) is a plain Claude Code task run + // — e.g. "Re-run affected test files" — not a subagent. Track it so its + // completion is consumed, but never emit subagent rows for it (it would + // otherwise pollute the Subagents roster). A bare task_started with no + // task_type stays a subagent for back-compat; cron keeps its own row. + const nonAgentTaskRun = isNonAgentTaskRun({ + taskType, + agentType, + agentId, + hasStashedToolInput: Boolean(stashed), }); + runtime.activeSubagents.set(taskId, nonAgentTaskRun + ? { + taskId, + description, + parentToolUseId, + background, + ...(taskType ? { taskType } : {}), + nonAgentTaskRun: true, + } + : { + taskId, + description, + parentToolUseId, + background, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + }); const remappedTodoItems = remapClaudeTaskTodoFromRuntimeEvent( - claudeTaskTodosById, + claudeTaskTodoMap(managed, runtime), parentToolUseId, taskId, { @@ -15406,7 +15597,10 @@ export function createAgentChatService(args: { if (remappedTodoItems) { emitChatEvent(managed, { type: "todo_update", items: remappedTodoItems, turnId }); } - emitChatEvent(managed, { + // Non-agent task runs get their todo update (above) but never a + // subagent row. + if (nonAgentTaskRun) continue; + emitClaudeSubagentStarted(managed, runtime, { type: "subagent_started", taskId, ...(agentId ? { agentId } : {}), @@ -15432,7 +15626,7 @@ export function createAgentChatService(args: { ? taskMsg.agent_id.trim() : undefined; const existing = resolveClaudeActiveSubagent(runtime, taskId, notificationAgentId); - if (existing?.skipTranscript) { + if (existing?.skipTranscript || existing?.nonAgentTaskRun) { runtime.activeSubagents.delete(taskId); if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId); continue; @@ -15490,7 +15684,7 @@ export function createAgentChatService(args: { runtime.activeSubagents.delete(taskId); if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId); if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId, ...(agentId ? { agentId } : {}), @@ -15515,7 +15709,7 @@ export function createAgentChatService(args: { if (workflowTracker) { runtime.workflowAgentsByTask.delete(taskId); for (const { agent, agentId: workflowAgentId } of drainRunningClaudeWorkflowAgents(workflowTracker)) { - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId: `${taskId}::a${agent.index}`, agentId: workflowAgentId, @@ -15588,16 +15782,24 @@ export function createAgentChatService(args: { // bubble in the renderer. const textKey = claudeDedupeKey(assistantMessageId, blockIndex); const fallbackTextKey = assistantMessageId ? claudeDedupeKey(null, blockIndex) : null; + const emittedRecord = assistantProviderMessageId + ? claudeEmittedTextRecord(runtime, assistantProviderMessageId) + : null; + const previouslyEmitted = emittedRecord?.get(blockIndex) ?? ""; const alreadyStreamed = (textKey ? streamedClaudeTextContentKeys.has(textKey) : false) - || (fallbackTextKey ? streamedClaudeTextContentKeys.has(fallbackTextKey) : false); + || (fallbackTextKey ? streamedClaudeTextContentKeys.has(fallbackTextKey) : false) + || (previouslyEmitted.length > 0 && (previouslyEmitted === blockText || previouslyEmitted.startsWith(blockText))); const replayedStreamPrefix = recentClaudeTextDeltaBuffer.length > 0 && blockText.startsWith(recentClaudeTextDeltaBuffer); const replayedSnapshotPrefix = recentClaudeTextDeltaBuffer.length > 0 && recentClaudeTextDeltaBuffer.startsWith(blockText); + const replayedRecordPrefix = previouslyEmitted.length > 0 && blockText.startsWith(previouslyEmitted); const textToEmit = alreadyStreamed || replayedSnapshotPrefix ? "" : replayedStreamPrefix ? blockText.slice(recentClaudeTextDeltaBuffer.length) - : blockText; + : replayedRecordPrefix + ? blockText.slice(previouslyEmitted.length) + : blockText; if (textToEmit.length > 0) { assistantText += textToEmit; emitChatEvent(managed, { @@ -15607,6 +15809,9 @@ export function createAgentChatService(args: { turnId, }); } + if (emittedRecord) { + emittedRecord.set(blockIndex, blockText.length >= previouslyEmitted.length ? blockText : previouslyEmitted); + } if (textKey) streamedClaudeTextContentKeys.add(textKey); if (fallbackTextKey) streamedClaudeTextContentKeys.add(fallbackTextKey); recentClaudeTextDeltaBuffer = replayedSnapshotPrefix @@ -15703,6 +15908,10 @@ export function createAgentChatService(args: { recentClaudeTextDeltaBuffer += text; assistantText += text; const streamProviderMessageId = currentClaudeStreamMessageId ?? compactString(streamMsg.uuid); + if (streamProviderMessageId && contentIndex != null) { + const record = claudeEmittedTextRecord(runtime, streamProviderMessageId); + record.set(contentIndex, `${record.get(contentIndex) ?? ""}${text}`); + } emitChatEvent(managed, { type: "text", text, @@ -16082,11 +16291,12 @@ export function createAgentChatService(args: { const doneModel = buildDoneModelPayload(); const finalStatus = runtime.interrupted ? "interrupted" : "completed"; - // Nothing may survive a turn as a "running" background row: any background - // shell task still open when the turn ends is settled as stopped. The - // hook diff-close and task_notification paths usually beat this; the sweep - // is the last-write-wins guarantee. - closeOpenClaudeBackgroundTasks(managed, runtime, "stopped", turnId); + // A background shell survives the turn boundary as a "running" row on + // purpose: the query is NOT closed here (see above), so its real + // completion still arrives on a later turn via system:task_notification. + // Only true teardown paths (interrupt — handled above via + // stopActiveClaudeSubagents — reset/dispose, host-restart rebind) settle + // them as stopped. if (!runtime.interruptEventsEmitted) { emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId }); void emitTurnDiffSummaryIfChanged(managed, turnId); @@ -18446,6 +18656,32 @@ export function createAgentChatService(args: { persistChatState(managed); }; + const emitClaudeSubagentStarted = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + event: Extract, + ): void => { + runtime.emittedSubagentStartIds.add(event.taskId); + if (event.agentId) runtime.emittedSubagentStartIds.add(event.agentId); + emitChatEvent(managed, event); + }; + + const emitClaudeSubagentResult = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + event: Extract, + ): void => { + if ( + !runtime.emittedSubagentStartIds.has(event.taskId) + && (!event.agentId || !runtime.emittedSubagentStartIds.has(event.agentId)) + ) { + return; + } + emitChatEvent(managed, event); + runtime.emittedSubagentStartIds.delete(event.taskId); + if (event.agentId) runtime.emittedSubagentStartIds.delete(event.agentId); + }; + /** * Emit one workflow-agent transition (from claudeWorkflowProgress.ts) as * the matching legacy `subagent_*` event so Workflow runs flow through the @@ -18457,6 +18693,7 @@ export function createAgentChatService(args: { */ const emitClaudeWorkflowAgentEvent = ( managed: ManagedChatSession, + runtime: ClaudeRuntime, transition: ClaudeWorkflowAgentTransition, context: { workflowTaskId: string; background: boolean; workflowName?: string; turnId?: string }, ): void => { @@ -18470,7 +18707,7 @@ export function createAgentChatService(args: { } : undefined; if (transition.kind === "started") { - emitChatEvent(managed, { + emitClaudeSubagentStarted(managed, runtime, { type: "subagent_started", taskId, agentId, @@ -18501,7 +18738,7 @@ export function createAgentChatService(args: { }); return; } - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId, agentId, @@ -18530,7 +18767,7 @@ export function createAgentChatService(args: { runtime.workflowAgentsByTask.delete(workflowTaskId); const workflowName = runtime.activeSubagents.get(workflowTaskId)?.workflowName; for (const { agent, agentId } of drainRunningClaudeWorkflowAgents(tracked)) { - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId: `${workflowTaskId}::a${agent.index}`, agentId, @@ -18556,6 +18793,13 @@ export function createAgentChatService(args: { const control = getClaudeQueryControl(runtime.query); for (const subagent of activeSubagents) { if (!runtime.activeSubagents.has(subagent.taskId)) continue; + // Ambient (skip_transcript) and non-agent task runs never surfaced as + // subagent rows, so they must not emit a stopped subagent_result here — + // just drop the tracking entry. + if (subagent.skipTranscript || subagent.nonAgentTaskRun) { + runtime.activeSubagents.delete(subagent.taskId); + continue; + } // A background shell entry with no real subagent agentType must not emit a // subagent_result — closeOpenClaudeBackgroundTasks already settled it. if (isBackgroundShellCommand({ @@ -18594,9 +18838,10 @@ export function createAgentChatService(args: { if (timeoutHandle) clearTimeout(timeoutHandle); } } - emitChatEvent(managed, { + emitClaudeSubagentResult(managed, runtime, { type: "subagent_result", taskId: subagent.taskId, + ...(subagent.agentId ? { agentId: subagent.agentId } : {}), parentToolUseId: subagent.parentToolUseId ?? undefined, status: "stopped", summary, @@ -21697,7 +21942,7 @@ export function createAgentChatService(args: { description: input.agent_type, parentToolUseId: null, }); - emitChatEvent(managed, { + emitClaudeSubagentStarted(managed, runtime, { type: "subagent_started", taskId, agentId: input.agent_id, @@ -22087,11 +22332,32 @@ export function createAgentChatService(args: { runtime.inputPump?.close(); runtime.query = null; runtime.inputPump = null; + runtime.queryGeneration += 1; + runtime.queryStartPromise = null; runtime.pendingPostResultNext = null; runtime.pendingPostResultNextSettledAt = null; + // close() only ends iteration — enforce that no SDK subprocess outlives + // the reset. A leaked twin keeps streaming the same resumed session into a + // dead reader, and its background children die silently later. + claudeSubprocessReaper.reapForSession(managed.session.id, `claude_${reason}`); + const hadOpenBackgroundTasks = runtime.seenBackgroundTaskIds.size > 0; + // Tearing down the query orphans any still-open background shell: its + // completion notification can only ever arrive on THIS query, so settle the + // rows as stopped before the tracking maps are cleared. (Turn boundaries do + // NOT do this — the query survives them and delivers the real completion.) + closeOpenClaudeBackgroundTasks(managed, runtime, "stopped"); + if (hadOpenBackgroundTasks) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "The Claude session restarted, so its running background tasks were stopped and will not report completion. Ask the agent to re-check or re-arm them if they are still needed.", + }); + } runtime.scheduledWorkKindById.clear(); runtime.scheduledWorkSignatures.clear(); + runtime.emittedSubagentStartIds.clear(); runtime.seenBackgroundTaskIds.clear(); + runtime.backgroundTaskTitleById.clear(); runtime.scheduledWorkIdByTaskId.clear(); runtime.scheduledWorkIdByToolUseId.clear(); runtime.activeProviderCronIds.clear(); @@ -22262,8 +22528,26 @@ export function createAgentChatService(args: { const ensureClaudeQuery = async (managed: ManagedChatSession, runtime: ClaudeRuntime): Promise => { if (runtime.query && runtime.inputPump) return runtime.query; + if (runtime.queryStartPromise) return runtime.queryStartPromise; + const startPromise = startClaudeQuery(managed, runtime); + runtime.queryStartPromise = startPromise; + try { + return await startPromise; + } finally { + if (runtime.queryStartPromise === startPromise) runtime.queryStartPromise = null; + } + }; + const startClaudeQuery = async (managed: ManagedChatSession, runtime: ClaudeRuntime): Promise => { + const startGeneration = runtime.queryGeneration; const pump = new ClaudeInputPump(); + const assertCurrentStart = (spawnedQuery?: ClaudeQuery): void => { + if (runtime.queryGeneration === startGeneration) return; + try { spawnedQuery?.close(); } catch { /* ignore */ } + pump.close(); + claudeSubprocessReaper.reapForSession(managed.session.id, "claude_stale_start"); + throw new Error("Claude query start superseded by reset/interrupt"); + }; const options = buildClaudeQueryOptions(managed, runtime); if (runtime.forkFromSdkSessionId) { if (!runtime.sdkSessionId) { @@ -22301,10 +22585,12 @@ export function createAgentChatService(args: { at: "resume", }); } + assertCurrentStart(); // Must run AFTER the thinking-transcript repair: that repair can rekey // SDK message ids, and the splice repair keys rebuilt envelopes to the // post-rekey ids it reads via getSessionMessages. await repairClaudeEnvelopeSplicesBeforeResume(managed, options.resume); + assertCurrentStart(); } let sessionQuery: ClaudeQuery; @@ -22319,9 +22605,10 @@ export function createAgentChatService(args: { } throw error; } - runtime.warmQuery = null; + assertCurrentStart(sessionQuery); runtime.query = sessionQuery; runtime.inputPump = pump; + runtime.warmQuery = null; if (runtime.forkFromSdkSessionId) { runtime.forkFromSdkSessionId = null; persistChatState(managed); @@ -22398,6 +22685,7 @@ export function createAgentChatService(args: { // Apply the per-send execution/interaction mode captured when the steer was // queued, mirroring prepareSendMessage's session mutation + directives. if (managed.session.provider === "claude") { + managed.session.reasoningEffort = normalizeReasoningEffort(managed.session.reasoningEffort); managed.session.interactionMode = nextSteer.interactionMode ?? managed.session.interactionMode ?? "default"; managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; } @@ -22830,19 +23118,25 @@ export function createAgentChatService(args: { forkFromSdkSessionId, query: null, inputPump: null, + queryStartPromise: null, pendingPostResultNext: null, pendingPostResultNextSettledAt: null, idleReaderPromise: null, idleReaderGeneration: 0, + queryGeneration: 0, warmQuery: null, warmupDone: null, warmupCancel: null, warmupCancelled: false, activeSubagents: new Map(), + emittedSubagentStartIds: new Set(), taskToolInputByToolUseId: new Map(), workflowAgentsByTask: new Map(), scheduledWorkSignatures: new Map(), + taskTodos: { seeded: false, byId: new Map() }, + emittedTextByAssistantMessage: new Map(), seenBackgroundTaskIds: new Set(), + backgroundTaskTitleById: new Map(), scheduledWorkKindById: new Map(), scheduledWorkIdByTaskId: new Map(), scheduledWorkIdByToolUseId: new Map(), @@ -28668,7 +28962,7 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } - enqueueSteerOrDrop( + const queued = enqueueSteerOrDrop( managed, runtime, sessionId, @@ -28680,7 +28974,9 @@ export function createAgentChatService(args: { preparedSteer.metadata, { displayText: preparedSteer.visibleText, reasoningEffort, executionMode, interactionMode }, ); - return { steerId, queued: true }; + return queued + ? { steerId, queued: true } + : { steerId, queued: false, reason: "queue_full" }; } const preparedSteer = prepareSendMessage({ sessionId, @@ -28720,7 +29016,7 @@ export function createAgentChatService(args: { message: "Steer dropped — the queue is full. Wait for the current turn to finish.", turnId: rt.activeTurnId ?? undefined, }); - return { steerId, queued: false }; + return { steerId, queued: false, reason: "queue_full" }; } rt.pendingSteers.push({ steerId, @@ -28792,7 +29088,7 @@ export function createAgentChatService(args: { message: "Steer dropped — the queue is full. Wait for the current turn to finish.", turnId: rt.activeTurnId ?? undefined, }); - return { steerId, queued: false }; + return { steerId, queued: false, reason: "queue_full" }; } rt.pendingSteers.push({ steerId, @@ -28946,7 +29242,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "claude") { const runtime = ensureClaudeSessionRuntime(managed); if (runtime.busy || managed.session.status === "active") { - enqueueSteerOrDrop( + const queued = enqueueSteerOrDrop( managed, runtime, sessionId, @@ -28958,7 +29254,9 @@ export function createAgentChatService(args: { preparedSteer.metadata, { displayText: preparedSteer.visibleText, reasoningEffort, executionMode, interactionMode }, ); - return { steerId, queued: true }; + return queued + ? { steerId, queued: true } + : { steerId, queued: false, reason: "queue_full" }; } await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; @@ -29002,6 +29300,9 @@ export function createAgentChatService(args: { contextAttachments, metadata, }); + if (result.reason === "queue_full") { + throw new Error("The Claude steer queue is full; the message was not queued."); + } return { sessionId, kind: normalizedKind, @@ -29015,18 +29316,41 @@ export function createAgentChatService(args: { } if (normalizedKind === "interrupt-replace") { - await interrupt({ sessionId }); - await waitForCursorDroidTurnToSettleAfterInterrupt(managed, sessionId); - await sendMessage( - { - sessionId, - text, - attachments, - contextAttachments, - metadata, - }, - { awaitDispatch: false }, - ); + if (managed.session.provider !== "claude") { + await interrupt({ sessionId }); + await waitForCursorDroidTurnToSettleAfterInterrupt(managed, sessionId); + await sendMessage( + { + sessionId, + text, + attachments, + contextAttachments, + metadata, + }, + { awaitDispatch: false }, + ); + } else { + try { + await interrupt({ sessionId }, { requireClaudeProviderInterrupt: true }); + await sendMessage( + { + sessionId, + text, + attachments, + contextAttachments, + metadata, + }, + { awaitBackendDispatch: true }, + ); + } catch (error) { + logger.warn("agent_chat.interrupt_replace_failed", { + sessionId, + provider: managed.session.provider, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } return { sessionId, kind: normalizedKind, @@ -29214,47 +29538,48 @@ export function createAgentChatService(args: { } if (mode === "interrupt") { - // Move to head of queue so the existing post-turn flush at the end of - // runClaudeTurn (`if (runtime.pendingSteers.length) deliverNextQueuedSteer`) - // delivers our message as the next turn after the abort drains. - if (idx !== 0) { - queue.splice(idx, 1); - queue.unshift(steer); - } - - runtime.interrupted = true; - await stopActiveClaudeSubagents( - managed, - runtime, - runtime.activeTurnId ?? undefined, - "Interrupted by queued message", - ); - const control = getClaudeQueryControl(runtime.query); - if (control.interrupt) { - try { - await control.interrupt(); - } catch (err) { - logger.warn("agent_chat.dispatch_steer_interrupt_failed", { - sessionId, - steerId, - err: err instanceof Error ? err.message : String(err), - }); + const prepared = prepareSendMessage({ + sessionId, + text: steer.text, + displayText: steer.displayText ?? steer.text, + attachments: steer.attachments, + contextAttachments: steer.contextAttachments, + metadata: steer.metadata, + reasoningEffort: normalizeReasoningEffort(managed.session.reasoningEffort), + executionMode: steer.executionMode, + interactionMode: steer.interactionMode, + allowActiveSession: true, + }); + if (!prepared) return { dispatchedAt: null }; + + // Keep the replacement out of interrupt()'s queued-steer cancellation, + // then acknowledge only after Claude has accepted the replacement turn. + queue.splice(idx, 1); + try { + await interrupt({ sessionId }, { requireClaudeProviderInterrupt: true }); + await sendMessage({ + sessionId, + text: prepared.submittedText, + displayText: prepared.visibleText, + attachments: prepared.attachments, + contextAttachments: prepared.contextAttachments, + metadata: prepared.metadata, + reasoningEffort: normalizeReasoningEffort(managed.session.reasoningEffort), + executionMode: steer.executionMode, + interactionMode: steer.interactionMode, + }, { awaitBackendDispatch: true }); + } catch (error) { + if (!queue.some((entry) => entry.steerId === steerId)) { + queue.splice(Math.min(idx, queue.length), 0, steer); + persistChatState(managed); } - } else { - logger.warn("agent_chat.dispatch_steer_interrupt_unavailable", { + logger.warn("agent_chat.dispatch_steer_interrupt_replace_failed", { sessionId, steerId, + error: error instanceof Error ? error.message : String(error), }); + return { dispatchedAt: null }; } - - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - steerId, - message: "Interrupting current turn to run queued message.", - turnId: runtime.activeTurnId ?? undefined, - }); - persistChatState(managed); return { dispatchedAt: Date.now() }; } @@ -29287,7 +29612,10 @@ export function createAgentChatService(args: { return { cancelled: false }; }; - const interrupt = async ({ sessionId }: AgentChatInterruptArgs): Promise => { + const interrupt = async ( + { sessionId }: AgentChatInterruptArgs, + internalOptions: { requireClaudeProviderInterrupt?: boolean } = {}, + ): Promise => { const managed = ensureManagedSession(sessionId); abortActiveBashControllers(managed, "Session interrupt requested."); @@ -29472,6 +29800,13 @@ export function createAgentChatService(args: { busy: runtime.busy, warmupInFlight: Boolean(runtime.warmupDone), }); + const claudeControl = getClaudeQueryControl(runtime.query); + if (internalOptions.requireClaudeProviderInterrupt) { + if (!claudeControl.interrupt) { + throw new Error("Claude interrupt is unavailable; the replacement was not sent."); + } + await claudeControl.interrupt(); + } // Set interrupted before touching the runtime so the streaming loop can // break cleanly while the underlying SDK stream is aborted below. runtime.interrupted = true; @@ -29489,8 +29824,17 @@ export function createAgentChatService(args: { } cancelClaudeWarmup(managed, runtime, "interrupt"); await stopActiveClaudeSubagents(managed, runtime, interruptedTurnId ?? undefined, "Interrupted by user"); - try { await runtime.query?.interrupt(); } catch { /* ignore */ } + runtime.queryGeneration += 1; + runtime.queryStartPromise = null; + if (!internalOptions.requireClaudeProviderInterrupt) { + try { await claudeControl.interrupt?.(); } catch { /* ignore */ } + } try { runtime.query?.close(); } catch { /* ignore */ } + // close() only ends stream iteration — it does not guarantee the SDK + // subprocess exits. Reap it so an interrupted turn never leaves a live + // `claude --resume` twin streaming into a dead reader (the same enforcement + // resetClaudeQuerySession applies on reset/remodel). + claudeSubprocessReaper.reapForSession(managed.session.id, "claude_interrupt"); runtime.inputPump?.close(); runtime.query = null; runtime.inputPump = null; diff --git a/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts b/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts new file mode 100644 index 000000000..e1355e366 --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts @@ -0,0 +1,335 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const claudeSdk = vi.hoisted(() => ({ + messages: [] as Array>, + releaseIdleMessages: null as null | (() => void), +})); + +function makeClaudeQuery(messages: Array>) { + let releaseIdleMessages!: () => void; + const idleGate = new Promise((resolve) => { + releaseIdleMessages = resolve; + }); + claudeSdk.releaseIdleMessages = releaseIdleMessages; + const iterator = (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-text-dedup", slash_commands: [] }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-text-dedup", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + await idleGate; + for (const message of messages) yield message; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-text-dedup", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })(); + return Object.assign(iterator, { + close: vi.fn(), + interrupt: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + reloadPlugins: vi.fn(async () => ({ commands: [], agents: [], plugins: [], error_count: 0 })), + supportedCommands: vi.fn(async () => []), + getContextUsage: vi.fn(async () => ({ + categories: [], + totalTokens: 0, + maxTokens: 0, + rawMaxTokens: 0, + percentage: 0, + gridRows: [], + model: "", + })), + }); +} + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + createSdkMcpServer: vi.fn((config: { name?: string; tools?: Array<{ name: string }> }) => ({ + type: "sdk", + name: config?.name, + instance: { + _registeredTools: Object.fromEntries((config?.tools ?? []).map((tool) => [tool.name, tool])), + }, + })), + getSessionInfo: vi.fn(), + getSessionMessages: vi.fn(async () => []), + listSessions: vi.fn(async () => []), + query: vi.fn(() => makeClaudeQuery(claudeSdk.messages)), + renameSession: vi.fn(async () => undefined), + startup: vi.fn(async () => ({ + query: () => makeClaudeQuery(claudeSdk.messages), + close: vi.fn(), + })), + tagSession: vi.fn(async () => undefined), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("@factory/droid-sdk", () => ({ + createSdkMcpServer: vi.fn(() => ({ start: vi.fn(), close: vi.fn() })), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("../ai/tools/universalTools", () => ({ + createUniversalToolSet: vi.fn(() => ({ + readFile: { description: "stub", parameters: {}, execute: vi.fn() }, + grep: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoRead: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoWrite: { description: "stub", parameters: {}, execute: vi.fn() }, + bash: { description: "stub", parameters: {}, execute: vi.fn() }, + })), +})); +vi.mock("../ai/tools/workflowTools", () => ({ createWorkflowTools: vi.fn(() => []) })); +vi.mock("../ai/tools/linearTools", () => ({ createLinearTools: vi.fn(() => []) })); +vi.mock("../ai/tools/ctoOperatorTools", () => ({ createCtoOperatorTools: vi.fn(() => []) })); +vi.mock("../ai/tools/systemPrompt", () => ({ + buildCodingAgentSystemPrompt: vi.fn(() => "system prompt"), + composeSystemPrompt: vi.fn(() => "system prompt"), +})); +vi.mock("../ai/claudeModelUtils", () => ({ resolveClaudeCliModel: vi.fn((model: string) => model) })); +vi.mock("../ai/providerRuntimeHealth", () => ({ + getProviderRuntimeHealth: vi.fn(() => null), + reportProviderRuntimeAuthFailure: vi.fn(), + reportProviderRuntimeFailure: vi.fn(), + reportProviderRuntimeReady: vi.fn(), +})); +vi.mock("../ai/claudeRuntimeProbe", () => ({ + CLAUDE_RUNTIME_AUTH_ERROR: "Claude authentication failed", + isClaudeRuntimeAuthError: vi.fn(() => false), +})); +vi.mock("../ai/claudeCodeExecutable", () => ({ + isExecutablePath: vi.fn(() => true), + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), +})); +vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []) })); +vi.mock("../git/git", () => ({ runGit: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })) })); +vi.mock("./permissionMapping", () => ({ + mapPermissionToClaude: vi.fn(() => "default"), + mapPermissionToCodex: vi.fn(() => ({ approvalPolicy: "on-request", sandbox: "read-only" })), +})); +vi.mock("../../../shared/chatTranscript", () => ({ parseAgentChatTranscript: vi.fn(() => []) })); + +import { createAgentChatService } from "./agentChatService"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; + +let tempRoot: string; + +function createHarness(messages: Array>) { + claudeSdk.messages = messages; + claudeSdk.releaseIdleMessages = null; + + const sessions = new Map>(); + const claudePointers = new Map>(); + const sessionService = { + create: vi.fn((args: Record) => { + sessions.set(args.sessionId, { + id: args.sessionId, + laneId: args.laneId, + title: args.title ?? "Chat", + toolType: args.toolType ?? "claude-chat", + status: "running", + startedAt: args.startedAt ?? new Date().toISOString(), + endedAt: null, + archivedAt: null, + transcriptPath: args.transcriptPath ?? "", + resumeCommand: args.resumeCommand ?? null, + goal: args.goal ?? null, + manuallyNamed: false, + }); + }), + get: vi.fn((sessionId: string) => sessions.get(sessionId) ?? null), + list: vi.fn(() => [...sessions.values()]), + reopen: vi.fn(), + end: vi.fn(), + deleteSession: vi.fn(), + archiveSession: vi.fn(), + unarchiveSession: vi.fn(), + updateMeta: vi.fn(), + setHeadShaStart: vi.fn(), + setHeadShaEnd: vi.fn(), + setLastOutputPreview: vi.fn(), + setSummary: vi.fn(), + setResumeCommand: vi.fn(), + upsertClaudeSessionPointer: vi.fn((pointer: Record) => { + const next = { ...claudePointers.get(pointer.chatSessionId), ...pointer }; + if (pointer.chatSessionId) claudePointers.set(pointer.chatSessionId, next); + return next; + }), + getClaudeSessionPointer: vi.fn(() => null), + getClaudeSessionPointerByChatSessionId: vi.fn((sessionId: string) => claudePointers.get(sessionId) ?? null), + listClaudeSessionPointers: vi.fn(() => [...claudePointers.values()]), + }; + const laneService = { + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/test", + worktreePath: tempRoot, + laneType: "feature", + })), + list: vi.fn(async () => []), + getSummary: vi.fn(async () => null), + getLane: vi.fn(() => null), + listLinearIssuesForSession: vi.fn(() => []), + }; + const events: AgentChatEventEnvelope[] = []; + const transcriptsDir = path.join(tempRoot, "transcripts"); + fs.mkdirSync(transcriptsDir, { recursive: true }); + + const service = createAgentChatService({ + projectRoot: tempRoot, + transcriptsDir, + laneService: laneService as any, + sessionService: sessionService as any, + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { cli: { mode: "edit" }, inProcess: { mode: "edit" } }, + chat: {}, + sessionIntelligence: {}, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, + aiIntegrationService: { + summarizeTerminal: vi.fn(async () => ({ text: "", structuredOutput: null })), + getMode: vi.fn(() => "subscription"), + } as any, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + appVersion: "0.0.1-test", + getDirtyFileTextForPath: () => undefined, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + return { service, events }; +} + +async function runTextFixture(messages: Array>) { + const { service, events } = createHarness(messages); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await service.runSessionTurn({ sessionId: session.id, text: "Exercise Claude text deduplication." }); + expect(claudeSdk.releaseIdleMessages, "Claude query should be waiting at the idle-reader boundary").toBeTypeOf("function"); + claudeSdk.releaseIdleMessages?.(); + await vi.waitFor(() => { + expect(events.filter((envelope) => envelope.sessionId === session.id && envelope.event.type === "done")).toHaveLength(2); + }, { timeout: 2_000 }); + await service.disposeAll(); + return events + .filter((envelope) => envelope.sessionId === session.id) + .map((envelope) => envelope.event) + .filter((event): event is Extract => event.type === "text"); +} + +function messageStart(messageId: string) { + return { + type: "stream_event", + event: { + type: "message_start", + message: { id: messageId, usage: { input_tokens: 1, output_tokens: 0 } }, + }, + }; +} + +function textDelta(messageId: string, text: string) { + return { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + message: { id: messageId }, + delta: { type: "text_delta", text }, + }, + }; +} + +function assistantSnapshot(messageId: string, text: string) { + return { + type: "assistant", + message: { + id: messageId, + content: [{ type: "text", text }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; +} + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-text-dedup-")); + fs.mkdirSync(path.join(tempRoot, ".ade", "cache", "chat-sessions"), { recursive: true }); + fs.mkdirSync(path.join(tempRoot, ".ade", "transcripts", "chat"), { recursive: true }); + vi.spyOn(os, "homedir").mockReturnValue(tempRoot); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("Claude assistant text snapshot deduplication", () => { + it("emits only the unseen suffix when a full snapshot follows streamed prefix deltas", async () => { + const messageId = "msg-prefix-then-snapshot"; + const fullText = "I checked the renderer and added focused tests."; + const textEvents = await runTextFixture([ + messageStart(messageId), + textDelta(messageId, "I checked "), + textDelta(messageId, "the renderer"), + assistantSnapshot(messageId, fullText), + ]); + + expect(textEvents.map((event) => event.text).join("")).toBe(fullText); + expect(textEvents.every((event) => event.messageId === messageId)).toBe(true); + expect(textEvents.map((event) => event.text).join("").match(/I checked the renderer/g)).toHaveLength(1); + }); + + it("does not re-emit an old full snapshot after another message_start resets stream-local state", async () => { + const messageId = "msg-redelivered-after-other-start"; + const fullText = "Review findings are synthesized."; + const textEvents = await runTextFixture([ + messageStart(messageId), + textDelta(messageId, "Review findings "), + assistantSnapshot(messageId, fullText), + messageStart("msg-different"), + assistantSnapshot(messageId, fullText), + ]); + + expect(textEvents.map((event) => event.text).join("")).toBe(fullText); + expect(textEvents.every((event) => event.messageId === messageId)).toBe(true); + expect(textEvents.map((event) => event.text).join("").match(/Review findings/g)).toHaveLength(1); + }); + + it("emits identical back-to-back assistant snapshots only once", async () => { + const messageId = "msg-identical-snapshots"; + const fullText = "The final answer appears once."; + const textEvents = await runTextFixture([ + assistantSnapshot(messageId, fullText), + assistantSnapshot(messageId, fullText), + ]); + + expect(textEvents).toHaveLength(1); + expect(textEvents[0]).toMatchObject({ text: fullText, messageId }); + expect(textEvents.map((event) => event.text).join("")).toBe(fullText); + }); +}); diff --git a/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts b/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts new file mode 100644 index 000000000..a41216d3f --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts @@ -0,0 +1,315 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const claudeSdk = vi.hoisted(() => ({ + messages: [] as Array>, +})); + +function makeClaudeQuery(messages: Array>) { + const iterator = (async function* () { + for (const message of messages) yield message; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-query-lifecycle", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })(); + return Object.assign(iterator, { + close: vi.fn(), + interrupt: vi.fn(async () => undefined), + stopTask: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + reloadPlugins: vi.fn(async () => ({ commands: [], agents: [], plugins: [], error_count: 0 })), + supportedCommands: vi.fn(async () => []), + getContextUsage: vi.fn(async () => ({ + categories: [], + totalTokens: 0, + maxTokens: 0, + rawMaxTokens: 0, + percentage: 0, + gridRows: [], + model: "", + })), + }); +} + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + createSdkMcpServer: vi.fn((config: { name?: string; tools?: Array<{ name: string }> }) => ({ + type: "sdk", + name: config?.name, + instance: { + _registeredTools: Object.fromEntries((config?.tools ?? []).map((tool) => [tool.name, tool])), + }, + })), + getSessionInfo: vi.fn(), + getSessionMessages: vi.fn(async () => []), + listSessions: vi.fn(async () => []), + query: vi.fn(() => makeClaudeQuery(claudeSdk.messages)), + renameSession: vi.fn(async () => undefined), + startup: vi.fn(async () => ({ + query: () => makeClaudeQuery(claudeSdk.messages), + close: vi.fn(), + })), + tagSession: vi.fn(async () => undefined), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("@factory/droid-sdk", () => ({ + createSdkMcpServer: vi.fn(() => ({ start: vi.fn(), close: vi.fn() })), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("../ai/tools/universalTools", () => ({ + createUniversalToolSet: vi.fn(() => ({ + readFile: { description: "stub", parameters: {}, execute: vi.fn() }, + grep: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoRead: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoWrite: { description: "stub", parameters: {}, execute: vi.fn() }, + bash: { description: "stub", parameters: {}, execute: vi.fn() }, + })), +})); +vi.mock("../ai/tools/workflowTools", () => ({ createWorkflowTools: vi.fn(() => []) })); +vi.mock("../ai/tools/linearTools", () => ({ createLinearTools: vi.fn(() => []) })); +vi.mock("../ai/tools/ctoOperatorTools", () => ({ createCtoOperatorTools: vi.fn(() => []) })); +vi.mock("../ai/tools/systemPrompt", () => ({ + buildCodingAgentSystemPrompt: vi.fn(() => "system prompt"), + composeSystemPrompt: vi.fn(() => "system prompt"), +})); +vi.mock("../ai/claudeModelUtils", () => ({ resolveClaudeCliModel: vi.fn((model: string) => model) })); +vi.mock("../ai/providerRuntimeHealth", () => ({ + getProviderRuntimeHealth: vi.fn(() => null), + reportProviderRuntimeAuthFailure: vi.fn(), + reportProviderRuntimeFailure: vi.fn(), + reportProviderRuntimeReady: vi.fn(), +})); +vi.mock("../ai/claudeRuntimeProbe", () => ({ + CLAUDE_RUNTIME_AUTH_ERROR: "Claude authentication failed", + isClaudeRuntimeAuthError: vi.fn(() => false), +})); +vi.mock("../ai/claudeCodeExecutable", () => ({ + isExecutablePath: vi.fn(() => true), + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), +})); +vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []) })); +vi.mock("../git/git", () => ({ runGit: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })) })); +vi.mock("./permissionMapping", () => ({ + mapPermissionToClaude: vi.fn(() => "default"), + mapPermissionToCodex: vi.fn(() => ({ approvalPolicy: "on-request", sandbox: "read-only" })), +})); +vi.mock("../../../shared/chatTranscript", () => ({ parseAgentChatTranscript: vi.fn(() => []) })); + +import { createAgentChatService } from "./agentChatService"; +import type { ClaudeSubprocessReaper } from "./claudeSubprocessReaper"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; + +let tempRoot: string; +const activeServices: Array<{ disposeAll: () => Promise }> = []; + +function createReaperSpy(): ClaudeSubprocessReaper { + return { + reapForSession: vi.fn(), + reapAll: vi.fn(), + reapStaleRegistry: vi.fn(), + register: vi.fn(), + spawnClaudeCodeProcess: vi.fn(() => ({ pid: 4321 }) as any), + liveRecords: vi.fn(() => []), + }; +} + +function createHarness(messages: Array>) { + claudeSdk.messages = [ + { type: "system", subtype: "init", session_id: "sdk-query-lifecycle", slash_commands: [] }, + ...messages, + ]; + + const sessions = new Map>(); + const claudePointers = new Map>(); + const sessionService = { + create: vi.fn((args: Record) => { + sessions.set(args.sessionId, { + id: args.sessionId, + laneId: args.laneId, + title: args.title ?? "Chat", + toolType: args.toolType ?? "claude-chat", + status: "running", + startedAt: args.startedAt ?? new Date().toISOString(), + endedAt: null, + archivedAt: null, + transcriptPath: args.transcriptPath ?? "", + resumeCommand: args.resumeCommand ?? null, + goal: args.goal ?? null, + manuallyNamed: false, + }); + }), + get: vi.fn((sessionId: string) => sessions.get(sessionId) ?? null), + list: vi.fn(() => [...sessions.values()]), + reopen: vi.fn(), + end: vi.fn(), + deleteSession: vi.fn(), + archiveSession: vi.fn(), + unarchiveSession: vi.fn(), + updateMeta: vi.fn(), + setHeadShaStart: vi.fn(), + setHeadShaEnd: vi.fn(), + setLastOutputPreview: vi.fn(), + setSummary: vi.fn(), + setResumeCommand: vi.fn(), + upsertClaudeSessionPointer: vi.fn((pointer: Record) => { + const next = { ...claudePointers.get(pointer.chatSessionId), ...pointer }; + if (pointer.chatSessionId) claudePointers.set(pointer.chatSessionId, next); + return next; + }), + getClaudeSessionPointer: vi.fn(() => null), + getClaudeSessionPointerByChatSessionId: vi.fn((sessionId: string) => claudePointers.get(sessionId) ?? null), + listClaudeSessionPointers: vi.fn(() => [...claudePointers.values()]), + }; + const laneService = { + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/test", + worktreePath: tempRoot, + laneType: "feature", + })), + list: vi.fn(async () => []), + getSummary: vi.fn(async () => null), + getLane: vi.fn(() => null), + listLinearIssuesForSession: vi.fn(() => []), + }; + const events: AgentChatEventEnvelope[] = []; + const reaper = createReaperSpy(); + const transcriptsDir = path.join(tempRoot, "transcripts"); + fs.mkdirSync(transcriptsDir, { recursive: true }); + + const service = createAgentChatService({ + projectRoot: tempRoot, + transcriptsDir, + laneService: laneService as any, + sessionService: sessionService as any, + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { cli: { mode: "edit" }, inProcess: { mode: "edit" } }, + chat: {}, + sessionIntelligence: {}, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, + aiIntegrationService: { + summarizeTerminal: vi.fn(async () => ({ text: "", structuredOutput: null })), + getMode: vi.fn(() => "subscription"), + } as any, + claudeSubprocessReaper: reaper, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + appVersion: "0.0.1-test", + getDirtyFileTextForPath: () => undefined, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + activeServices.push(service); + return { service, events, reaper }; +} + +async function createDrivenSession(messages: Array>) { + const harness = createHarness(messages); + const session = await harness.service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await harness.service.runSessionTurn({ + sessionId: session.id, + text: "Exercise the Claude query lifecycle.", + }); + vi.mocked(harness.reaper.reapForSession).mockClear(); + return { ...harness, session }; +} + +async function resetReasoningEffort( + service: ReturnType, + sessionId: string, +) { + await service.updateSession({ + sessionId, + reasoningEffort: "high", + }); +} + +function lifecycleRestartNotices(events: AgentChatEventEnvelope[]) { + return events.filter((envelope) => + envelope.event.type === "system_notice" + && /session restarted/i.test(envelope.event.message) + && /background tasks were stopped/i.test(envelope.event.message) + ); +} + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-query-lifecycle-")); + fs.mkdirSync(path.join(tempRoot, ".ade", "cache", "chat-sessions"), { recursive: true }); + fs.mkdirSync(path.join(tempRoot, ".ade", "transcripts", "chat"), { recursive: true }); + vi.spyOn(os, "homedir").mockReturnValue(tempRoot); + vi.clearAllMocks(); +}); + +afterEach(async () => { + await Promise.all(activeServices.splice(0).map((service) => service.disposeAll())); + vi.restoreAllMocks(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("Claude query lifecycle", () => { + it("reaps the session subprocesses when a reasoning change resets the query", async () => { + const { service, reaper, session } = await createDrivenSession([]); + + await resetReasoningEffort(service, session.id); + + expect(reaper.reapForSession).toHaveBeenCalledWith( + session.id, + expect.stringMatching(/^claude_/), + ); + }); + + it("emits an orphan notice when reset stops an open background task", async () => { + const { service, events, reaper, session } = await createDrivenSession([{ + type: "system", + subtype: "task_started", + task_id: "background-task-1", + task_type: "local_bash", + description: "Run the verification suite", + command: "npm test", + }]); + + await resetReasoningEffort(service, session.id); + + expect(reaper.reapForSession).toHaveBeenCalledWith( + session.id, + expect.stringMatching(/^claude_/), + ); + expect(lifecycleRestartNotices(events)).toHaveLength(1); + }); + + it("does not emit an orphan notice when reset has no open background task", async () => { + const { service, events, session } = await createDrivenSession([]); + + await resetReasoningEffort(service, session.id); + + expect(lifecycleRestartNotices(events)).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts b/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts new file mode 100644 index 000000000..f2a6269a4 --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeSubagentResultGate.test.ts @@ -0,0 +1,375 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const claudeSdk = vi.hoisted(() => ({ + messages: [] as Array>, + holdOpen: false, + release: null as null | (() => void), +})); + +function makeClaudeQuery(messages: Array>) { + let release: (() => void) | null = null; + const gate = claudeSdk.holdOpen + ? new Promise((resolve) => { + release = resolve; + claudeSdk.release = resolve; + }) + : Promise.resolve(); + const iterator = (async function* () { + for (const message of messages) yield message; + await gate; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-subagent-result-gate", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })(); + return Object.assign(iterator, { + close: vi.fn(() => release?.()), + interrupt: vi.fn(async () => undefined), + stopTask: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + reloadPlugins: vi.fn(async () => ({ commands: [], agents: [], plugins: [], error_count: 0 })), + supportedCommands: vi.fn(async () => []), + getContextUsage: vi.fn(async () => ({ + categories: [], + totalTokens: 0, + maxTokens: 0, + rawMaxTokens: 0, + percentage: 0, + gridRows: [], + model: "", + })), + }); +} + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + createSdkMcpServer: vi.fn((config: { name?: string; tools?: Array<{ name: string }> }) => ({ + type: "sdk", + name: config?.name, + instance: { + _registeredTools: Object.fromEntries((config?.tools ?? []).map((tool) => [tool.name, tool])), + }, + })), + getSessionInfo: vi.fn(), + getSessionMessages: vi.fn(async () => []), + listSessions: vi.fn(async () => []), + query: vi.fn(() => makeClaudeQuery(claudeSdk.messages)), + renameSession: vi.fn(async () => undefined), + startup: vi.fn(async () => ({ + query: () => makeClaudeQuery(claudeSdk.messages), + close: vi.fn(), + })), + tagSession: vi.fn(async () => undefined), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("@factory/droid-sdk", () => ({ + createSdkMcpServer: vi.fn(() => ({ start: vi.fn(), close: vi.fn() })), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("../ai/tools/universalTools", () => ({ + createUniversalToolSet: vi.fn(() => ({ + readFile: { description: "stub", parameters: {}, execute: vi.fn() }, + grep: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoRead: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoWrite: { description: "stub", parameters: {}, execute: vi.fn() }, + bash: { description: "stub", parameters: {}, execute: vi.fn() }, + })), +})); +vi.mock("../ai/tools/workflowTools", () => ({ createWorkflowTools: vi.fn(() => []) })); +vi.mock("../ai/tools/linearTools", () => ({ createLinearTools: vi.fn(() => []) })); +vi.mock("../ai/tools/ctoOperatorTools", () => ({ createCtoOperatorTools: vi.fn(() => []) })); +vi.mock("../ai/tools/systemPrompt", () => ({ + buildCodingAgentSystemPrompt: vi.fn(() => "system prompt"), + composeSystemPrompt: vi.fn(() => "system prompt"), +})); +vi.mock("../ai/claudeModelUtils", () => ({ resolveClaudeCliModel: vi.fn((model: string) => model) })); +vi.mock("../ai/providerRuntimeHealth", () => ({ + getProviderRuntimeHealth: vi.fn(() => null), + reportProviderRuntimeAuthFailure: vi.fn(), + reportProviderRuntimeFailure: vi.fn(), + reportProviderRuntimeReady: vi.fn(), +})); +vi.mock("../ai/claudeRuntimeProbe", () => ({ + CLAUDE_RUNTIME_AUTH_ERROR: "Claude authentication failed", + isClaudeRuntimeAuthError: vi.fn(() => false), +})); +vi.mock("../ai/claudeCodeExecutable", () => ({ + isExecutablePath: vi.fn(() => true), + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), +})); +vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []) })); +vi.mock("../git/git", () => ({ runGit: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })) })); +vi.mock("./permissionMapping", () => ({ + mapPermissionToClaude: vi.fn(() => "default"), + mapPermissionToCodex: vi.fn(() => ({ approvalPolicy: "on-request", sandbox: "read-only" })), +})); +vi.mock("../../../shared/chatTranscript", () => ({ parseAgentChatTranscript: vi.fn(() => []) })); + +import { createAgentChatService } from "./agentChatService"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; + +let tempRoot: string; +const activeServices: Array<{ disposeAll: () => Promise }> = []; + +function createHarness(messages: Array>, holdOpen = false) { + claudeSdk.messages = [ + { type: "system", subtype: "init", session_id: "sdk-subagent-result-gate", slash_commands: [] }, + ...messages, + ]; + claudeSdk.holdOpen = holdOpen; + claudeSdk.release = null; + + const sessions = new Map>(); + const claudePointers = new Map>(); + const sessionService = { + create: vi.fn((args: Record) => { + sessions.set(args.sessionId, { + id: args.sessionId, + laneId: args.laneId, + title: args.title ?? "Chat", + toolType: args.toolType ?? "claude-chat", + status: "running", + startedAt: args.startedAt ?? new Date().toISOString(), + endedAt: null, + archivedAt: null, + transcriptPath: args.transcriptPath ?? "", + resumeCommand: args.resumeCommand ?? null, + goal: args.goal ?? null, + manuallyNamed: false, + }); + }), + get: vi.fn((sessionId: string) => sessions.get(sessionId) ?? null), + list: vi.fn(() => [...sessions.values()]), + reopen: vi.fn(), + end: vi.fn(), + deleteSession: vi.fn(), + archiveSession: vi.fn(), + unarchiveSession: vi.fn(), + updateMeta: vi.fn(), + setHeadShaStart: vi.fn(), + setHeadShaEnd: vi.fn(), + setLastOutputPreview: vi.fn(), + setSummary: vi.fn(), + setResumeCommand: vi.fn(), + upsertClaudeSessionPointer: vi.fn((pointer: Record) => { + const next = { ...claudePointers.get(pointer.chatSessionId), ...pointer }; + if (pointer.chatSessionId) claudePointers.set(pointer.chatSessionId, next); + return next; + }), + getClaudeSessionPointer: vi.fn(() => null), + getClaudeSessionPointerByChatSessionId: vi.fn((sessionId: string) => claudePointers.get(sessionId) ?? null), + listClaudeSessionPointers: vi.fn(() => [...claudePointers.values()]), + }; + const laneService = { + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/test", + worktreePath: tempRoot, + laneType: "feature", + })), + list: vi.fn(async () => []), + getSummary: vi.fn(async () => null), + getLane: vi.fn(() => null), + listLinearIssuesForSession: vi.fn(() => []), + }; + const events: AgentChatEventEnvelope[] = []; + const transcriptsDir = path.join(tempRoot, "transcripts"); + fs.mkdirSync(transcriptsDir, { recursive: true }); + + const service = createAgentChatService({ + projectRoot: tempRoot, + transcriptsDir, + laneService: laneService as any, + sessionService: sessionService as any, + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { cli: { mode: "edit" }, inProcess: { mode: "edit" } }, + chat: {}, + sessionIntelligence: {}, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, + aiIntegrationService: { + summarizeTerminal: vi.fn(async () => ({ text: "", structuredOutput: null })), + getMode: vi.fn(() => "subscription"), + } as any, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + appVersion: "0.0.1-test", + getDirtyFileTextForPath: () => undefined, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + activeServices.push(service); + return { service, events }; +} + +async function createSession(messages: Array>, holdOpen = false) { + const harness = createHarness(messages, holdOpen); + const session = await harness.service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await vi.waitFor(() => { + expect(harness.events.some((envelope) => + envelope.sessionId === session.id + && envelope.event.type === "system_notice" + && envelope.event.message === "Session ready" + )).toBe(true); + }); + return { ...harness, session }; +} + +function resultsFor(events: AgentChatEventEnvelope[], taskId: string) { + return events.filter((envelope) => + envelope.event.type === "subagent_result" && envelope.event.taskId === taskId + ).map((envelope) => envelope.event); +} + +async function interruptAfterEvent( + messages: Array>, + ready: (event: AgentChatEventEnvelope) => boolean, +) { + const harness = await createSession(messages, true); + const sendPromise = harness.service.sendMessage({ + sessionId: harness.session.id, + text: "Exercise Claude subagent lifecycle gating.", + }); + await vi.waitFor(() => { + expect(harness.events.some(ready)).toBe(true); + }); + await harness.service.interrupt({ sessionId: harness.session.id }); + claudeSdk.release?.(); + await expect(sendPromise).resolves.toBeUndefined(); + return harness.events; +} + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-subagent-result-gate-")); + fs.mkdirSync(path.join(tempRoot, ".ade", "cache", "chat-sessions"), { recursive: true }); + fs.mkdirSync(path.join(tempRoot, ".ade", "transcripts", "chat"), { recursive: true }); + vi.spyOn(os, "homedir").mockReturnValue(tempRoot); + vi.clearAllMocks(); +}); + +afterEach(async () => { + claudeSdk.release?.(); + await Promise.all(activeServices.splice(0).map((service) => service.disposeAll())); + vi.restoreAllMocks(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("Claude subagent result gate", () => { + it("does not emit an interrupted result for a task_updated id that never started", async () => { + const taskId = "phantom-task"; + const events = await interruptAfterEvent([ + { + type: "system", + subtype: "task_updated", + task_id: taskId, + patch: { status: "in_progress", description: "Unknown SDK task" }, + }, + ], (envelope) => envelope.event.type === "subagent_progress" && envelope.event.taskId === taskId); + + expect(resultsFor(events, taskId)).toHaveLength(0); + }); + + it("emits exactly one stopped result for a subagent that emitted started", async () => { + const taskId = "real-subagent"; + const events = await interruptAfterEvent([ + { + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Inspect the chat pipeline", + task_type: "subagent", + }, + ], (envelope) => envelope.event.type === "subagent_started" && envelope.event.taskId === taskId); + + expect(resultsFor(events, taskId)).toEqual([ + expect.objectContaining({ type: "subagent_result", taskId, status: "stopped" }), + ]); + }); + + it("emits one completed result and suppresses a second terminal update", async () => { + const taskId = "completed-subagent"; + const { service, events, session } = await createSession([ + { + type: "system", + subtype: "task_started", + task_id: taskId, + description: "Finish the focused review", + task_type: "subagent", + }, + { + type: "system", + subtype: "task_notification", + task_id: taskId, + status: "completed", + summary: "Review complete", + }, + { + type: "system", + subtype: "task_updated", + task_id: taskId, + patch: { status: "completed" }, + }, + ]); + + await service.runSessionTurn({ sessionId: session.id, text: "Complete the subagent task." }); + + expect(resultsFor(events, taskId)).toEqual([ + expect.objectContaining({ type: "subagent_result", taskId, status: "completed" }), + ]); + }); + + it("stops only the two surfaced subagents when five unknown task ids are tracked", async () => { + const realIds = ["real-a", "real-b"]; + const strayIds = ["stray-1", "stray-2", "stray-3", "stray-4", "stray-5"]; + const events = await interruptAfterEvent([ + ...realIds.map((taskId) => ({ + type: "system", + subtype: "task_started", + task_id: taskId, + description: `Started ${taskId}`, + task_type: "subagent", + })), + ...strayIds.map((taskId) => ({ + type: "system", + subtype: "task_updated", + task_id: taskId, + patch: { status: "in_progress", description: `Unknown ${taskId}` }, + })), + ], (envelope) => envelope.event.type === "subagent_progress" && envelope.event.taskId === strayIds.at(-1)); + + const stoppedResults = events.filter((envelope) => + envelope.event.type === "subagent_result" && envelope.event.status === "stopped" + ); + expect(stoppedResults).toHaveLength(2); + expect(stoppedResults.map((envelope) => envelope.event.type === "subagent_result" && envelope.event.taskId).sort()) + .toEqual(realIds); + expect(strayIds.flatMap((taskId) => resultsFor(events, taskId))).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts b/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts new file mode 100644 index 000000000..104c15eba --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeTaskTodos.test.ts @@ -0,0 +1,313 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const claudeSdk = vi.hoisted(() => ({ + messages: [] as Array>, +})); + +function makeClaudeQuery(messages: Array>) { + const iterator = (async function* () { + for (const message of messages) yield message; + })(); + return Object.assign(iterator, { + close: vi.fn(), + interrupt: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + reloadPlugins: vi.fn(async () => ({ commands: [], agents: [], plugins: [], error_count: 0 })), + supportedCommands: vi.fn(async () => []), + getContextUsage: vi.fn(async () => ({ + categories: [], + totalTokens: 0, + maxTokens: 0, + rawMaxTokens: 0, + percentage: 0, + gridRows: [], + model: "", + })), + }); +} + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + createSdkMcpServer: vi.fn((config: { name?: string; tools?: Array<{ name: string }> }) => ({ + type: "sdk", + name: config?.name, + instance: { + _registeredTools: Object.fromEntries((config?.tools ?? []).map((tool) => [tool.name, tool])), + }, + })), + getSessionInfo: vi.fn(), + getSessionMessages: vi.fn(async () => []), + listSessions: vi.fn(async () => []), + query: vi.fn(() => makeClaudeQuery(claudeSdk.messages)), + renameSession: vi.fn(async () => undefined), + startup: vi.fn(async () => ({ + query: () => makeClaudeQuery(claudeSdk.messages), + close: vi.fn(), + })), + tagSession: vi.fn(async () => undefined), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("@factory/droid-sdk", () => ({ + createSdkMcpServer: vi.fn(() => ({ start: vi.fn(), close: vi.fn() })), + tool: vi.fn((name: string, description: string, inputSchema: unknown, handler: unknown) => ({ + name, + description, + inputSchema, + handler, + })), +})); + +vi.mock("../ai/tools/universalTools", () => ({ + createUniversalToolSet: vi.fn(() => ({ + readFile: { description: "stub", parameters: {}, execute: vi.fn() }, + grep: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoRead: { description: "stub", parameters: {}, execute: vi.fn() }, + TodoWrite: { description: "stub", parameters: {}, execute: vi.fn() }, + bash: { description: "stub", parameters: {}, execute: vi.fn() }, + })), +})); +vi.mock("../ai/tools/workflowTools", () => ({ createWorkflowTools: vi.fn(() => []) })); +vi.mock("../ai/tools/linearTools", () => ({ createLinearTools: vi.fn(() => []) })); +vi.mock("../ai/tools/ctoOperatorTools", () => ({ createCtoOperatorTools: vi.fn(() => []) })); +vi.mock("../ai/tools/systemPrompt", () => ({ + buildCodingAgentSystemPrompt: vi.fn(() => "system prompt"), + composeSystemPrompt: vi.fn(() => "system prompt"), +})); +vi.mock("../ai/claudeModelUtils", () => ({ resolveClaudeCliModel: vi.fn((model: string) => model) })); +vi.mock("../ai/providerRuntimeHealth", () => ({ + getProviderRuntimeHealth: vi.fn(() => null), + reportProviderRuntimeAuthFailure: vi.fn(), + reportProviderRuntimeFailure: vi.fn(), + reportProviderRuntimeReady: vi.fn(), +})); +vi.mock("../ai/claudeRuntimeProbe", () => ({ + CLAUDE_RUNTIME_AUTH_ERROR: "Claude authentication failed", + isClaudeRuntimeAuthError: vi.fn(() => false), +})); +vi.mock("../ai/claudeCodeExecutable", () => ({ + isExecutablePath: vi.fn(() => true), + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), +})); +vi.mock("../ai/authDetector", () => ({ detectAllAuth: vi.fn(async () => []) })); +vi.mock("../git/git", () => ({ runGit: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })) })); +vi.mock("./permissionMapping", () => ({ + mapPermissionToClaude: vi.fn(() => "default"), + mapPermissionToCodex: vi.fn(() => ({ approvalPolicy: "on-request", sandbox: "read-only" })), +})); +vi.mock("../../../shared/chatTranscript", () => ({ parseAgentChatTranscript: vi.fn(() => []) })); + +import { createAgentChatService } from "./agentChatService"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; + +let tempRoot: string; + +function createHarness(messages: Array>) { + claudeSdk.messages = [ + { type: "system", subtype: "init", session_id: "sdk-task-todos", slash_commands: [] }, + ...messages, + { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-task-todos", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + ]; + + const sessions = new Map>(); + const claudePointers = new Map>(); + const sessionService = { + create: vi.fn((args: Record) => { + sessions.set(args.sessionId, { + id: args.sessionId, + laneId: args.laneId, + title: args.title ?? "Chat", + toolType: args.toolType ?? "claude-chat", + status: "running", + startedAt: args.startedAt ?? new Date().toISOString(), + endedAt: null, + archivedAt: null, + transcriptPath: args.transcriptPath ?? "", + resumeCommand: args.resumeCommand ?? null, + goal: args.goal ?? null, + manuallyNamed: false, + }); + }), + get: vi.fn((sessionId: string) => sessions.get(sessionId) ?? null), + list: vi.fn(() => [...sessions.values()]), + reopen: vi.fn(), + end: vi.fn(), + deleteSession: vi.fn(), + archiveSession: vi.fn(), + unarchiveSession: vi.fn(), + updateMeta: vi.fn(), + setHeadShaStart: vi.fn(), + setHeadShaEnd: vi.fn(), + setLastOutputPreview: vi.fn(), + setSummary: vi.fn(), + setResumeCommand: vi.fn(), + upsertClaudeSessionPointer: vi.fn((pointer: Record) => { + const next = { ...claudePointers.get(pointer.chatSessionId), ...pointer }; + if (pointer.chatSessionId) claudePointers.set(pointer.chatSessionId, next); + return next; + }), + getClaudeSessionPointer: vi.fn(() => null), + getClaudeSessionPointerByChatSessionId: vi.fn((sessionId: string) => claudePointers.get(sessionId) ?? null), + listClaudeSessionPointers: vi.fn(() => [...claudePointers.values()]), + }; + const laneService = { + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/test", + worktreePath: tempRoot, + laneType: "feature", + })), + list: vi.fn(async () => []), + getSummary: vi.fn(async () => null), + getLane: vi.fn(() => null), + listLinearIssuesForSession: vi.fn(() => []), + }; + const events: AgentChatEventEnvelope[] = []; + const transcriptsDir = path.join(tempRoot, "transcripts"); + fs.mkdirSync(transcriptsDir, { recursive: true }); + + const service = createAgentChatService({ + projectRoot: tempRoot, + transcriptsDir, + laneService: laneService as any, + sessionService: sessionService as any, + projectConfigService: { + get: vi.fn(() => ({ + effective: { + ai: { + permissions: { cli: { mode: "edit" }, inProcess: { mode: "edit" } }, + chat: {}, + sessionIntelligence: {}, + }, + }, + })), + getAll: vi.fn(() => ({})), + set: vi.fn(), + } as any, + aiIntegrationService: { + summarizeTerminal: vi.fn(async () => ({ text: "", structuredOutput: null })), + getMode: vi.fn(() => "subscription"), + } as any, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any, + appVersion: "0.0.1-test", + getDirtyFileTextForPath: () => undefined, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + return { service, events }; +} + +async function runTaskFixture(messages: Array>) { + const { service, events } = createHarness(messages); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await service.runSessionTurn({ sessionId: session.id, text: "Exercise Claude task tracking." }); + await service.disposeAll(); + return events + .filter((envelope) => envelope.sessionId === session.id) + .map((envelope) => envelope.event) + .filter((event): event is Extract => + event.type === "todo_update", + ); +} + +function taskToolUse(id: string, name: "TaskCreate" | "TaskUpdate", input: Record) { + return { + type: "assistant", + message: { + content: [{ type: "tool_use", id, name, input }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; +} + +beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-task-todos-")); + fs.mkdirSync(path.join(tempRoot, ".ade", "cache", "chat-sessions"), { recursive: true }); + fs.mkdirSync(path.join(tempRoot, ".ade", "transcripts", "chat"), { recursive: true }); + vi.spyOn(os, "homedir").mockReturnValue(tempRoot); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("Claude TaskCreate and TaskUpdate todo tracking", () => { + it("remaps ordinal task id 1 onto the first created task without fabricating a ghost row", async () => { + const todoEvents = await runTaskFixture([ + taskToolUse("toolu_A", "TaskCreate", { subject: "Synthesize review findings" }), + taskToolUse("toolu_B", "TaskUpdate", { taskId: "1", status: "in_progress" }), + ]); + + expect(todoEvents).toHaveLength(2); + expect(todoEvents[0].items).toEqual([ + { id: "toolu_A", description: "Synthesize review findings", status: "pending" }, + ]); + expect(todoEvents[1].items).toEqual([ + { id: "1", description: "Synthesize review findings", status: "in_progress" }, + ]); + expect(todoEvents[1].items.some((item) => item.description === "1")).toBe(false); + }); + + it("maps ordinal task id 2 to the second created task while preserving order and the first task", async () => { + const todoEvents = await runTaskFixture([ + taskToolUse("toolu_A", "TaskCreate", { subject: "Inspect implementation" }), + taskToolUse("toolu_B", "TaskCreate", { subject: "Write regression tests" }), + taskToolUse("toolu_C", "TaskUpdate", { taskId: "2", status: "completed" }), + ]); + + expect(todoEvents).toHaveLength(3); + expect(todoEvents.at(-1)?.items).toEqual([ + { id: "toolu_A", description: "Inspect implementation", status: "pending" }, + { id: "2", description: "Write regression tests", status: "completed" }, + ]); + expect(todoEvents.at(-1)?.items.map((item) => item.description)).toEqual([ + "Inspect implementation", + "Write regression tests", + ]); + }); + + it("ignores a bare update for an unknown ordinal instead of emitting a fabricated todo", async () => { + const todoEvents = await runTaskFixture([ + taskToolUse("toolu_A", "TaskCreate", { subject: "Only known task" }), + taskToolUse("toolu_B", "TaskUpdate", { taskId: "7" }), + ]); + + expect(todoEvents).toHaveLength(1); + expect(todoEvents[0].items).toEqual([ + { id: "toolu_A", description: "Only known task", status: "pending" }, + ]); + expect(todoEvents.flatMap((event) => event.items).some((item) => item.description === "7")).toBe(false); + }); + + it("creates a subject-bearing todo for an unknown ordinal beyond the creation map", async () => { + const todoEvents = await runTaskFixture([ + taskToolUse("toolu_A", "TaskCreate", { subject: "Existing task" }), + taskToolUse("toolu_B", "TaskUpdate", { taskId: "9", subject: "New follow-up", status: "pending" }), + ]); + + expect(todoEvents).toHaveLength(2); + expect(todoEvents.at(-1)?.items).toEqual([ + { id: "toolu_A", description: "Existing task", status: "pending" }, + { id: "9", description: "New follow-up", status: "pending" }, + ]); + expect(todoEvents.at(-1)?.items.filter((item) => item.id === "9")).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 1d8f8018b..5de04d257 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -160,6 +160,7 @@ import type { AgentChatSessionSummary, CodexThreadGoal, AgentChatSteerArgs, + AgentChatSteerResult, AgentChatCancelSteerArgs, AgentChatEditSteerArgs, AgentChatDispatchSteerArgs, @@ -1344,7 +1345,7 @@ declare global { args: AgentChatMarkCrossMachineHandoffArgs, ) => Promise; send: (args: AgentChatSendArgs, pin?: OpenProjectBinding | null) => Promise; - steer: (args: AgentChatSteerArgs) => Promise; + steer: (args: AgentChatSteerArgs) => Promise; cancelSteer: (args: AgentChatCancelSteerArgs) => Promise; editSteer: (args: AgentChatEditSteerArgs) => Promise; dispatchSteer: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d9f4da14e..f271138ee 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -336,6 +336,7 @@ import type { AgentChatSessionSummary, CodexThreadGoal, AgentChatSteerArgs, + AgentChatSteerResult, AgentChatCancelSteerArgs, AgentChatEditSteerArgs, AgentChatDispatchSteerArgs, @@ -5228,12 +5229,13 @@ contextBridge.exposeInMainWorld("ade", { } agentChatSummaryCache.clear(); }, - steer: async (args: AgentChatSteerArgs): Promise => { + steer: async (args: AgentChatSteerArgs): Promise => { agentChatSummaryCache.clear(); - await callProjectRuntimeActionOr("chat", "steer", { args }, () => + const result = await callProjectRuntimeActionOr("chat", "steer", { args }, () => ipcRenderer.invoke(IPC.agentChatSteer, args), ); agentChatSummaryCache.clear(); + return result; }, cancelSteer: async (args: AgentChatCancelSteerArgs): Promise => { agentChatSummaryCache.clear(); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index d995dde7b..7bee801c5 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4832,7 +4832,10 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { validateCrossMachineSource: resolvedArg(undefined), markCrossMachineHandoff: resolvedArg(undefined), send: resolvedArg(undefined), - steer: resolvedArg(undefined), + steer: async () => ({ + steerId: globalThis.crypto.randomUUID(), + queued: true, + }), cancelSteer: resolvedArg(undefined), editSteer: resolvedArg(undefined), dispatchSteer: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 69e32e969..1e9365f07 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -253,6 +253,76 @@ describe("AgentChatComposer", () => { expect(onCancelSteer).toHaveBeenCalledWith("steer-1"); }); + const CLAUDE_STEER_OVERRIDES = { + sessionProvider: "claude" as const, + modelId: "anthropic/claude-sonnet-5", + availableModelIds: ["anthropic/claude-sonnet-5"], + }; + + it("primary send folds the draft into the running Claude turn", () => { + const onSendSteerNow = vi.fn(); + renderComposer({ + ...CLAUDE_STEER_OVERRIDES, + onSendSteerNow, + onSendSteerInterrupt: vi.fn(), + }); + + fireEvent.click(screen.getByRole("button", { name: "Send now" })); + + expect(onSendSteerNow).toHaveBeenCalledTimes(1); + }); + + it("split-button menu queues after the turn or interrupts and replaces it", () => { + const onSubmit = vi.fn(); + const onSendSteerInterrupt = vi.fn(); + renderComposer({ + ...CLAUDE_STEER_OVERRIDES, + onSubmit, + onSendSteerNow: vi.fn(), + onSendSteerInterrupt, + }); + + fireEvent.click(screen.getByRole("button", { name: "More send options" })); + fireEvent.click(screen.getByRole("menuitem", { name: /Queue for after turn/ })); + expect(onSubmit).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "More send options" })); + fireEvent.click(screen.getByRole("menuitem", { name: /Interrupt & replace/ })); + expect(onSendSteerInterrupt).toHaveBeenCalledTimes(1); + }); + + it("disables the active-turn send actions when the draft is whitespace-only", () => { + const onSendSteerNow = vi.fn(); + renderComposer({ + ...CLAUDE_STEER_OVERRIDES, + draft: " ", + onSendSteerNow, + onSendSteerInterrupt: vi.fn(), + }); + + const sendNow = screen.getByRole("button", { name: "Send now" }) as HTMLButtonElement; + expect(sendNow.disabled).toBe(true); + + fireEvent.click(sendNow); + expect(onSendSteerNow).not.toHaveBeenCalled(); + }); + + it("routes Enter to Send now during an active Claude turn", () => { + const onSendSteerNow = vi.fn(); + const onSubmit = vi.fn(); + renderComposer({ + ...CLAUDE_STEER_OVERRIDES, + onSendSteerNow, + onSendSteerInterrupt: vi.fn(), + onSubmit, + }); + + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + + expect(onSendSteerNow).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it("accepts the prompt suggestion with Tab", () => { const onDraftChange = vi.fn(); renderComposer({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 9a31a2733..a1ee49e8b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -960,7 +960,7 @@ function PendingSteerItem({ {!editing ? (
{onSendNow ? ( - + - + + + + + +
+ {menuOpen && caretRef.current + ? createPortal( + (() => { + const rect = caretRef.current.getBoundingClientRect(); + const width = 244; + const left = Math.min( + Math.max(8, rect.right - width), + Math.max(8, window.innerWidth - width - 8), + ); + return ( +
+ + + + {onInterrupt ? ( + + + + ) : null} +
+ ); + })(), + document.body, + ) + : null} + + ); +} + export function AgentChatComposer({ surfaceMode = "standard", layoutVariant = "standard", @@ -1096,6 +1270,8 @@ export function AgentChatComposer({ onEditSteer, onDispatchSteerInline, onDispatchSteerInterrupt, + onSendSteerNow, + onSendSteerInterrupt, onOpenAiSettings, onOpenLinearSettings, launchPromptClipboardEnabled = false, @@ -1252,6 +1428,14 @@ export function AgentChatComposer({ onEditSteer?: (steerId: string, text: string) => void; onDispatchSteerInline?: (steerId: string) => void; onDispatchSteerInterrupt?: (steerId: string) => void; + /** + * Active-turn split-button primary: submit the current draft and immediately + * fold it into the running turn (Claude Code parity). Only supplied for + * providers whose runtime can dispatch a queued steer into a live turn. + */ + onSendSteerNow?: () => void; + /** Active-turn split-button option: submit the draft, then stop the current turn and run it. */ + onSendSteerInterrupt?: () => void; onOpenAiSettings?: (family?: ProviderFamily) => void; onOpenLinearSettings?: () => void; launchPromptClipboardEnabled?: boolean; @@ -2848,6 +3032,13 @@ export function AgentChatComposer({ const shouldSend = sendOnEnter ? !commandEnter : commandEnter; if (!shouldSend) return; event.preventDefault(); + // Claude Code parity: pressing Enter mid-turn folds the draft into the + // running turn ("Send now") rather than only staging it. Other providers + // (no inline-steer dispatch) keep the queue-on-Enter behavior. + if (turnActive && onSendSteerNow) { + if (activeSteerEnabled) onSendSteerNow(); + return; + } submitComposerDraft(); }; @@ -3099,6 +3290,16 @@ export function AgentChatComposer({ ); const hasPendingImageAttachments = pendingImageAttachments.length > 0; const sendEnabled = !busy && !backgroundLaunchBusy && !parallelLaunchBusy && !composerInputLocked && !hasPendingImageAttachments && (parallelReady || singleReady); + // Active-turn steering has something to deliver when the draft carries text or + // any visual/issue context is selected. Mirrors `singleReady` so an empty or + // whitespace-only draft disables the send actions instead of silently no-oping. + const activeTurnHasContent = + draft.trim().length > 0 + || hasIosElementContext + || hasAppControlContext + || hasBuiltInBrowserContext + || contextAttachmentCount > 0; + const activeSteerEnabled = !composerInputLocked && !hasPendingImageAttachments && activeTurnHasContent; const backgroundSendEnabled = Boolean(onSubmitInBackground) && !busy && !backgroundLaunchBusy @@ -4118,7 +4319,7 @@ export function AgentChatComposer({ {turnActive ? ( <> {draft.trim().length > 0 && onClearDraft ? ( - + - + {!composerInputLocked ? ( + onSendSteerNow ? ( + // Claude Code parity: primary click folds the draft into the + // live turn; caret menu keeps queue / interrupt-replace. + + ) : ( + // Providers without inline-steer dispatch keep the single + // queue affordance; it still explains itself on hover. + + + + ) ) : null} - + - - ) : null} {/* Codex chat goal is rendered in the Agents tab via ChatSubagentsPanel; the in-chat banner was removed so the chat header stays clean and goal context lives next to subagents + progress where it belongs. */} 0, )} loadingOlderHistory={Boolean( !subagentView - && !mainTranscriptView && selectedSessionId && olderHistoryLoadingBySession[selectedSessionId], )} - onLoadOlderHistory={!subagentView && !mainTranscriptView && selectedSessionId ? loadOlderHistoryForSelectedSession : undefined} + onLoadOlderHistory={!subagentView && selectedSessionId ? loadOlderHistoryForSelectedSession : undefined} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} laneId={laneId} @@ -11169,8 +11119,8 @@ export function AgentChatPane({ laneId, })); }} - mosaic={subagentView || mainTranscriptView ? undefined : mosaicContext} - scrollToRowKeyRequest={subagentView || mainTranscriptView ? null : wakeJumpRequest} + mosaic={subagentView ? undefined : mosaicContext} + scrollToRowKeyRequest={subagentView ? null : wakeJumpRequest} /> {sessionDelta ? (
diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index eb664fd45..db82d7697 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -188,7 +188,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); + fireEvent.click(screen.getByRole("button", { name: /Completed \(1\)/i })); fireEvent.click(screen.getByTitle("Audit chat renderer")); await waitFor(() => expect(probeSubagentTranscript).toHaveBeenCalledTimes(1)); @@ -415,7 +415,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { // Background section shows smart labels (cwd stripped from the collapsed row). expect(screen.getByText("npx vitest run t")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); + fireEvent.click(screen.getByRole("button", { name: /Completed \(1\)/i })); expect(screen.getByText("npm run build")).toBeTruthy(); // Schedule row present. @@ -518,7 +518,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.getByText("paused")).toBeTruthy(); }); - it("moves fired one-shot wakeups into the collapsed Earlier group and marks late fires", () => { + it("moves fired one-shot wakeups into the collapsed Completed group and marks late fires", () => { const firedAt = new Date(2026, 4, 12, 8, 41).toISOString(); render( { ); expect(screen.getByTitle("Recurring CI check")).toBeTruthy(); - const earlierToggle = screen.getByRole("button", { name: "Earlier (1)" }); + const earlierToggle = screen.getByRole("button", { name: "Completed (1)" }); expect(earlierToggle.getAttribute("aria-expanded")).toBe("false"); expect(screen.queryByText("✓ Check PR CI · fired 8:41 AM · late")).toBeNull(); @@ -599,12 +599,12 @@ describe("ChatSubagentsPanel (pane variant)", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); + fireEvent.click(screen.getByRole("button", { name: /Completed \(1\)/i })); expect(screen.getByText("done")).toBeTruthy(); expect(screen.queryByText("running")).toBeNull(); }); - it("keeps the small case free of collapse, Earlier, Show all, and Clear chrome", () => { + it("keeps the small case free of collapse, Completed, Show all, and Clear chrome", () => { render( { ); expect(screen.queryByRole("button", { name: /Subagents/i })).toBeNull(); - expect(screen.queryByRole("button", { name: /Earlier/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /Completed/i })).toBeNull(); expect(screen.queryByRole("button", { name: /Show all/i })).toBeNull(); expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); }); @@ -643,7 +643,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.queryByRole("button", { name: /Show all/i })).toBeNull(); }); - it("clears and restores Earlier rows with the normalized per-session storage shape", () => { + it("clears and restores Completed rows with the normalized per-session storage shape", () => { const sessionId = "pane-persistence"; window.localStorage.removeItem(`ade.chat.paneUi.v1:${sessionId}`); window.localStorage.removeItem(`ade.chat.paneCleared.v1:${sessionId}`); @@ -664,6 +664,10 @@ describe("ChatSubagentsPanel (pane variant)", () => { />, ); + // Clear lives inline on the Completed row, only once the bucket is expanded. + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Completed (2)" })); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); expect(screen.getByText("Subagents · all clear")).toBeTruthy(); expect(JSON.parse(window.localStorage.getItem(`ade.chat.paneCleared.v1:${sessionId}`) ?? "null")).toEqual({ @@ -671,8 +675,8 @@ describe("ChatSubagentsPanel (pane variant)", () => { background: [], schedule: [], }); - fireEvent.click(screen.getByRole("button", { name: "Restore (2)" })); - expect(screen.getByRole("button", { name: "Earlier (2)" })).toBeTruthy(); + fireEvent.click(screen.getAllByRole("button", { name: "Restore (2)" })[0]); + expect(screen.getByRole("button", { name: "Completed (2)" })).toBeTruthy(); }); it("owns the pane scroller and uses sticky opaque section headers", () => { diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index a965b0109..4922758ec 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -141,19 +141,12 @@ function readPaneClearedState(sessionId?: string | null): PaneClearedStorageStat function paneSectionHint(args: { activeCount: number; - earlierCount: number; - clearedCount: number; runningCount?: number; failedCount?: number; }): string { - const active = args.runningCount && args.failedCount + return args.runningCount && args.failedCount ? `${args.runningCount} running · ${args.failedCount} failed` : `${args.activeCount}`; - return [ - active, - ...(args.earlierCount ? [`${args.earlierCount} earlier`] : []), - ...(args.clearedCount ? [`${args.clearedCount} hidden`] : []), - ].join(" · "); } type GlyphCategory = "subagent" | "background"; @@ -307,10 +300,10 @@ function EarlierToggle({ type="button" onClick={onToggle} aria-expanded={expanded} - className="flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left font-sans text-[10.5px] text-fg/40 transition-colors hover:bg-white/[0.035] hover:text-fg/60" + className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1 text-left font-sans text-[10.5px] text-fg/40 transition-colors hover:bg-white/[0.035] hover:text-fg/60" > {expanded ? : } - Earlier ({count}){clearedCount ? ` · ${clearedCount} hidden` : ""} + Completed ({count}){clearedCount ? ` · ${clearedCount} hidden` : ""} ); } @@ -380,8 +373,6 @@ function PaneScalableSection({ const earlierExpanded = paneUi.earlier[sectionKey] === true; const sectionAction = allClear ? ( Restore ({groups.clearedCount}) - ) : groups.earlier.length > 0 ? ( - onClear(groups.earlier.map(idOf))}>Clear ) : null; const renderRows = (items: T[], renderRow: (item: T) => ReactNode, animated: boolean) => { const rows = items.map((item) => animated ? ( @@ -425,7 +416,14 @@ function PaneScalableSection({ ) : null} {groups.earlier.length > 0 || groups.clearedCount > 0 ? (
- +
+ + {earlierExpanded && groups.earlier.length > 0 ? ( + + onClear(groups.earlier.map(idOf))}>Clear + + ) : null} +
{renderRows(groups.earlier, renderEarlierRow, animateEarlierRows)} @@ -936,7 +934,6 @@ export function ChatSubagentsPanel({ backgroundItems = [], schedulesPaused = false, onToggleSchedulesPaused, - onViewMainTranscript, }: { sessionId?: string | null; snapshots: ChatSubagentSnapshot[]; @@ -969,8 +966,6 @@ export function ChatSubagentsPanel({ schedulesPaused?: boolean; /** Pause or resume all durable schedules for this chat. */ onToggleSchedulesPaused?: () => void; - /** Opens the provider-fidelity transcript for the parent Claude session. */ - onViewMainTranscript?: () => void; }) { const [expanded, setExpanded] = useState(false); const [paneUi, setPaneUi] = useState(() => readPaneUiState(sessionId)); @@ -1372,7 +1367,7 @@ export function ChatSubagentsPanel({ {hasSubagents ? ( snap.taskId} renderActiveRow={renderSubagentPaneRow} renderEarlierRow={renderSubagentPaneRow} @@ -1388,7 +1383,7 @@ export function ChatSubagentsPanel({ {hasBackground ? ( item.id} renderActiveRow={(item) => } @@ -1404,7 +1399,7 @@ export function ChatSubagentsPanel({ {hasScheduled ? ( {onToggleSchedulesPaused ? ( -
- ) : null} -
); diff --git a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx index 8f2f44279..526871995 100644 --- a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx +++ b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ArrowDown, ArrowUp, CaretDown, CaretRight, CheckCircle, Circle, XCircle } from "@phosphor-icons/react"; +import { ArrowDown, ArrowUp, CaretDown, CaretRight, CheckCircle, Circle, Stop, XCircle } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; import { formatSubagentDurationMs } from "../../lib/format"; import { ChatSubagentGlyph, chatSubagentColor } from "./chatSubagentIdentity"; @@ -8,6 +8,7 @@ import type { BackgroundFinishChipRenderEvent, SubagentResultCardRenderEvent, SubagentSpawnAnchorRenderEvent, + SubagentStoppedGroupEvent, } from "./chatTranscriptRows"; // Two rows per real subagent — a spawn card anchored where it started, and a @@ -55,7 +56,12 @@ export function SubagentSpawnCard({ event.endedAt ? Math.max(0, Date.parse(event.endedAt) - Date.parse(event.startedAt)) : null, ); - const activity = event.statusLine?.trim() || event.lastToolName?.trim() || null; + // Suppress activity text that just echoes the task title (e.g. title + // "Run affected suites" + status "done · Run affected suites · 27s"). + const title = (event.description || "").trim(); + const rawActivity = event.statusLine?.trim() || event.lastToolName?.trim() || null; + const activity = + rawActivity && title && title.toLowerCase().includes(rawActivity.toLowerCase()) ? null : rawActivity; const statusWord = isRunning ? "running" : event.status === "completed" @@ -75,18 +81,20 @@ export function SubagentSpawnCard({ return (
-
- - +
+ + + +
-
- +
+ {event.description || "Subagent task"} {event.agentType?.trim() && event.agentType.trim() !== "background" ? ( @@ -105,18 +113,18 @@ export function SubagentSpawnCard({ {liveParts.join(" · ")}
) : null} - {!isRunning && onJumpToResult ? ( - - ) : null}
+ {!isRunning && onJumpToResult ? ( + + ) : null}
); @@ -153,9 +161,9 @@ export function SubagentResultCard({ const statusColor = isSuccess ? "text-fg/70" : "text-amber-100/85"; return ( -
-
- +
+
+ {isSuccess ? ( ) : isStopped ? ( @@ -183,47 +191,49 @@ export function SubagentResultCard({ {event.summaryPreview.trim()}
) : null} - {isFailed && event.error?.trim() ? ( +
+
+ {onViewTranscript ? ( ) : null} - {isFailed && detailsOpen && event.error?.trim() ? ( + {onJumpToStart ? ( + + ) : null} +
+
+ {isFailed && event.error?.trim() ? ( +
+ + {detailsOpen ? (
{event.error.trim()}
) : null} -
- {onViewTranscript ? ( - - ) : null} - {onJumpToStart ? ( - - ) : null} -
-
+ ) : null}
); } @@ -245,7 +255,7 @@ export function BackgroundFinishChip({ event }: { event: BackgroundFinishChipRen return (
); } + +/** + * One calm card standing in for a run of subagents that were all stopped by a + * single user interrupt — instead of a wall of identical "stopped — interrupted" + * result cards. Collapsed by default: a single amber line reading "N agents + * stopped when you interrupted" with a disclosure that lists each agent and a + * "jump to start" link (reusing the same row-key scroll machinery as the result + * cards). Never a red error block; inherits `--chat-accent` for the hover. + */ +export function SubagentStoppedGroupCard({ + event, + onJumpToStart, +}: { + event: SubagentStoppedGroupEvent; + onJumpToStart?: (rowKey: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const count = event.count; + const headline = `${count} ${count === 1 ? "agent" : "agents"} stopped when you interrupted`; + + return ( +
+ + {expanded ? ( +
    + {event.items.map((item) => ( +
  • + + {item.title} + + {onJumpToStart ? ( + + ) : null} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index f2b6a6c30..4203d2c28 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -2391,3 +2391,52 @@ describe("subagent two-row rendering", () => { expect(rows[1]?.event.type).toBe("user_message"); }); }); + +describe("interrupt-stopped subagent grouping", () => { + it("folds a run of 3 stopped-interrupted results into one group while a completed result stays individual", () => { + const grouped = groupEvents([ + env("2026-07-11T10:00:00.000Z", { type: "subagent_started", taskId: "agent-a", agentType: "explorer", description: "Explore auth flow" }), + env("2026-07-11T10:00:00.100Z", { type: "subagent_started", taskId: "agent-b", agentType: "explorer", description: "Explore sync flow" }), + env("2026-07-11T10:00:00.200Z", { type: "subagent_started", taskId: "agent-c", agentType: "explorer", description: "Explore the UI" }), + env("2026-07-11T10:00:00.300Z", { type: "subagent_started", taskId: "agent-d", agentType: "builder", description: "Build the widget" }), + // agent-d finishes for real; then the user interrupts and the rest are swept to "stopped". + env("2026-07-11T10:00:05.000Z", { type: "subagent_result", taskId: "agent-d", status: "completed", summary: "Widget built" }), + env("2026-07-11T10:00:06.000Z", { type: "subagent_result", taskId: "agent-a", status: "stopped", summary: "Interrupted", finalSummary: "Interrupted" }), + env("2026-07-11T10:00:06.001Z", { type: "subagent_result", taskId: "agent-b", status: "stopped", summary: "Interrupted", finalSummary: "Interrupted" }), + env("2026-07-11T10:00:06.002Z", { type: "subagent_result", taskId: "agent-c", status: "stopped", summary: "Interrupted", finalSummary: "Interrupted" }), + ]); + + // Exactly one folded group — never a wall of identical stopped cards. + const groups = grouped.filter((row) => row.event.type === "subagent_stopped_group"); + expect(groups).toHaveLength(1); + const group = groups[0]!; + if (group.event.type !== "subagent_stopped_group") throw new Error("Expected stopped group"); + expect(group.key).toBe("subagent-stopped-group:agent-a"); + expect(group.event.count).toBe(3); + expect(group.event.items).toEqual([ + { agentKey: "agent-a", title: "Explore auth flow", jumpToStartRowKey: "subagent-spawn:agent-a" }, + { agentKey: "agent-b", title: "Explore sync flow", jumpToStartRowKey: "subagent-spawn:agent-b" }, + { agentKey: "agent-c", title: "Explore the UI", jumpToStartRowKey: "subagent-spawn:agent-c" }, + ]); + + // The completed agent keeps its own result card (real summary the user wants to read). + const resultCards = grouped.filter((row) => row.event.type === "subagent_result_card"); + expect(resultCards).toHaveLength(1); + if (resultCards[0]!.event.type !== "subagent_result_card") throw new Error("Expected result card"); + expect(resultCards[0]!.event.status).toBe("completed"); + expect(resultCards[0]!.event.summaryPreview).toBe("Widget built"); + }); + + it("keeps a single lone stopped result as a normal result card (no group of one)", () => { + const grouped = groupEvents([ + env("2026-07-11T10:00:00.000Z", { type: "subagent_started", taskId: "agent-a", agentType: "explorer", description: "Explore auth flow" }), + env("2026-07-11T10:00:06.000Z", { type: "subagent_result", taskId: "agent-a", status: "stopped", summary: "Interrupted", finalSummary: "Interrupted" }), + ]); + + expect(grouped.some((row) => row.event.type === "subagent_stopped_group")).toBe(false); + const resultCards = grouped.filter((row) => row.event.type === "subagent_result_card"); + expect(resultCards).toHaveLength(1); + if (resultCards[0]!.event.type !== "subagent_result_card") throw new Error("Expected result card"); + expect(resultCards[0]!.event.status).toBe("stopped"); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index a623a242b..37f75b0b5 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -148,6 +148,8 @@ export type SubagentSpawnAnchorRenderEvent = { export type SubagentResultCardRenderEvent = { type: "subagent_result_card"; agentKey: string; + /** Task title, carried so the stopped-group card can label each folded agent. */ + description: string | null; status: SubagentCardTerminalStatus; summaryPreview: string | null; error: string | null; @@ -156,6 +158,27 @@ export type SubagentResultCardRenderEvent = { durationMs: number | null; }; +/** One folded agent inside a {@link SubagentStoppedGroupEvent}. */ +export type SubagentStoppedGroupItem = { + agentKey: string; + title: string; + /** Stable row key of this agent's spawn anchor (`subagent-spawn:${agentKey}`). */ + jumpToStartRowKey: string; +}; + +/** + * A run of 2+ consecutive interrupt-stopped subagent result cards, folded into + * one calm card so a mass interrupt (a dozen — or fifty — agents) renders as a + * single line instead of a wall of identical "stopped" cards. Produced by the + * second-layer grouping pass; never emitted by the first-layer collapse. + * Row key: `subagent-stopped-group:${firstAgentKey}`. + */ +export type SubagentStoppedGroupEvent = { + type: "subagent_stopped_group"; + count: number; + items: SubagentStoppedGroupItem[]; +}; + /** * Compact finish chip for a backgrounded shell command (no spawn/result cards). * Row key: `background-chip:${agentKey}`. @@ -199,7 +222,11 @@ export type ChatTranscriptRenderEnvelope = { export type ChatTranscriptGroupedEnvelope = { key: string; timestamp: string; - event: ChatTranscriptRenderEvent | ChatWorkLogGroupEvent | ChatActivityBundleEvent; + event: + | ChatTranscriptRenderEvent + | ChatWorkLogGroupEvent + | ChatActivityBundleEvent + | SubagentStoppedGroupEvent; }; type PlanTranscriptEvent = Extract; @@ -1047,6 +1074,7 @@ function handleSubagentLifecycleEvent( const resultEvent: SubagentResultCardRenderEvent = { type: "subagent_result_card", agentKey: state.renderKeyBase, + description: state.description, status: terminalStatus, summaryPreview: state.resultSummary, error: terminalStatus === "failed" ? state.error : null, @@ -1752,7 +1780,66 @@ export function collapseGroupedActivityPhaseRows( export function groupChatTranscriptRows( rows: ChatTranscriptRenderEnvelope[], ): ChatTranscriptGroupedEnvelope[] { - return collapseGroupedActivityPhaseRows(groupConsecutiveWorkLogRows(rows)); + return groupStoppedSubagentResultCards( + collapseGroupedActivityPhaseRows(groupConsecutiveWorkLogRows(rows)), + ); +} + +// A `stopped` terminal status is only ever emitted when the user interrupts a +// turn (see stopActiveClaudeSubagents — it settles every live subagent with +// status "stopped" + summary "Interrupted"). So a stopped result card is always +// an interrupt casualty carrying no summary the user needs to read individually. +function isInterruptStoppedResultCard( + event: ChatTranscriptGroupedEnvelope["event"], +): event is SubagentResultCardRenderEvent { + return event.type === "subagent_result_card" && event.status === "stopped"; +} + +// Fold a run of 2+ consecutive interrupt-stopped result cards into one compact +// `subagent_stopped_group` card. Completed/failed cards (which carry real +// summaries) and a lone stopped card stay individual. The group key is derived +// from the first agent so it stays stable across the virtualizer's re-renders. +function groupStoppedSubagentResultCards( + rows: ChatTranscriptGroupedEnvelope[], +): ChatTranscriptGroupedEnvelope[] { + const result: ChatTranscriptGroupedEnvelope[] = []; + let index = 0; + while (index < rows.length) { + const row = rows[index]!; + if (!isInterruptStoppedResultCard(row.event)) { + result.push(row); + index += 1; + continue; + } + + let end = index; + while (end < rows.length && isInterruptStoppedResultCard(rows[end]!.event)) end += 1; + const run = rows.slice(index, end); + index = end; + + if (run.length < 2) { + // A single lone stopped result stays a normal result card (no group of one). + result.push(run[0]!); + continue; + } + + const items: SubagentStoppedGroupItem[] = run.map((entry) => { + const event = entry.event as SubagentResultCardRenderEvent; + return { + agentKey: event.agentKey, + title: event.description?.trim() || "Subagent task", + jumpToStartRowKey: subagentSpawnKey(event.agentKey), + }; + }); + const firstAgentKey = (run[0]!.event as SubagentResultCardRenderEvent).agentKey; + const lastInRun = run[run.length - 1]!; + result.push({ + key: `subagent-stopped-group:${firstAgentKey}`, + timestamp: lastInRun.timestamp, + event: { type: "subagent_stopped_group", count: run.length, items }, + }); + } + return result; } // Collapse consecutive interrupted/failed status + done rows (parent turn + N subagents) diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index e7c55bf7a..1a530747f 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -1,4 +1,5 @@ import type { SyncChatEventPayload } from "../../../shared/types/sync"; +import type { AgentChatSteerResult } from "../../../shared/types/chat"; import type { AdapterInfra, AdeNamespace } from "./types"; import { requestDataUrl, requestFileBlob } from "./infra/fileBlob"; @@ -115,7 +116,10 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age ensureChatSubscription(stringField(asRecord(args), "sessionId")); }, steer: async (args: unknown) => { - await call("chat.steer", args, undefined, false); + return await call("chat.steer", args, { + steerId: globalThis.crypto.randomUUID(), + queued: true, + }, false); }, cancelSteer: async (args: unknown) => { await call("chat.cancelSteer", args, undefined, false); diff --git a/apps/desktop/src/shared/chatSubagents.test.ts b/apps/desktop/src/shared/chatSubagents.test.ts index 80a7ab5c2..ad1ccf12a 100644 --- a/apps/desktop/src/shared/chatSubagents.test.ts +++ b/apps/desktop/src/shared/chatSubagents.test.ts @@ -6,6 +6,7 @@ import { isEarlierSubagentSnapshot, deriveSubagentTimelineRows, isBackgroundShellCommand, + isNonAgentTaskRun, isRealSubagent, preferSubagentSummary, subagentAgentKey, @@ -103,6 +104,50 @@ describe("chatSubagents timeline helpers", () => { expect(isRealSubagent({ taskType: "local_workflow" })).toBe(true); }); + it("treats a local_bash run_in_background shell as a background shell, never a subagent", () => { + // The Claude Agent SDK tags Bash run_in_background with task_type + // "local_bash"; it must land in the background pane, not the roster. + expect(isBackgroundShellCommand({ taskType: "local_bash" })).toBe(true); + expect(isBackgroundShellCommand({ taskType: "local_bash", command: "codex exec …" })).toBe(true); + expect(isRealSubagent({ taskType: "local_bash" })).toBe(false); + // A local_bash task that somehow carried a real agent type is still not a + // background shell (agentType wins), staying available as a real subagent. + expect(isBackgroundShellCommand({ taskType: "local_bash", agentType: "Explore" })).toBe(false); + expect(isRealSubagent({ taskType: "local_bash", agentType: "Explore" })).toBe(true); + }); + + it("classifies a task_type 'other' run without agent metadata as a non-agent task run", () => { + // A plain Claude Code TaskCreate run (e.g. "Re-run affected test files") + // reports task_type "other" with no agent identity — it must never surface + // subagent rows. A bare task_started (no task_type) stays a subagent. + expect(isNonAgentTaskRun({ taskType: "other" })).toBe(true); + expect(isNonAgentTaskRun({ taskType: "other", agentType: "general-purpose" })).toBe(false); + expect(isNonAgentTaskRun({ taskType: "other", agentId: "a123" })).toBe(false); + expect(isNonAgentTaskRun({ taskType: "subagent" })).toBe(false); + expect(isNonAgentTaskRun({})).toBe(false); + }); + + it("keeps idle and foreground paths aligned by counting a stashed Task-tool input as agent metadata", () => { + // Regression: the idle-turn task_started handler used to omit the stashed + // check, so a Task subagent reported as task_type "other" with a stashed + // tool input was suppressed on idle turns but shown on foreground turns. + // Both paths now share this predicate — a stashed input means it is a real + // subagent, not a non-agent task run. + expect(isNonAgentTaskRun({ taskType: "other", hasStashedToolInput: true })).toBe(false); + expect(isNonAgentTaskRun({ taskType: "other", hasStashedToolInput: false })).toBe(true); + }); + + it("omits non-agent task runs (no agentType/agentId, non-subagent task type) from the timeline", () => { + // A plain Claude Code task run like "Re-run affected test files" carries no + // agent metadata and a non-subagent task type — it must not render as a + // spawn/result card. + const rows = deriveSubagentTimelineRows([ + { type: "subagent_started", taskId: "bwguvejv9", description: "Re-run affected test files" }, + { type: "subagent_result", taskId: "bwguvejv9", status: "completed", summary: "done" }, + ]); + expect(rows).toEqual([]); + }); + it("coalesces hook and task starts by alias while enriching the first spawn row", () => { const events: AgentChatEvent[] = [ { diff --git a/apps/desktop/src/shared/chatSubagents.ts b/apps/desktop/src/shared/chatSubagents.ts index 9225b8809..f042969e0 100644 --- a/apps/desktop/src/shared/chatSubagents.ts +++ b/apps/desktop/src/shared/chatSubagents.ts @@ -186,7 +186,12 @@ type SubagentClassificationInput = { export function isBackgroundShellCommand(input: SubagentClassificationInput): boolean { const taskType = textField(input.taskType); const agentType = textField(input.agentType); - return taskType === "background" && (!agentType || agentType === "background"); + // The Claude Agent SDK tags a `Bash` run_in_background shell with task_type + // "local_bash" (older builds said "background"). Either one, with no real + // subagent agentType, is a background shell — it belongs in the background + // pane, never the subagent roster. + return (taskType === "background" || taskType === "local_bash") + && (!agentType || agentType === "background"); } export function isRealSubagent(input: SubagentClassificationInput): boolean { @@ -200,6 +205,32 @@ export function isRealSubagent(input: SubagentClassificationInput): boolean { ); } +/** + * An explicit task_type "other" with no agent metadata (no agentType, no + * agentId, no stashed Task/Agent tool input) is a plain Claude Code task run — + * e.g. "Re-run affected test files" — not a subagent, so it must never surface + * subagent rows. Shared so the idle-turn and foreground task_started handlers + * classify identically; keeping this in one place is what stops the two paths + * from drifting apart. A bare task_started with no task_type stays a subagent + * for back-compat. + */ +export function isNonAgentTaskRun(input: { + taskType?: string | null; + agentType?: string | null; + agentId?: string | null; + hasStashedToolInput?: boolean; +}): boolean { + const taskType = textField(input.taskType); + const hasAgentMetadata = Boolean( + textField(input.agentType) + || textField(input.agentId) + || input.hasStashedToolInput + || taskType === "subagent" + || taskType === "local_workflow", + ); + return taskType === "other" && !hasAgentMetadata; +} + type SubagentTimelineStatus = "running" | "completed" | "stopped" | "failed"; type SubagentTimelineTerminalStatus = Exclude; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 37cb81370..b2a5b1579 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1910,6 +1910,7 @@ export type AgentChatSteerArgs = { export type AgentChatSteerResult = { steerId: string; queued: boolean; + reason?: "queue_full"; }; export type AgentChatMessageSessionKind = diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index d7844a80b..eae886840 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -97,6 +97,12 @@ func syncConnectionHealth( enum SyncChatMessageDelivery: Equatable { case sent case queued(steerId: String?) + /// The host accepted the request but dropped it without delivering. Today this + /// only happens when a steer arrives and the pending-steer queue is already + /// full (`reason == "queue_full"`). Callers should restore the composer and + /// prompt a resend rather than clearing it as if the message went through, + /// mirroring desktop's queue-full handling. + case dropped(reason: String?) } struct SavedChatTempAttachment: Decodable, Equatable { @@ -110,8 +116,16 @@ private struct PersonalChatImageData: Decodable { } func syncChatMessageDelivery(from response: Any) -> SyncChatMessageDelivery { - if let response = response as? [String: Any], response["queued"] as? Bool == true { - return .queued(steerId: response["steerId"] as? String) + if let response = response as? [String: Any] { + if response["queued"] as? Bool == true { + return .queued(steerId: response["steerId"] as? String) + } + // A full pending-steer queue makes the host drop the steer, answering + // `queued: false, reason: "queue_full"`. Surface it as `.dropped` so the + // caller can keep the user's text instead of treating it as delivered. + if response["reason"] as? String == "queue_full" { + return .dropped(reason: "queue_full") + } } return .sent } diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index c05a07566..3ff01f8c8 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2683,11 +2683,7 @@ struct WorkChatInfoDetailsSheet: View { } private func sectionHint(active: Int, earlier: Int, hidden: Int, running: Int, failed: Int) -> String { - let activeLabel = running > 0 && failed > 0 ? "\(running) running · \(failed) failed" : "\(active)" - return ([activeLabel] - + (earlier > 0 ? ["\(earlier) earlier"] : []) - + (hidden > 0 ? ["\(hidden) hidden"] : [])) - .joined(separator: " · ") + running > 0 && failed > 0 ? "\(running) running · \(failed) failed" : "\(active)" } var body: some View { @@ -2864,7 +2860,7 @@ struct WorkChatInfoDetailsSheet: View { Button("Restore (\(clearedCount))") { restore(key) } .font(.caption) .foregroundStyle(ADEColor.textMuted) - } else if !clearIds.isEmpty { + } else if !clearIds.isEmpty && paneFlag("earlier", section: key) { Button("Clear") { clear(key, ids: clearIds) } .font(.caption) .foregroundStyle(ADEColor.textMuted) @@ -2892,7 +2888,7 @@ struct WorkChatInfoDetailsSheet: View { withPaneAnimation { setPaneFlag("earlier", section: section, value: !expanded) } } label: { Label( - "Earlier (\(count))\(clearedCount > 0 ? " · \(clearedCount) hidden" : "")", + "Completed (\(count))\(clearedCount > 0 ? " · \(clearedCount) hidden" : "")", systemImage: expanded ? "chevron.down" : "chevron.right" ) } @@ -3438,6 +3434,101 @@ struct WorkSubagentTimelineRowView: View { } } +/// Folded card for a run of 2+ interrupt-stopped subagents — desktop parity with +/// `SubagentStoppedGroupCard`. A mass interrupt renders as one calm amber line, +/// "N agents stopped when you interrupted", that expands to a per-agent list; +/// tapping a row reopens that subagent's detail (the iOS analog of the desktop +/// "jump to start"). Never a red error block. +struct WorkSubagentStoppedGroupCardView: View { + let model: WorkSubagentStoppedGroupModel + /// Same opener the result rows use; nil in previews/offline renders leaves the + /// list inert (and hides the per-row open affordance). + let onOpen: (@MainActor (WorkSubagentSnapshot) async -> Void)? + + @State private var expanded = false + + private var headline: String { + "\(model.count) \(model.count == 1 ? "agent" : "agents") stopped when you interrupted" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + // No height animation — mirror the desktop card, which just toggles the + // list, and stay calm under Reduce Motion. + expanded.toggle() + } label: { + HStack(spacing: 10) { + Image(systemName: "stop.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.warning) + Text(headline) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(headline) + .accessibilityHint(expanded ? "Collapse list" : "Expand list") + + if expanded { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.rows) { row in + stoppedItem(row) + } + } + .padding(.top, 8) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .adeGlassCard(cornerRadius: 12, padding: 0) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.warning.opacity(0.16), lineWidth: 0.8) + ) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func stoppedItem(_ row: WorkSubagentTimelineRow) -> some View { + if let onOpen { + Button { + Task { await onOpen(row.snapshot) } + } label: { + stoppedItemLabel(row) + } + .buttonStyle(.plain) + } else { + stoppedItemLabel(row) + } + } + + private func stoppedItemLabel(_ row: WorkSubagentTimelineRow) -> some View { + HStack(spacing: 8) { + Text(workSubagentMeaningfulName(row.snapshot)) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Image(systemName: "arrow.up.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .opacity(onOpen == nil ? 0 : 1) + } + .padding(.vertical, 5) + .contentShape(Rectangle()) + } +} + private struct WorkSubagentSpawnRow: View { let row: WorkSubagentTimelineRow diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index b6593f3ba..209ae7b76 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -107,6 +107,8 @@ extension WorkChatSessionView { WorkFileChangeCardView(card: fileChangeCard) case .subagent(let row): WorkSubagentTimelineRowView(row: row, onOpen: onSelectSubagentRow) + case .subagentStoppedGroup(let model): + WorkSubagentStoppedGroupCardView(model: model, onOpen: onSelectSubagentRow) case .toolGroup(let group): timelineToolGroup(group) case .changedFiles(let group): diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 2b53e6d4f..3523f3d2a 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -382,6 +382,11 @@ enum WorkTimelinePayload: Equatable { /// spawn/result rows hard timeline boundaries that tool/activity folding /// cannot absorb. case subagent(WorkSubagentTimelineRow) + /// A run of 2+ consecutive interrupt-stopped subagent result rows, folded into + /// one calm "N agents stopped when you interrupted" card (desktop parity: + /// `subagent_stopped_group`). Keeps a mass interrupt from rendering as a wall + /// of identical stopped rows. + case subagentStoppedGroup(WorkSubagentStoppedGroupModel) /// Cluster of consecutive read-only tool-like entries (tool cards, /// commands) collapsed into a single header-only row. Tap to reveal the /// member list; tap a row to reveal its output. Matches the desktop @@ -613,6 +618,16 @@ struct WorkSubagentTimelineRow: Identifiable, Equatable { } } +/// Folded run of 2+ interrupt-stopped subagent result rows (desktop parity: +/// `SubagentStoppedGroupEvent`). Carries the original result rows so the card +/// can list each agent's title and reopen its detail on tap. +struct WorkSubagentStoppedGroupModel: Identifiable, Equatable { + let id: String + let rows: [WorkSubagentTimelineRow] + + var count: Int { rows.count } +} + struct WorkSubagentSelection: Identifiable, Equatable { let taskId: String let agentId: String? diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index b1e144ea6..746a41066 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -1161,7 +1161,10 @@ struct WorkNewChatScreen: View { switch delivery { case .queued: deliveryState = "queued" - case .sent: + case .sent, .dropped: + // `.dropped` (queue_full) is steer-only; a new chat's opener goes + // through sendChatMessage, so it is unreachable here. Fold it into the + // delivered path to keep the switch exhaustive. deliveryState = nil } await onStarted(summary, opener, true, deliveryState, attachmentRefs) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index e8efa7ee3..a63db7012 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -90,6 +90,15 @@ extension WorkSessionDestinationView { updateLocalEchoDeliveryState(echoId: echoId, deliveryState: nil) await refreshChatStateAfterAction(forceRemote: true) reconcileLocalEchoMessages() + case .dropped: + // The steer queue is full; the host dropped the message (and emitted its + // own transcript notice). Pull the optimistic echo so it doesn't linger + // as if delivered, and return false so the composer restores the text + // for a resend — matching desktop. + ADEHaptics.error() + localEchoMessages.removeAll { $0.id == echoId } + errorMessage = "Message not sent — the queue is full. Wait for the current turn to finish, then resend." + return false } errorMessage = nil return true diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 05df46af0..d52a457c1 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -1884,6 +1884,14 @@ struct WorkSessionDestinationView: View { updateLocalEchoDeliveryState(echoId: echo.id, deliveryState: nil) await refreshChatStateAfterAction(forceRemote: true) reconcileLocalEchoMessages() + case .dropped: + // Opening prompt steered into a full queue; the host dropped it. Remove + // the optimistic echo and surface the queue-full notice instead of + // leaving it as if delivered. + ADEHaptics.error() + localEchoMessages.removeAll { $0.id == echo.id } + errorMessage = "Message not sent — the queue is full. Wait for the current turn to finish, then resend." + return } errorMessage = nil } catch { diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index ee131b660..71babcf1f 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -516,12 +516,15 @@ func workChatIsStreaming( return sessionStatus == "active" } -/// Mirrors desktop `chatSubagents.ts` `isBackgroundShellCommand` exactly: -/// background task type plus an absent or literal background agent type. +/// Mirrors desktop `chatSubagents.ts` `isBackgroundShellCommand` exactly: a +/// background task type plus an absent or literal background agent type. The +/// Claude Agent SDK tags a `Bash` run_in_background shell with task_type +/// "local_bash" (older builds said "background"); either one belongs in the +/// background pane, never the subagent roster. func isBackgroundShellCommand(taskType: String?, agentType: String?) -> Bool { let normalizedTaskType = nonEmptyWorkTimelineText(taskType)?.lowercased() let normalizedAgentType = nonEmptyWorkTimelineText(agentType)?.lowercased() - return normalizedTaskType == "background" + return (normalizedTaskType == "background" || normalizedTaskType == "local_bash") && (normalizedAgentType == nil || normalizedAgentType == "background") } @@ -1484,11 +1487,63 @@ func buildWorkTimeline( deduped.append(entry) } } - return collapseActivityPhaseTimelineEntries( - collapseConsecutiveWorkActivityEntries(collapseConsecutiveWorkToolEntries(deduped)) + return collapseInterruptStoppedSubagentEntries( + collapseActivityPhaseTimelineEntries( + collapseConsecutiveWorkActivityEntries(collapseConsecutiveWorkToolEntries(deduped)) + ) ) } +/// Fold a run of 2+ consecutive interrupt-stopped subagent result rows into one +/// compact `.subagentStoppedGroup` entry — desktop parity with +/// `groupStoppedSubagentResultCards`. A `.stopped` result row is always an +/// interrupt casualty (the runtime settles every live subagent with status +/// `stopped` on cancel), carrying no summary worth reading on its own, so a mass +/// interrupt collapses to a single calm line instead of a wall of identical +/// stopped cards. A lone stopped row stays a normal result card. +func collapseInterruptStoppedSubagentEntries(_ entries: [WorkTimelineEntry]) -> [WorkTimelineEntry] { + var result: [WorkTimelineEntry] = [] + result.reserveCapacity(entries.count) + var index = 0 + while index < entries.count { + guard isInterruptStoppedSubagentResultEntry(entries[index]) else { + result.append(entries[index]) + index += 1 + continue + } + var end = index + while end < entries.count && isInterruptStoppedSubagentResultEntry(entries[end]) { + end += 1 + } + let run = Array(entries[index..= 2 else { + result.append(run[0]) + continue + } + let rows: [WorkSubagentTimelineRow] = run.compactMap { member in + if case .subagent(let row) = member.payload { return row } + return nil + } + let firstKey = rows.first.map { $0.snapshot.agentId ?? $0.snapshot.taskId } ?? run[0].id + let model = WorkSubagentStoppedGroupModel(id: "subagent-stopped-group-\(firstKey)", rows: rows) + result.append(WorkTimelineEntry( + id: model.id, + timestamp: run[run.count - 1].timestamp, + rank: run[0].rank, + payload: .subagentStoppedGroup(model) + )) + } + return result +} + +private func isInterruptStoppedSubagentResultEntry(_ entry: WorkTimelineEntry) -> Bool { + if case .subagent(let row) = entry.payload { + return row.kind == .result && row.snapshot.status == .stopped + } + return false +} + /// Fold tool-like timeline entries (tool cards, commands, file changes) into /// a single `.toolGroup` entry so the iOS chat mirrors the desktop /// `work_log_group` behavior — one summary row per cluster instead of N diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9559a38f9..f5ba1867f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -8961,6 +8961,10 @@ final class ADETests: XCTestCase { func testSyncChatMessageDeliveryParsesQueuedSteerResult() { XCTAssertEqual(syncChatMessageDelivery(from: ["ok": true, "steerId": "steer-1", "queued": true]), .queued(steerId: "steer-1")) XCTAssertEqual(syncChatMessageDelivery(from: ["ok": true, "steerId": "steer-1", "queued": false]), .sent) + XCTAssertEqual( + syncChatMessageDelivery(from: ["ok": true, "steerId": "steer-1", "queued": false, "reason": "queue_full"]), + .dropped(reason: "queue_full") + ) XCTAssertEqual(syncChatMessageDelivery(from: NSNull()), .sent) } @@ -17971,3 +17975,97 @@ final class LinearPaneTests: XCTestCase { XCTAssertGreaterThan(path.boundingRect.height, 18) } } + +/// Parity coverage for the iOS mirror of the desktop `groupStoppedSubagentResultCards` +/// fold: a mass interrupt collapses a run of 2+ consecutive stopped result rows +/// into one `.subagentStoppedGroup`, while lone stops and non-stopped rows stay +/// individual and break runs. +final class WorkSubagentStoppedGroupFoldTests: XCTestCase { + private func resultEntry( + _ id: String, + _ title: String, + status: WorkSubagentSnapshot.Status, + rank: Int + ) -> WorkTimelineEntry { + let snapshot = WorkSubagentSnapshot( + taskId: id, + agentId: id, + agentType: nil, + parentToolUseId: nil, + description: title, + background: false, + label: nil, + model: nil, + reasoningEffort: nil, + status: status, + lastToolName: nil, + latestSummary: nil, + turnId: nil, + startedAt: nil, + updatedAt: nil + ) + let row = WorkSubagentTimelineRow( + kind: .result, + snapshot: snapshot, + timestamp: "2026-07-11T00:00:0\(rank)Z", + summary: nil, + commandLabel: nil, + exitLabel: nil + ) + return WorkTimelineEntry(id: row.id, timestamp: row.timestamp, rank: rank, payload: .subagent(row)) + } + + private func stopped(_ id: String, _ title: String, rank: Int) -> WorkTimelineEntry { + resultEntry(id, title, status: .stopped, rank: rank) + } + + private func isGroup(_ entry: WorkTimelineEntry) -> Bool { + if case .subagentStoppedGroup = entry.payload { return true } + return false + } + + func testFoldsRunOfStoppedResultsIntoOneGroup() { + let folded = collapseInterruptStoppedSubagentEntries([ + stopped("a", "Alpha", rank: 0), + stopped("b", "Bravo", rank: 1), + stopped("c", "Charlie", rank: 2), + ]) + XCTAssertEqual(folded.count, 1) + guard case .subagentStoppedGroup(let model) = folded[0].payload else { + return XCTFail("expected a stopped group") + } + XCTAssertEqual(model.count, 3) + XCTAssertEqual(model.rows.map { $0.snapshot.description }, ["Alpha", "Bravo", "Charlie"]) + // Group key derives from the first agent so it stays stable as the run grows. + XCTAssertEqual(folded[0].id, "subagent-stopped-group-a") + } + + func testLoneStoppedResultStaysIndividual() { + let folded = collapseInterruptStoppedSubagentEntries([ + resultEntry("a", "Alpha", status: .succeeded, rank: 0), + stopped("b", "Bravo", rank: 1), + resultEntry("c", "Charlie", status: .succeeded, rank: 2), + ]) + XCTAssertEqual(folded.count, 3) + XCTAssertFalse(folded.contains(where: isGroup)) + } + + func testNonStoppedRowBreaksRunIntoSeparateGroups() { + let folded = collapseInterruptStoppedSubagentEntries([ + stopped("a", "Alpha", rank: 0), + stopped("b", "Bravo", rank: 1), + resultEntry("x", "Interloper", status: .succeeded, rank: 2), + stopped("c", "Charlie", rank: 3), + stopped("d", "Delta", rank: 4), + ]) + // group(a,b) · succeeded(x) · group(c,d) + XCTAssertEqual(folded.count, 3) + XCTAssertFalse(isGroup(folded[1])) + guard case .subagentStoppedGroup(let first) = folded[0].payload, + case .subagentStoppedGroup(let last) = folded[2].payload else { + return XCTFail("expected two stopped groups around the boundary") + } + XCTAssertEqual(first.rows.map { $0.snapshot.description }, ["Alpha", "Bravo"]) + XCTAssertEqual(last.rows.map { $0.snapshot.description }, ["Charlie", "Delta"]) + } +} diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index be51ac97e..9a1164141 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -21,7 +21,7 @@ for its separate RPC, sync, storage, and UI contracts. |---|---| | `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, transport disclosure, route-pinned final send, and recoverable source-marker completion. 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. | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). 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 call `stopTask` for active subagents before emitting stopped subagent results. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. 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 runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). 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. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and a Claude `interrupt-replace` acknowledges only after the SDK accepts the replacement turn (re-queuing the message if the interrupt fails). Large service file. | | `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable scheduler for Claude `ScheduleWakeup`, `CronCreate`, and `/loop`. Persists versioned schedule records and per-chat pause state in the project SQLite `kv` store, restores and re-arms them on service start, coalesces overdue work to one late fire, advances recurring cron work to its next normal occurrence, cancels schedules whose session is missing or archived, and reports transitions back to `agentChatService`. Uses injected time/timer/persistence adapters so restart, pause, collision, and catch-up behavior can be tested without Electron. | | `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex thread-turn history into ADE `AgentChatEventEnvelope` rows. It reads at most the last 32 MB of source transcript bytes, keeps the newest 2,000 imported content events, emits system notices for provenance/truncation, drops metadata-only/provider-wrapper user rows without stripping user-authored JSX/XML, preserves failed Claude tool-result status, maps user/assistant text plus tool calls/results/file changes/commands/search/image events where available, and derives a fallback imported-chat title from the first user or assistant text. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | @@ -63,7 +63,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts` | Resolves the OpenCode CLI: PATH first, then the bundled `node_modules/.bin/opencode`. Cache entries are re-validated with `canRunBinaryCandidate` on every lookup so user installs after launch are picked up; missing-binary lookups are intentionally not cached. `clearOpenCodeBinaryCache()` is wired into the AI integration's full cache reset. | | `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize retired Sonnet 4.6 / basic Opus 4.7 ids to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | -| `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `normalizeSubagentLifecycleEvent` (canonicalizes legacy `subagent_*` and dotted `subagent.*` envelopes), the stable `groupPaneSectionItems` partition and pane caps, `buildSubagentPaneRows`, tagged pane click targets, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. The partition keeps source order, forces pinned rows into the active cap, and excludes visually cleared Earlier ids. It also owns the shared subagent-vs-background classification (`isBackgroundShellCommand`, `isRealSubagent`, `subagentAgentKey`), summary-quality helpers, and `deriveSubagentTimelineRows` → `SubagentTimelineRow` (`spawn` / `result` / `background_chip`). Desktop consumes the partition directly; ADE Code consumes the expanded row model; iOS mirrors the same predicates and caps. | +| `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `normalizeSubagentLifecycleEvent` (canonicalizes legacy `subagent_*` and dotted `subagent.*` envelopes), the stable `groupPaneSectionItems` partition and pane caps, `buildSubagentPaneRows`, tagged pane click targets, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. The partition keeps source order, forces pinned rows into the active cap, and excludes visually cleared Completed ids. It also owns the shared subagent-vs-background classification (`isBackgroundShellCommand`, `isRealSubagent`, `isNonAgentTaskRun`, `subagentAgentKey`) — `isNonAgentTaskRun` flags a `task_type` `other` run with no agent metadata (a plain Claude Code task, not a subagent) so both the idle-turn and foreground paths keep it out of the roster, and `isBackgroundShellCommand` now also matches the SDK's `local_bash` task type — summary-quality helpers, and `deriveSubagentTimelineRows` → `SubagentTimelineRow` (`spawn` / `result` / `background_chip`). Desktop consumes the partition directly; ADE Code consumes the expanded row model; iOS mirrors the same predicates and caps. | | `apps/desktop/src/shared/chatScheduledWork.ts` | Cross-target scheduled-work derivation. Folds `scheduled_work_update` envelopes into stable snapshots for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work, then partitions them by surface: `deriveScheduleItems` returns the schedule kinds (`wakeup` / `cron` / `loop` / `remote_trigger`) while `deriveBackgroundItems` returns `background_task` rows. `isEarlierBackgroundItem`, `isFiredOneShotWakeup`, and `isEarlierScheduleItem` define the shared Earlier membership mirrored by ADE Code and iOS; the older active/history helpers remain available to existing callers. Also owns next-fire labels and readable background command labels/cwds. | | `apps/desktop/src/main/services/chat/claudeWorkflowProgress.ts` | Defensive normalizer for the Claude Agent SDK's undocumented `workflow_progress` snapshot on `system:task_progress` (Workflow orchestration runs). Parses phases + per-agent entries (caps counts, clips previews, drops malformed entries, unknown states degrade to queued/running; unparseable snapshots return undefined so the generic task rendering is untouched), then `planClaudeWorkflowAgentTransitions` diffs each cumulative tick against per-task emit state to fan out `subagent_started/progress/result` events under a stable `::a` identity with the emitted agentId latched at first emission. Consumed by `agentChatService`'s `task_progress`/`task_notification` handlers and the interrupt path (which close still-running agents as `stopped`). | | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | @@ -77,14 +77,14 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout 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` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, status labels (`preparing-summary` -> `creating-chat` -> `sending-handoff`), search matching, and the stable placeholder id used by the Work session sidebar. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (background shell commands collapse to a single `BackgroundFinishChip`), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | -| `apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx` | Inline subagent transcript cards mounted by `AgentChatMessageList` from the render events `chatTranscriptRows.ts` derives. `SubagentSpawnCard` anchors where the agent started (identicon/colour from `chatSubagentIdentity`, task title, agent-type/background chips, a single live `running · · tools · ` line that ticks each second, and a `jump to result` link once the agent ends); `SubagentResultCard` renders at the settle position (status + duration, ~2-line report preview, View transcript, `jump to start`, warm amber tones for stopped/failed instead of red error blocks); `BackgroundFinishChip` is the one-line finish chip for backgrounded shell commands. All inherit `--chat-accent`. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (background shell commands collapse to a single `BackgroundFinishChip`), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | +| `apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx` | Inline subagent transcript cards mounted by `AgentChatMessageList` from the render events `chatTranscriptRows.ts` derives. `SubagentSpawnCard` anchors where the agent started (identicon/colour from `chatSubagentIdentity`, task title, agent-type/background chips, a single live `running · · tools · ` line that ticks each second, and a `jump to result` link once the agent ends); `SubagentResultCard` renders at the settle position (status + duration, ~2-line report preview, View transcript, `jump to start`, warm amber tones for stopped/failed instead of red error blocks); `BackgroundFinishChip` is the one-line finish chip for backgrounded shell commands; `SubagentStoppedGroupCard` collapses a run of interrupt-stopped subagents into one amber "N agents stopped when you interrupted" line that expands to a per-agent list with `jump to start` links. All inherit `--chat-accent`. | | `apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Codex Chat Actions source inventory. Sources is the first available tab and derives a deduplicated list of attachments/files, web searches/results, MCP apps/tools, and external resource URLs from the current transcript. HTTP(S) rows open in ADE's built-in browser; internal `node_repl` plumbing and unsafe protocols are excluded. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens or toggles that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). When the chat PR pane or compact PR menu opens, it asks `prReadCache.refreshLinkedPrCoalesced` for a targeted `prs.refresh({ prIds })` so the badge picks up merged/closed/check transitions without broad GitHub polling. | | `apps/desktop/src/renderer/components/chat/ChatPrPane.tsx` | Left floating PR pane for Work chat. Renders cached lane PR details immediately, then performs the same cooldown-bound targeted PR refresh as the toolbar before settling the state. Terminal PRs hide stale running-check labels so merged/closed PRs do not keep showing in-progress CI from an old cache row. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, and attachment context into prompt text. | | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Chat file-rewind confirmation surface. Claude uses the SDK `rewindFiles` control call; Codex uses app-server `thread/rollback` plus ADE's git-backed file restore plan. `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the provider rewind without using browser-native confirm UI. | -| `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `chatSubagentIdentity.tsx`, `codex/CodexGoalCard.tsx` | Chat Info drawer content: Codex goal card, capped/collapsible plan and task sections, and capped Subagents/Background/Schedule rosters. Terminal work moves into one Earlier disclosure without reordering survivors; failed and pinned rows stay active; Clear hides only terminal Earlier rows and Restore reverses it. Per-session collapse/Earlier/cleared state persists in normalized renderer storage while Show all remains mount-local. The pane variant owns one scroll container with sticky opaque section headers. Schedule pause/play remains beside Clear, recurring rows show last-run plus next-fire timing, and fired one-shot wakeups keep their dim history-row treatment inside Earlier. ADE Code and iOS mirror the grouping and cap behavior. `chatSubagentIdentity.tsx` centralizes deterministic subagent identity, and the Codex goal card stays above the roster. | +| `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `chatSubagentIdentity.tsx`, `codex/CodexGoalCard.tsx` | Chat Info drawer content: Codex goal card, capped/collapsible plan and task sections, and capped Subagents/Background/Schedule rosters. Terminal work moves into one **Completed** disclosure without reordering survivors; failed and pinned rows stay active; Clear (shown beside the toggle only while Completed is expanded) hides only terminal Completed rows and Restore reverses it. Per-session collapse/Completed/cleared state persists in normalized renderer storage while Show all remains mount-local. The pane variant owns one scroll container with sticky opaque section headers. Schedule pause/play remains in the Schedule section header (Clear now sits beside the Completed toggle when the fold is expanded), recurring rows show last-run plus next-fire timing, and fired one-shot wakeups keep their dim history-row treatment inside Completed. ADE Code and iOS mirror the grouping and cap behavior. `chatSubagentIdentity.tsx` centralizes deterministic subagent identity, and the Codex goal card stays above the roster. | | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Because native `WebContentsView` content sits above the renderer, the panel hides it while ADE overlays, dialogs, menus, or popovers overlap the browser surface so ADE chrome remains reachable. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | | `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. The `WorkSurfaceTitle` sub-component plays a one-time CSS shimmer when the title transitions from a provider default (`Claude Chat`, `Codex Chat`, …) to a real auto-generated title while the surface stays mounted, and respects `prefers-reduced-motion`. `AgentChatPane` also reuses `ClaudeLoginPromptButton` as a sticky bar above the composer (keyed `composer-auth:`) while a Claude session is logged out, but only when the chat header pill is absent so the two never double up. | | `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx` | Inline install / re-login card for missing or unauthenticated agent CLIs, rendered in the transcript from a decorated `error` event's `errorInfo.agentCli` payload. Copy chips + a tracked-PTY Run button (`window.ade.pty.create`) for the install / auth command. The logged-out (`category: "unauthenticated"`) variant is terracotta-toned for Claude (amber for other agents), retitles to "<Provider> is logged out", and adds an always-on **Retry turn** button that resends the last user message via the `CHAT_RETRY_AUTH_TURN_EVENT` (`ade:chat:retry-auth-turn`) window event; it collapses to a "Reconnected" confirmation when `AgentChatPane` fires `CHAT_AUTH_RECOVERED_EVENT` (`ade:chat:auth-recovered`) after a later turn succeeds. The "missing CLI" variant keeps the red-free amber install card. | @@ -100,7 +100,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx` | Shell that wraps every chat surface (desktop pane, mobile lane, CTO chat) with a unified header/footer slot and `--chat-accent` CSS variable. Supports a `layoutVariant="mobile"` mode that the iOS companion mirrors. | | `apps/desktop/src/renderer/components/chat/chatSurfaceTheme.ts` | Chat chrome tokens. Exports `PROVIDER_CHAT_ACCENTS` (claude → amber, codex → warm white, cursor → violet, opencode → blue, etc.) and `providerChatAccent(provider)`. iOS mirrors this table in `ADEDesignSystem.swift`. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` → `InlineQuestionRequestCard` | Inline question / structured-question card rendered in the transcript (there is no longer a separate `AgentQuestionModal`). Header is the provider logo + a kind-derived verb (`{Provider} asks` / `{Provider} · Plan ready` via `pendingInputHeaderLabel`); body shows the question's `header` kicker then the question text once (no generic title); options render with radio/checkbox a11y roles; option previews render through `QuestionOptionPreview` — a column-preserving monospace `
` for wireframes/ASCII (detected via `looksLikeWireframe`) and the code-fence-aware `ChatMarkdown` for prose. Card chrome inherits `--chat-accent` (per-provider). Keyboard: digits toggle options, ↑↓ move highlight, ←→ page, Enter advances/sends; recommended option auto-focuses; ≥2 previews enable an A/B compare toggle. |
-| `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. It threads per-subagent anchor state through the collapse pass to emit identity-keyed `subagent_spawn_anchor` / `subagent_result_card` / `background_finish_chip` render events (keys `subagent-spawn:` / `subagent-result:` / `background-chip:`), mutating anchors in place as progress/result events arrive and repairing row positions when a `transcript_retraction` splices a row out — so the virtualizer's measured heights survive rebind. It derives a `scheduled_wake_divider` render event immediately before every synthetic `user_message` carrying `metadata.scheduledWake`, with a stable `scheduled-wake::` key for digest jumps. It also diffs `todo_update` snapshots per turn so only changed tasks render, normalizes dotted `subagent.*` lifecycle events into the legacy renderer shape while providers migrate, and falls back to a full collapse when incremental append would miss todo state. |
+| `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` | Two-layer event-to-row pipeline (render events + grouped envelopes) that powers the message list. It threads per-subagent anchor state through the collapse pass to emit identity-keyed `subagent_spawn_anchor` / `subagent_result_card` / `background_finish_chip` render events (keys `subagent-spawn:` / `subagent-result:` / `background-chip:`), mutating anchors in place as progress/result events arrive and repairing row positions when a `transcript_retraction` splices a row out — so the virtualizer's measured heights survive rebind. It derives a `scheduled_wake_divider` render event immediately before every synthetic `user_message` carrying `metadata.scheduledWake`, with a stable `scheduled-wake::` key for digest jumps. It also diffs `todo_update` snapshots per turn so only changed tasks render, normalizes dotted `subagent.*` lifecycle events into the legacy renderer shape while providers migrate, and falls back to a full collapse when incremental append would miss todo state. A second-layer grouping pass (`groupStoppedSubagentResultCards`) folds a run of two or more consecutive interrupt-stopped `subagent_result_card` rows into one `subagent_stopped_group` event; completed/failed cards and a lone stopped card stay individual. |
 | `apps/desktop/src/main/services/ai/tools/` | Tool tiers consumed by the service when it provisions a Claude/Codex/OpenCode runtime (see [Tool System](tool-system.md)). |
 | `apps/desktop/src/main/services/ipc/registerIpc.ts` | Validates chat IPC args, exposes `agentChat.*` handlers (including per-chat scheduled-work pause), persists/retrieves parallel launch recovery state in `kv`, and refreshes the runtime scheduler after the global AI config pause changes. |
 | `apps/desktop/src/shared/ipc.ts` | `ade.agentChat.*` IPC channel constants. |
@@ -207,8 +207,9 @@ Controls and summaries project this runtime state rather than owning it:
   after a runtime restart. SDK-origin and cold-start wake paths both produce
   synthetic turns plus `scheduled_work_update` snapshots for desktop, ADE
   Code, and iOS Chat Info. SessionStore reads are limited to resume-time
-  envelope self-heal and an explicit provider-fidelity transcript takeover;
-  ADE envelopes remain the normal render backend. Deliberately not wired yet:
+  envelope self-heal and the on-demand provider-fidelity `getMainTranscript`
+  IPC (no longer wired to a desktop control — see the gotcha below); ADE
+  envelopes remain the normal render backend. Deliberately not wired yet:
   SDK `SessionStore` as ADE's transcript backend and channels/external message
   origins.
 - **Provider-agnostic sessions.** `AgentChatProvider` is one of `claude`,
@@ -256,7 +257,17 @@ Controls and summaries project this runtime state rather than owning it:
   picks it up at the next thinking step) or **send & interrupt**
   (abort the current turn and run the queued message as the next
   turn). Inline dispatches are reversible until the model reads them
-  via `cancelAsyncMessage(uuid)`. The pending-steer queue is persisted
+  via `cancelAsyncMessage(uuid)`. Every `steer()` call returns
+  `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); a
+  queue already at its `MAX_PENDING_STEERS` cap comes back
+  `queued: false, reason: "queue_full"` rather than silently dropping, the
+  queued message's reasoning effort is normalized and applied at delivery
+  (not at enqueue), and a Claude **interrupt-replace** (send & interrupt)
+  acknowledges only after the SDK accepts the replacement turn — re-queuing
+  the message if the provider interrupt fails. In the desktop composer these
+  map to a split mid-turn Send button (primary click / Enter = send now;
+  caret menu = Queue for after turn / Interrupt & replace). The pending-steer
+  queue is persisted
   with chat state so undelivered messages survive restart. `sendMessage`
   accepts an opt-in `routeActiveToSteer` flag (overloaded so the steered
   path can return an `AgentChatSteerResult`): when set, a non-empty send
@@ -663,7 +674,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`.
 | `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 first message; `mode: "fork"` keeps provider-native history when source and target are both Claude or both Codex. `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`. 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. 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; queued when appropriate. |
+| `ade.agentChat.steer` | invoke | Send a follow-up message mid-turn; queued when appropriate. 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. |
 | `ade.agentChat.dispatchSteer` | invoke | Claude-only: deliver a queued steer immediately as `mode: "inline"` (folded into the active turn via SDK `shouldQuery: false` send) or `mode: "interrupt"` (interrupt the active turn so the steer runs next). Throws on Codex/OpenCode/Cursor. |
 | `ade.agentChat.cancelDispatchedSteer` | invoke | Claude-only: rescinds an inline-dispatched message before the model reads it (calls SDK `cancelAsyncMessage(uuid)`). No-op if the message has already been consumed. |
@@ -770,14 +781,15 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`.
   byte-identical no-op, repaired files are written via temp-file rename with a
   one-time `.splice.bak`, and files over 64 MB are skipped. Cache invalidation
   plus a transient `session_meta_updated` event makes an open renderer refetch.
-- **The full session transcript is an alternate Claude-only view.**
+- **The full session transcript is an on-demand Claude-only IPC.**
   `ade.agentChat.getMainTranscript` resolves an ADE chat session to its live,
   persisted, or mirrored Claude SDK session id, requests system messages, and
-  applies the same 4 MB response budget as subagent transcripts. Desktop and
-  ADE Code expand that response through the existing subagent transcript
-  conversion path. The takeover is on demand and explicitly omits ADE-only
-  events such as approvals, schedules, and notices; it never replaces ADE's
-  persisted envelope backend.
+  applies the same 4 MB response budget as subagent transcripts, expanding the
+  response through the subagent-transcript conversion path. It explicitly omits
+  ADE-only events such as approvals, schedules, and notices and never replaces
+  ADE's persisted envelope backend. The Chat Info drawer no longer surfaces a
+  "View full session transcript" control (removed with the drawer redesign);
+  the IPC/action remains for programmatic callers.
 - **Claude tags are lightweight session metadata.** The first tag stored in the
   mirrored Claude session pointer populates optional `claudeTag` on
   `AgentChatSessionSummary`. `updateSession({ tag })` writes or clears the SDK
diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md
index 68770c393..32338748d 100644
--- a/docs/features/chat/composer-and-ui.md
+++ b/docs/features/chat/composer-and-ui.md
@@ -33,7 +33,7 @@ subagents, computer use). The pane derives all visible state from the
 | `apps/desktop/src/shared/chatScheduledWork.ts` | Pure scheduled-work derivation. Folds `scheduled_work_update` envelopes into Chat Info schedule rows for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work; defines the shared Background/Schedule Earlier predicates (including fired one-shot wakeups); and formats next-fire labels. Shared by desktop, ADE Code, and mirrored by iOS. |
 | `ChatFileChangesPanel.tsx` | Turn-level file change summary with lazy diff expansion. |
 | `RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Undo confirmation for provider-backed file rewind. Builds a message-scoped file list from provider dry-run output plus turn diff summaries, then renders per-file expandable diffs before applying `rewindFiles`. Claude uses SDK file checkpoints; Codex uses `thread/rollback` for the latest user message and restores files through ADE's git plan. |
-| `ChatSubagentsPanel.tsx` | Chat Info panel. It renders the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters. Large sections cap active rows and add Show all; terminal rows move into one Earlier fold; Clear/Restore is a visual per-session filter. Failed and pinned rows remain active, survivors keep source order, and the pane variant owns a single scroller with sticky section headers. The Schedule header keeps the per-chat pause/play action beside Clear. For Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header. |
+| `ChatSubagentsPanel.tsx` | Chat Info panel. It renders the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters. Large sections cap active rows and add Show all; terminal rows move into one Completed fold; Clear/Restore is a visual per-session filter. Failed and pinned rows remain active, survivors keep source order, and the pane variant owns a single scroller with sticky section headers. The Schedule header keeps the per-chat pause/play action. For Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header. |
 | `ChatComputerUsePanel.tsx` | Computer-use backend status. |
 | `ChatAppControlPanel.tsx` | App Control panel for Electron apps. Two mount points: under the chat composer (chat-scoped, `sessionId` set) and inside the Work right-edge sidebar (lane-scoped, `sessionId={null}`). Two modes: **Control** (live screencast frames + launch/connect form + click/type input + quick `terminal write` / `terminal signal` actions) and **Inspect** (hit-test crosshair on the screenshot; commits selections as `AppControlContextItem`s with screenshot, DOM packet, and source-file candidates). Persists panel state under `sessionStorage["ade.chat.appControlPanel."]`, where the key is `chat:` for the chat mount and `lane::` for the sidebar mount. Connect/launch calls forward `laneId` so the resulting `AppControlSession` records its launching lane. See [App Control](../computer-use/app-control.md). |
 | `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. See [iOS Simulator feature](../ios-simulator/README.md). |
@@ -354,6 +354,16 @@ and a footer that contains the composer.
   turn so the queued message runs as the next turn. 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
+  composer's primary send control is a split button
+  (`ActiveTurnSendButton`, Claude Code parity): the primary click — and
+  Enter — **Send now**, submitting the draft as a steer and immediately
+  inline-dispatching the exact `steerId` that `steer()` returned into the
+  running turn; the caret menu offers **Queue for after turn** and
+  **Interrupt & replace**. All controls carry force-enabled tooltips so
+  hover always explains the action, and the button disables on an
+  empty/whitespace-only draft. Providers without inline-steer dispatch
+  (Codex, OpenCode, Cursor) keep the single queue-on-send affordance.
 - **Question answering.** When a question-type pending input is active,
   the user answers (or declines) it through the inline question card.
   Multi-select questions render a toggle list plus a preview pane
@@ -570,12 +580,13 @@ overdue work fires once. Cron rows show `last ran