diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e160bdb27..9a7afee7f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4610,6 +4610,62 @@ describe("createAgentChatService", () => { }); }); + // ADE-122 regression: seeding a fork re-published every historical envelope + // to live event subscribers, streaming the entire source chat over IPC/sync + // and stalling the app during the handoff. Seeded history must be durable + // and readable, but never live-published. + it("seeds forked history into storage without re-publishing it as live events", async () => { + installRealTranscriptParser(); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const source = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + }); + source.threadId = "source-thread-live-publish"; + const sourceEnvelopes: AgentChatEventEnvelope[] = Array.from({ length: 4 }, (_, index) => ({ + sessionId: source.id, + timestamp: `2026-07-10T11:0${index}:00.000Z`, + event: index % 2 === 0 + ? { type: "user_message", messageId: `seed-user-${index}`, text: `Seed user message ${index}.` } + : { type: "text", messageId: `seed-assistant-${index}`, text: `Seed assistant reply ${index}.` }, + provenance: { messageId: `provider-message-${index}` }, + })); + writeTestTranscriptEnvelopes(source.id, sourceEnvelopes); + mockState.codexResponseOverrides.set("thread/fork", { thread: { id: "forked-thread-live-publish" } }); + + const result = await service.handoffSession({ + sourceSessionId: source.id, + targetModelId: "openai/gpt-5.5", + mode: "fork", + }); + + const publishedSeeded = events.filter((envelope) => + envelope.sessionId === result.session.id + && (envelope.provenance as { providerOrigin?: string } | undefined)?.providerOrigin === "handoff_fork"); + expect(publishedSeeded).toHaveLength(0); + + const history = service.getChatEventHistory(result.session.id, { maxEvents: 50 }); + const seededHistory = history.events.filter((envelope) => + (envelope.provenance as { providerOrigin?: string } | undefined)?.providerOrigin === "handoff_fork"); + expect(seededHistory).toHaveLength(sourceEnvelopes.length); + + const targetTranscript = path.join(tmpRoot, ".ade", "transcripts", "chat", `${result.session.id}.jsonl`); + await vi.waitFor(() => { + const parsed = fs.readFileSync(targetTranscript, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as AgentChatEventEnvelope); + const seededLines = parsed.filter((envelope) => + (envelope.provenance as { providerOrigin?: string } | undefined)?.providerOrigin === "handoff_fork"); + expect(seededLines).toHaveLength(sourceEnvelopes.length); + }); + }); + it("forks an OpenCode chat from the source session without injecting a summary prompt", async () => { vi.mocked(streamText).mockReturnValue({ fullStream: (async function* () { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8e007ef84..d800aede0 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -10934,24 +10934,29 @@ export function createAgentChatService(args: { } }; - const writeTranscript = (managed: ManagedChatSession, envelope: AgentChatEventEnvelope): void => { + const writeTranscript = ( + managed: ManagedChatSession, + envelope: AgentChatEventEnvelope, + options?: { deferFlush?: boolean }, + ): void => { // The legacy transcript_path is byte-capped because it also backs // terminal-session storage. The dedicated chat transcript is the durable // replay source and must keep receiving events after that cap is reached. - writeChatTranscriptLine(managed.session.id, envelope); + writeChatTranscriptLine(managed.session.id, envelope, options); if (managed.transcriptLimitReached) return; try { fs.mkdirSync(path.dirname(managed.transcriptPath), { recursive: true }); const warnTailHealed = (details: { path: string; priorSize: number }) => { logger.warn("agent_chat.transcript_tail_healed", details); }; + const flushOnUserMessage = envelope.event.type === "user_message" && !options?.deferFlush; const rawLine = `${JSON.stringify(envelope)}\n`; const chunk = Buffer.from(rawLine, "utf8"); const remaining = MAX_CHAT_TRANSCRIPT_BYTES - managed.transcriptBytesWritten; if (remaining <= 0) { managed.transcriptLimitReached = true; queueTranscriptWrite(managed.transcriptPath, CHAT_TRANSCRIPT_LIMIT_NOTICE, warnTailHealed); - if (envelope.event.type === "user_message") flushQueuedTranscriptWrite(managed.transcriptPath); + if (flushOnUserMessage) flushQueuedTranscriptWrite(managed.transcriptPath); return; } let toWrite = chunk; @@ -10964,20 +10969,26 @@ export function createAgentChatService(args: { if (managed.transcriptLimitReached) { queueTranscriptWrite(managed.transcriptPath, CHAT_TRANSCRIPT_LIMIT_NOTICE, warnTailHealed); } - if (envelope.event.type === "user_message") flushQueuedTranscriptWrite(managed.transcriptPath); + if (flushOnUserMessage) flushQueuedTranscriptWrite(managed.transcriptPath); } catch { // ignore transcript write failures } }; - const writeChatTranscriptLine = (sessionId: string, envelope: AgentChatEventEnvelope): void => { + const writeChatTranscriptLine = ( + sessionId: string, + envelope: AgentChatEventEnvelope, + options?: { deferFlush?: boolean }, + ): void => { try { const transcriptFile = path.join(chatTranscriptsDir, `${sessionId}.jsonl`); const line = `${JSON.stringify(envelope)}\n`; queueTranscriptWrite(transcriptFile, line, (details) => { logger.warn("agent_chat.transcript_tail_healed", details); }); - if (envelope.event.type === "user_message") flushQueuedTranscriptWrite(transcriptFile); + if (envelope.event.type === "user_message" && !options?.deferFlush) { + flushQueuedTranscriptWrite(transcriptFile); + } } catch { // ignore chat transcript write failures } @@ -12982,50 +12993,76 @@ export function createAgentChatService(args: { } }; - const appendImportedChatEvents = ( + // Seeding a fork or import can carry thousands of historical envelopes; + // chunked processing with event-loop yields keeps the shared runtime + // responsive while they persist (ADE-122: fork handoff froze the app). + const IMPORTED_CHAT_EVENT_CHUNK_SIZE = 250; + + const appendImportedChatEvents = async ( managed: ManagedChatSession, envelopes: AgentChatEventEnvelope[], - ): void => { + ): Promise => { flushBufferedReasoning(managed); flushBufferedText(managed); - for (const envelope of envelopes) { - const liveEvent = envelope.event; - const storedEvent = compactChatEventForStorage(liveEvent); - trackSubagentEvent(managed, liveEvent); - appendRecentConversationEntry(managed, liveEvent); - - if (storedEvent.type === "text") { - updatePreviewFromText(managed, storedEvent); - } else if (storedEvent.type === "command") { - setSessionPreview(managed, storedEvent.output); - } else if (storedEvent.type === "error") { - setSessionPreview(managed, storedEvent.message); - } else if (storedEvent.type === "completion_report") { - managed.session.completion = storedEvent.report; - if (storedEvent.report.summary.trim().length > 0) { - setSessionPreview(managed, storedEvent.report.summary); - } - } - - const timestamp = envelope.timestamp || nowIso(); - const sequence = ++managed.eventSequence; - const storedEnvelope: AgentChatEventEnvelope = { - ...envelope, - sessionId: managed.session.id, - timestamp, - event: storedEvent, - sequence, - }; - const liveEnvelope: AgentChatEventEnvelope = liveEvent === storedEvent - ? storedEnvelope - : { - ...storedEnvelope, - event: liveEvent, - }; - writeTranscript(managed, storedEnvelope); - recordChatEventInHistory(storedEnvelope); - publishChatEnvelope(liveEnvelope); + // Imported envelopes are historical: no client can be viewing a session + // whose history is still being seeded, and every reader loads seeded + // events through the history/transcript APIs. They are therefore written + // and recorded but NOT published to live event subscribers — publishing + // re-streams the entire source chat over IPC/sync for nothing. + const storedEnvelopes: AgentChatEventEnvelope[] = []; + const chatTranscriptFile = path.join(chatTranscriptsDir, `${managed.session.id}.jsonl`); + for (let start = 0; start < envelopes.length; start += IMPORTED_CHAT_EVENT_CHUNK_SIZE) { + const chunk = envelopes.slice(start, start + IMPORTED_CHAT_EVENT_CHUNK_SIZE); + for (const envelope of chunk) { + const liveEvent = envelope.event; + const storedEvent = compactChatEventForStorage(liveEvent); + trackSubagentEvent(managed, liveEvent); + appendRecentConversationEntry(managed, liveEvent); + + if (storedEvent.type === "text") { + updatePreviewFromText(managed, storedEvent); + } else if (storedEvent.type === "command") { + setSessionPreview(managed, storedEvent.output); + } else if (storedEvent.type === "error") { + setSessionPreview(managed, storedEvent.message); + } else if (storedEvent.type === "completion_report") { + managed.session.completion = storedEvent.report; + if (storedEvent.report.summary.trim().length > 0) { + setSessionPreview(managed, storedEvent.report.summary); + } + } + + const timestamp = envelope.timestamp || nowIso(); + const sequence = ++managed.eventSequence; + const storedEnvelope: AgentChatEventEnvelope = { + ...envelope, + sessionId: managed.session.id, + timestamp, + event: storedEvent, + sequence, + }; + writeTranscript(managed, storedEnvelope, { deferFlush: true }); + storedEnvelopes.push(storedEnvelope); + } + // One append syscall per file per chunk (instead of one forced flush per + // seeded user message) bounds queued-write memory without the sync-write + // storm that stalled the runtime. + flushQueuedTranscriptWrite(chatTranscriptFile); + flushQueuedTranscriptWrite(managed.transcriptPath); + if (start + IMPORTED_CHAT_EVENT_CHUNK_SIZE < envelopes.length) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + + if (storedEnvelopes.length) { + // Single bulk ring update; the per-envelope recordChatEventInHistory + // re-bounds the whole buffer each call (O(n²) across a large import). + const current = eventHistoryBySession.get(managed.session.id) ?? []; + eventHistoryBySession.set( + managed.session.id, + boundRingEnvelopes([...current, ...storedEnvelopes]), + ); } managed.session.lastActivityAt = nowIso(); @@ -26892,7 +26929,7 @@ export function createAgentChatService(args: { sourceSessionId: sourceId, }, })); - if (sourceEnvelopes.length) appendImportedChatEvents(createdManaged, sourceEnvelopes); + if (sourceEnvelopes.length) await appendImportedChatEvents(createdManaged, sourceEnvelopes); } if (handoffMode === "brief" || handoffNote) { @@ -28149,7 +28186,7 @@ export function createAgentChatService(args: { managed: destinationManaged, }); const importedEnvelopes = parseCrossMachineTranscriptEnvelopes(capsule, session.id); - if (importedEnvelopes.length) appendImportedChatEvents(destinationManaged, importedEnvelopes); + if (importedEnvelopes.length) await appendImportedChatEvents(destinationManaged, importedEnvelopes); persistChatState(destinationManaged); record = { ...record, forkMaterializedAt: nowIso(), updatedAt: nowIso() }; persistCrossMachineHandoffRecord(record); @@ -28507,7 +28544,7 @@ export function createAgentChatService(args: { }); applyImportedChatMetadata(managed, args, events, importedAt); persistChatState(managed); - appendImportedChatEvents(managed, events); + await appendImportedChatEvents(managed, events); persistChatState(managed); return persistedImportedChatResult(managed, "claude"); } catch (error) { @@ -28656,7 +28693,7 @@ export function createAgentChatService(args: { }); applyImportedChatMetadata(managed, args, events, importedAt); persistChatState(managed); - appendImportedChatEvents(managed, events); + await appendImportedChatEvents(managed, events); persistChatState(managed); return persistedImportedChatResult(managed, "codex"); } catch (error) { diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 3d1fc56ef..3caeab9ab 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -89,6 +89,28 @@ describe("ipcInvokeTimeoutMs", () => { }])).toBe(2 * 60_000); }); + // ADE-122 regression: a handoff (AI brief + session creation + first-message + // dispatch, or cross-machine history packaging) got the 30s default on the + // direct-IPC and remote-runtime paths, fired a false timeout, and then + // completed anyway as a "surprise" session about a minute later. + it("extends handoff timeouts on direct, local runtime, and remote runtime paths", () => { + expect(ipcInvokeTimeoutMs(IPC.agentChatHandoff)).toBe(150_000); + expect(ipcInvokeTimeoutMs(IPC.agentChatPrepareCrossMachineHandoff)).toBe(150_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "chat", action: "handoffSession", args: {} }, + }])).toBe(150_000); + expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ + id: "target-1", + projectId: "project-1", + request: { domain: "chat", action: "prepareCrossMachineHandoff", args: {} }, + }])).toBe(150_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "chat", action: "handoffSession", args: {} }, + }])).toBe(150_000); + }); + it("extends lane creation timeouts on direct and local runtime paths", () => { expect(ipcInvokeTimeoutMs(IPC.lanesCreate)).toBe(4 * 60_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index 61e8bda04..7e7588389 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -18,6 +18,10 @@ const RUNTIME_ACTION_CHANNEL: Record> = { ensurePreviewWorkspace: IPC.iosSimulatorEnsurePreviewWorkspace, renderCurrentPreview: IPC.iosSimulatorRenderCurrentPreview, }, + chat: { + handoffSession: IPC.agentChatHandoff, + prepareCrossMachineHandoff: IPC.agentChatPrepareCrossMachineHandoff, + }, }; const LOCAL_RUNTIME_PROJECT_SETUP_TIMEOUT_MS = 150_000; @@ -82,6 +86,13 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ case IPC.lanesImportBranch: case IPC.lanesDelete: return 4 * 60_000; + // Handoff runs an AI brief + session creation + first-message dispatch + // (and cross-machine prepare additionally packages provider history); + // it must outlive the daemon-side 120s action timeout so the false + // "timed out but later succeeded" failure can't reappear via this layer. + case IPC.agentChatHandoff: + case IPC.agentChatPrepareCrossMachineHandoff: + return 150_000; case IPC.iosSimulatorLaunch: return 10 * 60_000; case IPC.transcriptionTranscribe: diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index f4e0fae00..036473257 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -2098,6 +2098,72 @@ describe("local runtime connection pool", () => { ); }); + // ADE-122 regression: the 30s default action timeout fired a false failure on + // brief handoffs (AI brief + session creation + first-message dispatch) while + // the daemon-side handoff kept running to a late "surprise" success. + it("extends handoff actions beyond the default chat action timeout", async () => { + const call = vi.fn().mockResolvedValue({ + domain: "chat", + action: "handoffSession", + result: {}, + statusHints: {}, + }); + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, isClosed: vi.fn(() => false) }, + child: null, + socketPath: "/tmp/ade.sock", + }); + + await pool.callActionForRoot(rootPath, { + domain: "chat", + action: "handoffSession", + args: { sourceSessionId: "chat-1", targetModelId: "openai/gpt-5.5", mode: "brief" }, + }); + await pool.callActionForRoot(rootPath, { + domain: "chat", + action: "prepareCrossMachineHandoff", + args: { sourceSessionId: "chat-1" }, + }); + + expect(call).toHaveBeenNthCalledWith( + 1, + "ade/actions/call", + expect.objectContaining({ + arguments: expect.objectContaining({ + domain: "chat", + action: "handoffSession", + }), + }), + { timeoutMs: 120_000 }, + ); + expect(call).toHaveBeenNthCalledWith( + 2, + "ade/actions/call", + expect.objectContaining({ + arguments: expect.objectContaining({ + domain: "chat", + action: "prepareCrossMachineHandoff", + }), + }), + { timeoutMs: 120_000 }, + ); + }); + it("bounds mutations and propagates their timeout without closing or replaying the client", async () => { const timeout = new Error("Remote ADE service timed out waiting for method ade/actions/call (30000ms)."); const call = vi.fn().mockRejectedValue(timeout); diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index cfa0e66fd..ac94daf46 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -79,6 +79,12 @@ const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; const LONG_RUNNING_LOCAL_RUNTIME_ACTION_TIMEOUTS: ReadonlyMap = new Map([ ["chat.suggestLaneNameFromPrompt", 120_000], + // Handoff = AI brief generation (bounded at 45s) + session creation + + // provider dispatch of the first message; the 30s default fired a false + // timeout while the daemon-side handoff kept running to a late "surprise" + // success (ADE-122). + ["chat.handoffSession", 120_000], + ["chat.prepareCrossMachineHandoff", 120_000], ]); const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; const LOCAL_RUNTIME_OUTPUT_LINE_MAX_CHARS = 4_000; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 189a82711..bfa4d645d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -197,7 +197,6 @@ import { buildHandoffLaunchJobsScopeKey, createHandoffLaunchJobId, type HandoffLaunchJob, - type HandoffLaunchJobStatus, } from "../../lib/handoffLaunchJobs"; import { createAppControlContextInstanceId, @@ -8305,14 +8304,13 @@ export function AgentChatPane({ const launchDraftCliSession = useCallback((mode: DraftLaunchMode) => launchDraftSession("cli", mode), [launchDraftSession]); const handoffSession = useCallback(async (mode: "brief" | "fork" = "brief") => { - if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; + if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked || handoffBusy) return; const sourceLaneId = selectedSession?.laneId ?? laneId; if (!sourceLaneId) return; const jobId = createHandoffLaunchJobId(); - const stageTimerIds: number[] = []; - const patchHandoffJob = (status: HandoffLaunchJobStatus) => { + const patchHandoffJob = (patch: Partial) => { setHandoffLaunchJobs((current) => current.map((job) => ( - job.id === jobId ? { ...job, status } : job + job.id === jobId ? { ...job, ...patch } : job ))); }; const targetModelLabel = handoffTargetDescriptor?.displayName @@ -8326,7 +8324,10 @@ export function AgentChatPane({ targetModelId: handoffModelId, targetModelLabel, targetToolType: handoffTargetProvider ? chatToolTypeForProvider(handoffTargetProvider) : "other", - status: "preparing-summary", + // One honest per-mode label for the whole operation; the renderer + // cannot observe runtime-side stage transitions, and fake timed stage + // hops misreported where a slow handoff actually was. + status: mode === "fork" ? "forking-history" : "preparing-summary", createdAtMs: Date.now(), }, ...current.filter((job) => job.sourceSessionId !== selectedSessionId), @@ -8334,8 +8335,6 @@ export function AgentChatPane({ setError(null); setHandoffBusy(true); setChatActionsOpen(false); - stageTimerIds.push(window.setTimeout(() => patchHandoffJob("creating-chat"), 700)); - stageTimerIds.push(window.setTimeout(() => patchHandoffJob("sending-handoff"), 1500)); try { const resolvedHandoffPermissionMode = handoffNativePermissionMode ?? selectedSession?.permissionMode; const trimmedHandoffNote = handoffNote.trim(); @@ -8349,9 +8348,16 @@ export function AgentChatPane({ const laneName = createDeterministicAutoLaneName(seed, { genericSuffix: autoLaneGenericSuffix() }); const createdLane = await window.ade.lanes.create({ name: laneName }); resolvedTargetLaneId = createdLane.id; + patchHandoffJob({ laneId: createdLane.id, laneName }); await refreshLanesStore().catch(() => {}); } else if (handoffTargetLaneId && handoffTargetLaneId !== sourceLaneId) { resolvedTargetLaneId = handoffTargetLaneId; + // Re-home the sidebar placeholder to the lane the new chat will + // actually appear in. + patchHandoffJob({ + laneId: handoffTargetLaneId, + laneName: availableLanes?.find((lane) => lane.id === handoffTargetLaneId)?.name ?? handoffTargetLaneId, + }); } } const result = await window.ade.agentChat.handoff({ @@ -8379,23 +8385,44 @@ export function AgentChatPane({ invalidateCurrentChatSessionList(); void refreshSessions({ force: true }).catch(() => {}); } catch (handoffError) { - const message = handoffError instanceof Error ? handoffError.message : String(handoffError); + const rawMessage = handoffError instanceof Error ? handoffError.message : String(handoffError); + // A transport timeout abandons the RPC but does not cancel the + // daemon-side handoff, which usually still completes. Say so instead of + // reporting a hard failure, and re-poll the session list so a late + // success surfaces as the expected new chat, not a surprise. Match ONLY + // the two transport wrappers (registerIpc invoke timeout, RuntimeRpcClient + // request timeout) — daemon-internal errors also say "timed out after Nms" + // but those are real failures that must surface as errors. + const isTransportTimeout = /IPC handler for .+ timed out after \d+ms|Remote ADE service timed out waiting for method/i.test(rawMessage); + const message = isTransportTimeout + ? "The handoff is taking longer than expected. ADE is still finishing it in the background — if it completes, the new chat will appear in the session list." + : rawMessage; setError(message); + if (isTransportTimeout) { + for (const delayMs of [20_000, 60_000, 120_000]) { + window.setTimeout(() => { + if (!paneMountedRef.current) return; + invalidateCurrentChatSessionList(); + void refreshSessions({ force: true }).catch(() => {}); + }, delayMs); + } + } if (handoffErrorClearTimerRef.current != null) { window.clearTimeout(handoffErrorClearTimerRef.current); } handoffErrorClearTimerRef.current = window.setTimeout(() => { handoffErrorClearTimerRef.current = null; setError((current) => (current === message ? null : current)); - }, 6000); + }, isTransportTimeout ? 12000 : 6000); } finally { - stageTimerIds.forEach((timerId) => window.clearTimeout(timerId)); setHandoffLaunchJobs((current) => current.filter((job) => job.id !== jobId)); setHandoffBusy(false); } }, [ + availableLanes, canShowHandoff, handoffBlocked, + handoffBusy, handoffClaudePermissionMode, handoffCodexApprovalPolicy, handoffCodexConfigSource, diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index 4fe6c2495..2de35d4e7 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -155,7 +155,7 @@ describe("SessionListPane", () => { targetModelId: "openai/gpt-5.4-mini", targetModelLabel: "GPT-5.4-Mini", targetToolType: "codex-chat", - status: "creating-chat", + status: "preparing-summary", createdAtMs: Date.now(), }, ], @@ -163,10 +163,44 @@ describe("SessionListPane", () => { expect(screen.getByTestId("handoff-launch-placeholder")).toBeTruthy(); expect(screen.getByText("Handoff to GPT-5.4-Mini")).toBeTruthy(); - expect(screen.getByText("Creating chat...")).toBeTruthy(); + expect(screen.getByText("Summarizing chat & creating handoff...")).toBeTruthy(); expect(screen.getByText("First message: Chat handoff from previous session")).toBeTruthy(); }); + // ADE-122 regression: while a handoff RPC was in flight, the placeholder and + // the real created session were both visible ("two new sessions", one + // vanishing later). Once a matching real row exists, the placeholder must go. + it("hides a handoff placeholder once the matching real session row is visible", () => { + const jobCreatedAtMs = Date.parse("2026-07-17T12:00:00.000Z"); + const materialized = makeSession({ + id: "session-handoff-target", + laneId: "lane-known", + laneName: "Known Lane", + toolType: "codex-chat", + startedAt: "2026-07-17T12:00:05.000Z", + }); + renderPane({ + runningFiltered: [materialized], + allSessionsUnfiltered: [materialized], + sessionsGroupedByLane: new Map([[materialized.laneId, [materialized]]]), + handoffJobs: [ + { + id: "handoff-job-2", + sourceSessionId: "source-session", + laneId: "lane-known", + laneName: "Known Lane", + targetModelId: "openai/gpt-5.4-mini", + targetModelLabel: "GPT-5.4-Mini", + targetToolType: "codex-chat", + status: "preparing-summary", + createdAtMs: jobCreatedAtMs, + }, + ], + }); + + expect(screen.queryByTestId("handoff-launch-placeholder")).toBeNull(); + }); + it("lets the user set the group organization from the filter panel", () => { const setSessionListOrganization = vi.fn(); const view = renderPane({ setSessionListOrganization }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 88bf57165..3fff926ba 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -24,6 +24,7 @@ import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { relativeTimeCompact } from "../../lib/format"; import { getLaneDeleteStatusLabel } from "../../lib/laneDeleteProgress"; import { + handoffJobLikelyMaterialized, handoffLaunchMatchesQuery, handoffLaunchStatusMessage, handoffLaunchTitle, @@ -450,11 +451,17 @@ export const SessionListPane = React.memo(function SessionListPane({ const [filterOpen, setFilterOpen] = useState(false); const filteredHandoffJobs = useMemo(() => { const filtered = handoffJobs.filter((job) => { + // Once the real session this job is creating is visible in the list, the + // placeholder must go — otherwise a handoff briefly reads as two new + // sessions with one vanishing when the RPC settles (ADE-122). + if (allSessionsUnfiltered.some((session) => handoffJobLikelyMaterialized(job, session))) { + return false; + } if (laneFilterActive && job.laneId !== normalizedFilterLaneId) return false; return handoffLaunchMatchesQuery(job, q); }); return filtered.sort((a, b) => b.createdAtMs - a.createdAtMs); - }, [handoffJobs, laneFilterActive, normalizedFilterLaneId, q]); + }, [allSessionsUnfiltered, handoffJobs, laneFilterActive, normalizedFilterLaneId, q]); const hasAnySessions = runningFiltered.length + awaitingInputFiltered.length + endedFiltered.length + filteredHandoffJobs.length > 0; diff --git a/apps/desktop/src/renderer/lib/handoffLaunchJobs.test.ts b/apps/desktop/src/renderer/lib/handoffLaunchJobs.test.ts new file mode 100644 index 000000000..03d57f0c7 --- /dev/null +++ b/apps/desktop/src/renderer/lib/handoffLaunchJobs.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + handoffJobLikelyMaterialized, + handoffLaunchStatusMessage, + type HandoffLaunchJob, +} from "./handoffLaunchJobs"; + +const baseJob: HandoffLaunchJob = { + id: "job-1", + sourceSessionId: "chat-1", + laneId: "lane-1", + laneName: "Lane one", + targetModelId: "openai/gpt-5.5", + targetModelLabel: "GPT-5.5", + targetToolType: "codex-chat", + status: "preparing-summary", + createdAtMs: Date.parse("2026-07-17T12:00:00.000Z"), +}; + +// ADE-122 regression: while a handoff RPC was still in flight, the sidebar +// showed both the placeholder and the real created session — "two new +// sessions" with one vanishing when the RPC settled. The placeholder must be +// treated as materialized once a matching real session row is visible. +describe("handoffJobLikelyMaterialized", () => { + it("matches a same-lane same-tool session started after the job began", () => { + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-1", + toolType: "codex-chat", + startedAt: "2026-07-17T12:00:05.000Z", + })).toBe(true); + }); + + it("absorbs small clock drift between renderer and runtime", () => { + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-1", + toolType: "codex-chat", + startedAt: "2026-07-17T11:59:50.000Z", + })).toBe(true); + }); + + it("ignores sessions from before the handoff started", () => { + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-1", + toolType: "codex-chat", + startedAt: "2026-07-17T11:58:00.000Z", + })).toBe(false); + }); + + it("ignores sessions in other lanes or with other tool types", () => { + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-2", + toolType: "codex-chat", + startedAt: "2026-07-17T12:00:05.000Z", + })).toBe(false); + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-1", + toolType: "claude-chat", + startedAt: "2026-07-17T12:00:05.000Z", + })).toBe(false); + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-1", + toolType: null, + startedAt: "2026-07-17T12:00:05.000Z", + })).toBe(false); + }); + + it("rejects unparseable start times", () => { + expect(handoffJobLikelyMaterialized(baseJob, { + laneId: "lane-1", + toolType: "codex-chat", + startedAt: "not-a-date", + })).toBe(false); + }); +}); + +describe("handoffLaunchStatusMessage", () => { + it("labels the brief and fork statuses", () => { + expect(handoffLaunchStatusMessage("preparing-summary")).toBe("Summarizing chat & creating handoff..."); + expect(handoffLaunchStatusMessage("forking-history")).toBe("Forking chat history..."); + }); +}); diff --git a/apps/desktop/src/renderer/lib/handoffLaunchJobs.ts b/apps/desktop/src/renderer/lib/handoffLaunchJobs.ts index 218d54a2b..2e7f12731 100644 --- a/apps/desktop/src/renderer/lib/handoffLaunchJobs.ts +++ b/apps/desktop/src/renderer/lib/handoffLaunchJobs.ts @@ -1,6 +1,6 @@ import type { OpenProjectBinding, TerminalToolType } from "../../shared/types"; -export type HandoffLaunchJobStatus = "preparing-summary" | "creating-chat" | "sending-handoff"; +export type HandoffLaunchJobStatus = "preparing-summary" | "forking-history"; export type HandoffLaunchJob = { id: string; @@ -35,9 +35,27 @@ export function buildHandoffLaunchJobsScopeKey(args: { } export function handoffLaunchStatusMessage(status: HandoffLaunchJobStatus): string { - if (status === "preparing-summary") return "Preparing summary..."; - if (status === "creating-chat") return "Creating chat..."; - return "Sending handoff..."; + if (status === "forking-history") return "Forking chat history..."; + return "Summarizing chat & creating handoff..."; +} + +/** + * True when a real session row plausibly IS the chat this in-flight handoff job + * is creating: same lane, same tool type, and started at/after the job began + * (small slack absorbs renderer-vs-runtime clock drift). The sidebar hides the + * placeholder as soon as such a row is visible so a handoff never reads as two + * new sessions with one vanishing (ADE-122). A same-provider chat launched + * concurrently in the same lane can hide the placeholder a moment early, which + * is a harmless cosmetic trade. + */ +export function handoffJobLikelyMaterialized( + job: HandoffLaunchJob, + session: { laneId: string; toolType: string | null; startedAt: string }, +): boolean { + if (session.laneId !== job.laneId) return false; + if (!session.toolType || session.toolType !== job.targetToolType) return false; + const startedAtMs = Date.parse(session.startedAt); + return Number.isFinite(startedAtMs) && startedAtMs >= job.createdAtMs - 15_000; } export function handoffLaunchTitle(job: HandoffLaunchJob): string { diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 840b41bec..634772f5c 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -22,7 +22,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, brief or full-history fork 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. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines yet. A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | | `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`) + `providerSupportsHandoffFork()`, `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` only when present. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. 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 an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. 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 an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. Large service file. | | `apps/desktop/src/main/services/chat/providerResumeClassifier.ts` | Classifies Codex resume failures without conflating missing threads with MCP/provider-environment or transient transport failures; rollout-file evidence keeps a locally known thread from being declared missing. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | Renders the explicit continuity-recovery choices from a `system_notice`: retry the preserved thread, reconstruct from durable ADE history, or start a separate chat. | | `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for provider-neutral ADE action schedules plus Claude `ScheduleWakeup`, every successful `CronCreate`, and `/loop`. ADE's mirror is the delivery source of truth; Claude's native scheduler is an advisory latency path. The scheduler gives native Claude fire 90 seconds to claim a due record before ADE's timer backstops it. Every managed chat schedule that becomes due during an active Claude, Codex, Cursor, Droid, or OpenCode turn stays armed and retries in 20-second steps instead of entering that turn's disposable input queue; tracked CLI rows use the same defer loop until `ptyService` confirms a provider-specific composer boundary. Expiry remains authoritative during retries. Native no-id cron claims are limited to due CronCreate-owned rows, so an ambiguous provider event cannot consume a `ScheduleWakeup` or loop. The scheduler persists versioned records, optional provider ids, expiry/terminal timestamps, 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; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | @@ -80,7 +80,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/chatSessionEvents.ts` | Renderer-local chat-session lifecycle helpers. `announceWorkChatSessionCreated` invalidates both Work session-list caches and publishes the durable session; Work and Lanes subscribers seed an optimistic row before their background refresh. `shouldRefreshSessionListForChatEvent` separately gates list refreshes for streamed chat events. | | `apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts` | Short-lived renderer cache for `ade.agentChat.slashCommands`, keyed by project root plus session id or lane/provider. System notices can force-refresh the selected session's commands. | | `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/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, mode-aware status labels (`preparing-summary` for brief, `forking-history` for fork), search matching, the stable placeholder id used by the Work session sidebar, and `handoffJobLikelyMaterialized` — the ADE-122 dedupe that hides a placeholder as soon as a matching real session row (same lane + tool type, started at/after the job began) is visible, so an in-flight handoff never reads as two new sessions with one vanishing. | | `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; 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. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. 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`. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 5f63812a9..c12aea82e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -216,9 +216,13 @@ and a footer that contains the composer. parallel-slot model fell off the allowlist — main and slot setters also no-op on out-of-list ids instead of silently bouncing. Handoffs create a root-store `HandoffLaunchJob` before the IPC call - starts, advance it through summary/chat/send labels while the old - surface closes, and remove it once the new chat is created or the - handoff fails. When the source provider is fork-capable + starts, label it by mode (`preparing-summary` for a brief, + `forking-history` for a fork) while the old surface closes, and remove + it once the new chat is created or the handoff fails — or hide it + earlier as soon as a matching real session row appears in the sidebar + (`handoffJobLikelyMaterialized`), so an in-flight handoff never reads + as two sessions with one vanishing (ADE-122). When the source provider + is fork-capable (`providerSupportsHandoffFork`: Claude, Codex, OpenCode, or Droid) the local handoff surface exposes both **Brief** and **Fork** modes, defaulting to fork; Cursor is brief-only. Fork keeps the new chat in the