diff --git a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx index 32960f97b..0d1055f3d 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx @@ -134,15 +134,32 @@ function chat(overrides: Partial = {}): AgentChatSessio }; } -function event(sessionId: string, sequence: number, type: string): AgentChatEventEnvelope { +function event( + sessionId: string, + sequence: number, + type: string, + overrides: Partial = {}, +): AgentChatEventEnvelope { return { sessionId, sequence, timestamp: `2026-01-01T00:00:0${sequence}.000Z`, event: { type } as AgentChatEventEnvelope["event"], + ...overrides, }; } +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + async function flushAsyncEffects() { await act(async () => { for (let i = 0; i < 12; i++) { @@ -314,6 +331,52 @@ describe("AdeCodeApp polling", () => { await unmountApp(instance); }); + it("keeps live output flushed while selected-chat history is in flight", async () => { + mocks.listLanes.mockResolvedValue([lane({ worktreeAvailable: true })]); + const history = deferred<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }>(); + mocks.getChatHistory.mockReturnValue(history.promise); + const instance = await renderApp( + , + ); + + expect(mocks.getChatHistory).toHaveBeenCalledWith(connection, "chat-1"); + + const delayedLive = event("chat-1", 2, "text", { + event: { type: "text", text: "delayed output survives hydration" }, + }); + await act(async () => { + [...chatListeners][0]?.(delayedLive); + await vi.advanceTimersByTimeAsync(40); + }); + await flushAsyncEffects(); + + history.resolve({ + sessionId: "chat-1", + events: [ + event("chat-1", 1, "user_message", { + event: { + type: "user_message", + text: "start", + turnId: "turn-1", + messageId: "message-1", + }, + }), + event("chat-1", 3, "done", { + event: { type: "done", turnId: "turn-1", status: "completed" }, + }), + ], + truncated: false, + }); + await flushAsyncEffects(); + + expect(stripAnsi(instance.lastFrame() ?? "")).toContain("delayed output survives hydration"); + await unmountApp(instance); + }); + it("starts remote project launches from remote context instead of local saved lane state", async () => { mocks.listLanes.mockResolvedValue([ lane({ diff --git a/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts b/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts index 64bbb57da..af270d59f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import { advanceOlderHistoryCursor, + captureTuiHistoryArrivalWatermark, mergeDetachedTuiHistoryTail, + mergeHydratedTuiHistory, prependOlderTuiHistory, resolveSnapshotHistoryCursor, shouldRequestOlderTuiHistory, @@ -211,6 +213,132 @@ describe("mergeDetachedTuiHistoryTail", () => { expect(merged.filter((entry) => entry.sequence === 100)).toHaveLength(1); expect(merged.at(-1)?.sequence).toBe(101); }); + + it("places delayed live output before a later terminal event", () => { + const merged = mergeDetachedTuiHistoryTail( + [envelope(10, { timestamp: "2026-01-01T12:10:00.000Z" })], + [ + envelope(12, { + timestamp: "2026-01-01T12:12:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }), + envelope(11, { timestamp: "2026-01-01T12:11:00.000Z" }), + ], + ); + + expect(merged.map((entry) => entry.sequence)).toEqual([10, 11, 12]); + }); +}); + +describe("mergeHydratedTuiHistory", () => { + it("keeps scrollback ordered and drops a replayed old turn after the latest tail", () => { + const older = envelope(10, { timestamp: "2026-01-01T12:00:00.000Z" }); + const latest = envelope(20, { timestamp: "2026-01-01T12:20:00.000Z" }); + const replayedOldTurn = envelope(11, { timestamp: "2026-01-01T12:05:00.000Z" }); + const genuinelyLive = envelope(21, { timestamp: "2026-01-01T12:21:00.000Z" }); + + const merged = mergeHydratedTuiHistory( + [{ ...latest }], + [older, latest, replayedOldTurn], + [genuinelyLive], + captureTuiHistoryArrivalWatermark([older, latest, replayedOldTurn]), + ); + + expect(merged.map((entry) => entry.sequence)).toEqual([10, 20, 21]); + expect(merged[0]).toBe(older); + expect(merged[1]).toBe(latest); + expect(merged[2]).toBe(genuinelyLive); + }); + + it("uses the TUI semantic identity when a replay changes transport metadata", () => { + const older = envelope(9, { + timestamp: "2026-01-01T12:09:00.000Z", + event: { type: "text", text: "older scrollback" }, + }); + const originalPrompt = envelope(10, { + timestamp: "2026-01-01T12:10:00.000Z", + event: { type: "user_message", text: "ship it", turnId: "turn-1", messageId: "message-1" }, + }); + const replayedPrompt = envelope(99, { + timestamp: "2026-01-01T12:10:05.000Z", + event: { type: "user_message", text: "ship it", turnId: "turn-1", messageId: "message-1" }, + }); + + const merged = mergeHydratedTuiHistory( + [replayedPrompt], + [older, originalPrompt], + [], + captureTuiHistoryArrivalWatermark([older, originalPrompt]), + ); + + expect(merged).toEqual([older, originalPrompt]); + }); + + it("preserves delayed live output flushed while hydration is in flight", () => { + const prompt = envelope(10, { + timestamp: "2026-01-01T12:10:00.000Z", + event: { type: "user_message", text: "ship it", turnId: "turn-1", messageId: "message-1" }, + }); + const done = envelope(12, { + timestamp: "2026-01-01T12:12:00.000Z", + event: { type: "done", turnId: "turn-1", status: "completed" }, + }); + const staleReplay = envelope(9, { + timestamp: "2026-01-01T12:09:00.000Z", + event: { type: "text", text: "stale replay" }, + }); + const delayedPending = envelope(11, { + timestamp: "2026-01-01T12:11:00.000Z", + event: { type: "text", text: "delayed pending output" }, + }); + + const existingAtRequestStart = [prompt, done, staleReplay]; + const arrivalWatermark = captureTuiHistoryArrivalWatermark(existingAtRequestStart); + const pending = [delayedPending]; + const existingAtMerge = [...existingAtRequestStart, ...pending.splice(0)]; + + expect(pending).toEqual([]); + const merged = mergeHydratedTuiHistory( + [{ ...prompt }, { ...done }], + existingAtMerge, + pending, + arrivalWatermark, + ); + + expect(merged).toEqual([prompt, delayedPending, done]); + expect(merged[1]).toBe(delayedPending); + }); + + it("caps a large hydrated merge while preserving the chronological newest tail", () => { + const existingCount = TUI_LOADED_EVENT_CAP + 25; + const existing = Array.from({ length: existingCount }, (_, index) => { + const sequence = index + 1; + return envelope(sequence, { + timestamp: new Date(Date.UTC(2026, 0, 1) + sequence).toISOString(), + }); + }); + const snapshotTail = existing.slice(-TUI_SNAPSHOT_DISPLAY_CAP); + const pending = Array.from({ length: 10 }, (_, index) => { + const sequence = existingCount + index + 1; + return envelope(sequence, { + timestamp: new Date(Date.UTC(2026, 0, 1) + sequence).toISOString(), + }); + }); + + const merged = mergeHydratedTuiHistory( + snapshotTail, + existing, + pending, + captureTuiHistoryArrivalWatermark(existing), + ); + + expect(merged).toHaveLength(TUI_LOADED_EVENT_CAP); + expect(merged[0]?.sequence).toBe(existingCount + pending.length - TUI_LOADED_EVENT_CAP + 1); + expect(merged.at(-1)?.sequence).toBe(existingCount + pending.length); + expect(merged.every((entry, index) => ( + index === 0 || entry.sequence === (merged[index - 1]?.sequence ?? 0) + 1 + ))).toBe(true); + }); }); describe("splitSnapshotForDisplay", () => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 9a2753d99..999ea9816 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -271,7 +271,7 @@ import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from ". import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; import { clipboardScratchDir, isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets"; import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; -import { advanceOlderHistoryCursor, mergeDetachedTuiHistoryTail, prependOlderTuiHistory, resolveSnapshotHistoryCursor, shouldRequestOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP, TUI_SNAPSHOT_DISPLAY_CAP, type OlderHistoryStatus } from "./olderHistory"; +import { advanceOlderHistoryCursor, captureTuiHistoryArrivalWatermark, mergeDetachedTuiHistoryTail, mergeHydratedTuiHistory, prependOlderTuiHistory, resolveSnapshotHistoryCursor, shouldRequestOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP, TUI_SNAPSHOT_DISPLAY_CAP, type OlderHistoryStatus } from "./olderHistory"; import { coalesceTextDeltaEnvelopes } from "./assistantTextIdentity"; import { EMPTY_BRACKETED_PASTE_STATE, @@ -3680,7 +3680,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); }, []); - const mergeHydratedEventsWithLive = useCallback((sessionId: string, displayEvents: AgentChatEventEnvelope[]) => { + const captureHydratedEventsWatermark = useCallback((sessionId: string) => ( + captureTuiHistoryArrivalWatermark([ + ...(eventsBySessionIdRef.current[sessionId] ?? []), + ...pendingChatEnvelopesRef.current.filter((envelope) => envelope.sessionId === sessionId), + ]) + ), []); + + const mergeHydratedEventsWithLive = useCallback(( + sessionId: string, + displayEvents: AgentChatEventEnvelope[], + arrivalWatermark: ReadonlySet, + ) => { const existing = eventsBySessionIdRef.current[sessionId] ?? []; const pending = pendingChatEnvelopesRef.current.filter((envelope) => envelope.sessionId === sessionId); if (detachedHistorySessionIdsRef.current.has(sessionId)) { @@ -3693,10 +3704,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, ); } if (existing.length === 0 && pending.length === 0) return displayEvents; - return dedupeTuiEvents( - [...displayEvents, ...existing, ...pending], - Math.max(TUI_LOADED_EVENT_CAP, displayEvents.length, existing.length + pending.length), - ); + return mergeHydratedTuiHistory(displayEvents, existing, pending, arrivalWatermark); }, []); const commitActiveSessionEvents = useCallback(( @@ -4760,6 +4768,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const generation = drawerPreviewGenerationRef.current + 1; drawerPreviewGenerationRef.current = generation; + const historyArrivalWatermark = captureHydratedEventsWatermark(sessionId); void (async () => { try { const history = await getChatHistory(conn, sessionId); @@ -4801,7 +4810,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // displayed-oldest ← buffer ← tailStartOffset seams contiguous. const dedupedHistory = dedupeTuiEvents(visibleHistory, Math.max(1, visibleHistory.length)); const { display, buffer: olderBuffer } = splitSnapshotForDisplay(dedupedHistory); - const historyEvents = mergeHydratedEventsWithLive(sessionId, display); + const historyEvents = mergeHydratedEventsWithLive( + sessionId, + display, + historyArrivalWatermark, + ); loadedSessionIdRef.current = sessionId; commitActiveSessionEvents(sessionId, historyEvents, history.events.length); // A locally cleared transcript view must not page older history back in. @@ -4835,7 +4848,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, )); } })(); - }, [clearOlderHistoryCursor, commitActiveSessionEvents, mergeHydratedEventsWithLive, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setGridView, setSessionInterrupted, setSessionStreaming, setStreaming]); + }, [captureHydratedEventsWatermark, clearOlderHistoryCursor, commitActiveSessionEvents, mergeHydratedEventsWithLive, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setGridView, setSessionInterrupted, setSessionStreaming, setStreaming]); const toggleDrawerClosedCliGroup = useCallback((laneId: string | null) => { if (!laneId) return; setDrawerClosedCliExpandedLaneIds((prev) => { @@ -7401,6 +7414,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, nextSessionId, }); if (shouldHydrateHistory) { + const historyArrivalWatermark = captureHydratedEventsWatermark(nextSessionId); const history = await getChatHistory(conn, nextSessionId); if (!isCurrentRefresh()) return; if (history.unavailable === true) { @@ -7430,7 +7444,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // displayed-oldest ← buffer ← tailStartOffset seams contiguous. const dedupedHistory = dedupeTuiEvents(visibleHistory, Math.max(1, visibleHistory.length)); const { display, buffer: olderBuffer } = splitSnapshotForDisplay(dedupedHistory); - nextEvents = mergeHydratedEventsWithLive(nextSessionId, display); + nextEvents = mergeHydratedEventsWithLive( + nextSessionId, + display, + historyArrivalWatermark, + ); const activeModelId = nextSession?.modelId ?? null; const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; const stats = latestTokenStats(history.events, fallbackContext); @@ -7587,7 +7605,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } if (draftMode) draftSeededFromHistoryRef.current = true; } - }, [clearedAt, clearOlderHistoryCursor, commitActiveSessionEvents, drawerLaneId, loadProviderModels, mergeHydratedEventsWithLive, modelState.provider, project, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]); + }, [captureHydratedEventsWatermark, clearedAt, clearOlderHistoryCursor, commitActiveSessionEvents, drawerLaneId, loadProviderModels, mergeHydratedEventsWithLive, modelState.provider, project, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, selectedDrawerChatAction, setDraftChatMode, setSessionInterrupted, setSessionStreaming, setStreaming]); const renameLane = useCallback(async (laneIdArg: string | null, name: string) => { const conn = connectionRef.current; diff --git a/apps/ade-cli/src/tuiClient/olderHistory.ts b/apps/ade-cli/src/tuiClient/olderHistory.ts index d4eed966d..3093dc04c 100644 --- a/apps/ade-cli/src/tuiClient/olderHistory.ts +++ b/apps/ade-cli/src/tuiClient/olderHistory.ts @@ -1,4 +1,9 @@ import type { AgentChatEventEnvelope } from "../../../desktop/src/shared/types/chat"; +import { + captureAgentChatHistoryArrivalWatermark, + mergeAgentChatHistorySnapshot, + orderAgentChatEventsChronologically, +} from "../../../desktop/src/shared/chatHistoryMerge"; import { dedupeTuiEvents, tuiEventDedupKey } from "./eventDedup"; /** @@ -139,13 +144,51 @@ export function mergeDetachedTuiHistoryTail( snapshotTail: readonly AgentChatEventEnvelope[], bufferedLiveEvents: readonly AgentChatEventEnvelope[], ): AgentChatEventEnvelope[] { - const combined = [...snapshotTail, ...bufferedLiveEvents]; + const combined = mergeAgentChatHistorySnapshot( + [...snapshotTail], + [...snapshotTail, ...bufferedLiveEvents], + { identityKey: tuiEventDedupKey }, + ); return dedupeTuiEvents( - combined, + orderAgentChatEventsChronologically(combined), Math.min(TUI_LOADED_EVENT_CAP, Math.max(1, combined.length)), ); } +/** + * Freeze the TUI-semantic identities already resident when an asynchronous + * history request starts. Include both rendered and pending envelopes at the + * call site so a pending pre-request replay cannot masquerade as in-flight + * output if it flushes before the snapshot returns. + */ +export function captureTuiHistoryArrivalWatermark( + events: readonly AgentChatEventEnvelope[], +): ReadonlySet { + return captureAgentChatHistoryArrivalWatermark(events, tuiEventDedupKey); +} + +/** + * Reconcile a refreshed authoritative tail with cached scrollback and events + * received while hydration was in flight. Older cached rows stay before the + * tail; replayed historical rows are never appended after it. + */ +export function mergeHydratedTuiHistory( + snapshotTail: readonly AgentChatEventEnvelope[], + existing: readonly AgentChatEventEnvelope[], + pending: readonly AgentChatEventEnvelope[], + arrivalWatermark: ReadonlySet, +): AgentChatEventEnvelope[] { + const merged = mergeAgentChatHistorySnapshot( + [...snapshotTail], + [...existing, ...pending], + { arrivalWatermark, identityKey: tuiEventDedupKey }, + ); + return dedupeTuiEvents( + orderAgentChatEventsChronologically(merged), + Math.min(TUI_LOADED_EVENT_CAP, Math.max(1, merged.length)), + ); +} + export function shouldRequestOlderTuiHistory(args: { scrollMaxOffset: number; scrollOffset: number; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ae9e9c6d8..57015eade 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1713,6 +1713,7 @@ declare global { onEvent: ( cb: (ev: AgentChatEventEnvelope) => void, pin?: OpenProjectBinding | null, + options?: { forcePinned?: boolean }, ) => () => void; slashCommands: ( args: AgentChatSlashCommandsArgs, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 907a29c11..414bbe4fc 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -6928,6 +6928,7 @@ describe("per-chat runtime routing", () => { it("streams a pinned This Mac chat while the window is remote-bound", async () => { vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T18:02:00.500Z")); try { const { bridge, invoke, on } = await mountBridge(machineB); let streamAttempt = 0; @@ -7032,6 +7033,62 @@ describe("per-chat runtime routing", () => { } }); + it("force-pins a retained chat even when its binding is currently active", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-27T18:02:00.500Z")); + try { + const { bridge, invoke, on } = await mountBridge(machineA); + const envelope = { + sessionId: "retained-chat-on-a", + timestamp: "2026-07-27T18:02:00.500Z", + event: { type: "text", text: "still from captured project" }, + }; + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { + windowId: 1, + project: { rootPath: machineA.rootPath, displayName: machineA.displayName }, + binding: machineA, + }; + } + if (channel === IPC.localRuntimeStreamEvents) { + return { + events: [{ + id: 1, + timestamp: envelope.timestamp, + category: "runtime", + payload: envelope, + }], + nextCursor: 1, + hasMore: false, + eventEpoch: "local-epoch-a", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + await bridge.app.getWindowSession(); + const callback = vi.fn(); + const unsubscribe = bridge.agentChat.onEvent( + callback, + machineA, + { forcePinned: true }, + ); + await vi.advanceTimersByTimeAsync(0); + + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeStreamEvents, { + rootPath: "/repo-a", + request: { cursor: 0, limit: 200 }, + }); + expect(callback).toHaveBeenCalledWith(envelope); + expect(on.mock.calls.some(([channel]) => channel === IPC.agentChatEvent)).toBe(false); + + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + it("backs off repeated pinned chat poll failures and resets after success", async () => { vi.useFakeTimers(); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -7254,7 +7311,7 @@ describe("per-chat runtime routing", () => { { id: "target-b", projectId: "project-b", - request: { cursor: 0, limit: 200 }, + request: { cursor: 0, limit: 200, replay: false }, }, { id: "target-b", @@ -7264,7 +7321,7 @@ describe("per-chat runtime routing", () => { { id: "target-b", projectId: "project-b", - request: { cursor: 0, limit: 200 }, + request: { cursor: 0, limit: 200, replay: false }, }, ]); expect(callback).toHaveBeenCalledWith(oldEnvelope); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index aa9e4da2b..f791107ef 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2959,16 +2959,28 @@ function subscribeRemoteAppControlEvents( function subscribeAgentChatEvents( cb: (payload: AgentChatEventEnvelope) => void, pin?: OpenProjectBinding | null, + options?: { forcePinned?: boolean }, ): () => void { - const removeLocal = agentChatEventFanout(cb); - if (pin && pin.key !== currentProjectBinding?.key) { + const forcePinned = Boolean(pin && options?.forcePinned === true); + const removeLocal = forcePinned ? () => undefined : agentChatEventFanout(cb); + if (pin && (forcePinned || pin.key !== currentProjectBinding?.key)) { let cancelled = false; let timer: ReturnType | null = null; let cursor = 0; let eventEpoch: string | null = null; + let replaySuppressed = pin.kind === "remote"; + const startedAtMs = pin.kind === "local" ? Date.now() : 0; let consecutiveFailures = 0; const seenLocalEventIds = new Set(); const dispatchPinnedLocalEvent = (event: RemoteRuntimeBufferedEvent): void => { + const eventTime = Date.parse(event.timestamp); + if ( + startedAtMs > 0 + && Number.isFinite(eventTime) + && eventTime < startedAtMs - 1_000 + ) { + return; + } if (seenLocalEventIds.has(event.id)) return; seenLocalEventIds.add(event.id); while (seenLocalEventIds.size > 1_000) { @@ -2993,7 +3005,11 @@ function subscribeAgentChatEvents( const poll = async (): Promise => { let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; try { - const request = { cursor, limit: 200 }; + const request = { + cursor, + limit: 200, + ...(replaySuppressed && cursor === 0 ? { replay: false } : {}), + } satisfies RemoteRuntimeStreamEventsRequest; const batch = await ipcRenderer.invoke( pin.kind === "remote" ? IPC.remoteRuntimeStreamEvents @@ -3016,12 +3032,14 @@ function subscribeAgentChatEvents( eventEpoch = batchEpoch; if (epochChanged) { cursor = 0; + replaySuppressed = pin.kind === "remote"; delay = 0; resetForEpochChange = true; } } if (!resetForEpochChange) { cursor = Number.isFinite(batch.nextCursor) ? Math.max(0, Math.floor(batch.nextCursor)) : cursor; + if (request.replay === false) replaySuppressed = false; for (const event of batch.events ?? []) { if (pin.kind === "local") { dispatchPinnedLocalEvent(event); @@ -3162,12 +3180,17 @@ function subscribePinnedProjectRuntimeEvents( let timer: ReturnType | null = null; let cursor = 0; let eventEpoch: string | null = null; + let replaySuppressed = true; let consecutiveFailures = 0; const poll = async (): Promise => { let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; try { - const request = { cursor, limit: 200 }; + const request = { + cursor, + limit: 200, + ...(replaySuppressed && cursor === 0 ? { replay: false } : {}), + } satisfies RemoteRuntimeStreamEventsRequest; const batch = await ipcRenderer.invoke( pin.kind === "remote" ? IPC.remoteRuntimeStreamEvents @@ -3190,6 +3213,7 @@ function subscribePinnedProjectRuntimeEvents( eventEpoch = batchEpoch; if (epochChanged) { cursor = 0; + replaySuppressed = true; delay = 0; resetForEpochChange = true; } @@ -3198,6 +3222,7 @@ function subscribePinnedProjectRuntimeEvents( cursor = Number.isFinite(batch.nextCursor) ? Math.max(0, Math.floor(batch.nextCursor)) : cursor; + if (request.replay === false) replaySuppressed = false; for (const event of batch.events ?? []) { const payload = decode(event.payload); if (!payload) continue; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index dbeb2c03a..d2c1ac4aa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -83,7 +83,9 @@ import { deriveTurnModelState, findAnchoredChatEventIndex, formatElapsedSeconds, + getTranscriptCollapseCacheKeysForTests, reconcileMeasuredScrollTop, + resetTranscriptCollapseCacheForTests, resolveAnchoredChatRowIndex, shouldAbsorbProgrammaticScrollEvent, shouldStickToBottomAfterScroll, @@ -135,6 +137,7 @@ function renderMessageList( initialState?: Record; showStreamingIndicator?: boolean; sessionId?: string | null; + transcriptCollapseCacheKey?: string | null; laneId?: string | null; onInsertDraft?: (text: string) => void; onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; @@ -161,6 +164,7 @@ function renderMessageList( assistantLabel={options?.assistantLabel} showStreamingIndicator={options?.showStreamingIndicator} sessionId={options?.sessionId} + transcriptCollapseCacheKey={options?.transcriptCollapseCacheKey} laneId={options?.laneId} onInsertDraft={options?.onInsertDraft} onRevealChatTerminal={options?.onRevealChatTerminal} @@ -339,6 +343,7 @@ const MINIMAP_TRANSCRIPT: AgentChatEventEnvelope[] = [ const originalAde = globalThis.window.ade; beforeEach(() => { + resetTranscriptCollapseCacheForTests(); globalThis.window.ade = { ...(originalAde ?? {}), files: { @@ -2097,6 +2102,60 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByRole("button", { name: "Show less" })).toBeTruthy(); }); + it("isolates nested transcript collapse caches from the real session cache", () => { + const sessionId = "collapse-cache-parent"; + const parentEvents = userMessageEvents(["Parent transcript"], sessionId); + const nestedEvents = userMessageEvents(["Nested subagent transcript"], sessionId); + const nestedCacheKey = `subagent:${sessionId}:task-1`; + + const parent = renderMessageList(parentEvents, { sessionId }); + parent.unmount(); + const nested = renderMessageList(nestedEvents, { + sessionId, + transcriptCollapseCacheKey: nestedCacheKey, + }); + nested.unmount(); + + expect(getTranscriptCollapseCacheKeysForTests()).toEqual([sessionId, nestedCacheKey]); + + renderMessageList(parentEvents, { sessionId }); + expect(screen.getByText("Parent transcript")).toBeTruthy(); + expect(screen.queryByText("Nested subagent transcript")).toBeNull(); + expect(getTranscriptCollapseCacheKeysForTests()).toEqual([nestedCacheKey, sessionId]); + }); + + it("does not refresh collapse-cache LRU recency on an ordinary rerender", () => { + const firstSessionId = "collapse-lru-a"; + const firstEvents = userMessageEvents(["First"], firstSessionId); + const first = render( + + + , + ); + for (const suffix of ["b", "c", "d", "e", "f", "g", "h"]) { + const sessionId = `collapse-lru-${suffix}`; + renderMessageList(userMessageEvents([suffix], sessionId), { sessionId }); + } + expect(getTranscriptCollapseCacheKeysForTests()[0]).toBe(firstSessionId); + + first.rerender( + + + , + ); + renderMessageList(userMessageEvents(["i"], "collapse-lru-i"), { + sessionId: "collapse-lru-i", + }); + + const cacheKeys = getTranscriptCollapseCacheKeysForTests(); + expect(cacheKeys).not.toContain(firstSessionId); + expect(cacheKeys).toContain("collapse-lru-b"); + }); + it("leaves a short user prompt uncollapsed", () => { renderMessageList(userMessageEvents(["Ship it"], "short-session"), { sessionId: "short-session" }); expect(screen.queryByTestId("user-message-collapsible-body")).toBeNull(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 4d7f0e269..1024d5683 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -5746,6 +5746,46 @@ type PendingChatEventAnchor = { lastEventsLength: number; }; +type TranscriptCollapseCache = { + events: AgentChatEventEnvelope[]; + rows: TranscriptRenderEnvelope[]; + context: CollapseTranscriptResult["context"] | null; +}; + +const MAX_TRANSCRIPT_COLLAPSE_CACHE_ENTRIES = 8; +const transcriptCollapseCacheBySessionId = new Map(); + +export function resetTranscriptCollapseCacheForTests(): void { + transcriptCollapseCacheBySessionId.clear(); +} + +export function getTranscriptCollapseCacheKeysForTests(): string[] { + return [...transcriptCollapseCacheBySessionId.keys()]; +} + +function readTranscriptCollapseCache(sessionId: string | null | undefined): TranscriptCollapseCache { + if (!sessionId) return { events: [], rows: [], context: null }; + const cached = transcriptCollapseCacheBySessionId.get(sessionId); + if (!cached) return { events: [], rows: [], context: null }; + transcriptCollapseCacheBySessionId.delete(sessionId); + transcriptCollapseCacheBySessionId.set(sessionId, cached); + return cached; +} + +function writeTranscriptCollapseCache( + sessionId: string | null | undefined, + cached: TranscriptCollapseCache, +): void { + if (!sessionId) return; + transcriptCollapseCacheBySessionId.delete(sessionId); + transcriptCollapseCacheBySessionId.set(sessionId, cached); + while (transcriptCollapseCacheBySessionId.size > MAX_TRANSCRIPT_COLLAPSE_CACHE_ENTRIES) { + const oldest = transcriptCollapseCacheBySessionId.keys().next().value; + if (typeof oldest !== "string") break; + transcriptCollapseCacheBySessionId.delete(oldest); + } +} + function AgentChatMessageListMain({ events, showStreamingIndicator = false, @@ -5767,6 +5807,7 @@ function AgentChatMessageListMain({ pendingApprovalIds, laneId, sessionId, + transcriptCollapseCacheKey, onInsertDraft, onRevealChatTerminal, onRewindFiles, @@ -5813,6 +5854,8 @@ function AgentChatMessageListMain({ pendingApprovalIds?: Set; laneId?: string | null; sessionId?: string | null; + /** Stable identity for collapse warm-cache isolation when rendering a nested transcript. */ + transcriptCollapseCacheKey?: string | null; sessionEnded?: boolean; /** True when older transcript pages exist above the loaded events. */ hasOlderHistory?: boolean; @@ -5849,15 +5892,19 @@ function AgentChatMessageListMain({ // Carries the CollapseTranscriptContext alongside events/rows so appended // subagent progress/result events can index back into the previous rows and // mutate the anchor by its stored rowIndex (see collapseChatTranscriptRows). - const collapseCacheRef = useRef<{ - events: AgentChatEventEnvelope[]; - rows: TranscriptRenderEnvelope[]; - context: CollapseTranscriptResult["context"] | null; - }>({ - events: [], - rows: [], - context: null, - }); + const resolvedTranscriptCollapseCacheKey = transcriptCollapseCacheKey ?? sessionId; + const collapseCacheStateRef = useRef<{ + key: string | null | undefined; + cache: TranscriptCollapseCache; + } | null>(null); + let collapseCacheState = collapseCacheStateRef.current; + if (!collapseCacheState || collapseCacheState.key !== resolvedTranscriptCollapseCacheKey) { + collapseCacheState = { + key: resolvedTranscriptCollapseCacheKey, + cache: readTranscriptCollapseCache(resolvedTranscriptCollapseCacheKey), + }; + collapseCacheStateRef.current = collapseCacheState; + } // Read once per mount: the pane remounts this component per chat, so this is // effectively "the state this chat was left in". const [restoredScrollMemory] = useState(() => readChatScrollMemory(sessionId)); @@ -6000,16 +6047,18 @@ function AgentChatMessageListMain({ }, []); const rows = useMemo(() => { - const cached = collapseCacheRef.current; + const cached = collapseCacheState.cache; const { rows: nextRows, context } = collapseChatTranscriptEventsIncrementalWithContext( events, cached.events, cached.rows, cached.context, ); - collapseCacheRef.current = { events, rows: nextRows, context }; + const nextCache = { events, rows: nextRows, context }; + collapseCacheState.cache = nextCache; + writeTranscriptCollapseCache(resolvedTranscriptCollapseCacheKey, nextCache); return nextRows; - }, [events]); + }, [collapseCacheState, events, resolvedTranscriptCollapseCacheKey]); const assistantTurnCopyByRowKey = useMemo(() => { const byRowKey = new Map(); for (const info of deriveAssistantTurnCopyMap(rows).values()) { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 1a395ebf8..f5db66bb6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -50,7 +50,9 @@ import { resolveChatHistoryMissAction, resolveMergedSnapshotHistoryCursor, resolveNextSelectedSessionId, + resolveChatComposerSessionId, resolveRenderedChatSessionId, + resolveUnchangedHistoryTurnActive, resetChatBootModelRefreshMemoForTests, resolveSnapshotHistoryCursor, selectAgentChatSessionViewEvictions, @@ -68,6 +70,10 @@ import { writeChatCompanionUiState, } from "./chatCompanionUiState"; import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; +import { + isChatSessionRetained, + releaseAllRetainedChatSessions, +} from "./chatSessionRetention"; import { findUserMessageForTurn, isParentUserMessage } from "./chatTurnState"; vi.mock("../terminals/TerminalView", () => { @@ -967,6 +973,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + releaseAllRetainedChatSessions(); invalidateAgentChatSessionListCache(); invalidateAgentChatSlashCommandsCache(); invalidateAiDiscoveryCache(); @@ -7674,6 +7681,31 @@ describe("AgentChatPane submit recovery", () => { expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-2" })); }); + it("subscribes to live chat events before reading the authoritative history snapshot", async () => { + const session = buildSession("session-handoff", { + title: "Gap-free handoff", + }); + installAdeMocks({ + sessions: [session], + eventHistory: { + sessionId: session.sessionId, + events: [], + truncated: false, + sessionFound: true, + }, + }); + + renderPane(session); + + await waitFor(() => { + expect(window.ade.agentChat.onEvent).toHaveBeenCalled(); + expect(window.ade.agentChat.getEventHistory).toHaveBeenCalled(); + }); + expect(vi.mocked(window.ade.agentChat.onEvent).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(window.ade.agentChat.getEventHistory).mock.invocationCallOrder[0]!, + ); + }); + it("hydrates a visible inactive grid tile without requiring a click", async () => { const session = buildSession("grid-inactive-chat", { title: "Grid inactive chat", @@ -8435,6 +8467,39 @@ describe("deriveRuntimeState", () => { }); }); +describe("resolveUnchangedHistoryTurnActive", () => { + const promptOnly: AgentChatEventEnvelope[] = [{ + sessionId: "session-1", + timestamp: "2026-07-29T12:00:00.000Z", + event: { type: "user_message", text: "ship it" }, + }]; + + it("clears summary-derived running state when the same history settles", () => { + expect(resolveUnchangedHistoryTurnActive( + promptOnly, + false, + buildSession("session-1", { status: "active" }), + )).toBe(true); + expect(resolveUnchangedHistoryTurnActive( + promptOnly, + false, + buildSession("session-1", { status: "idle" }), + )).toBe(false); + }); + + it("preserves real transcript running evidence over a stale settled summary", () => { + expect(resolveUnchangedHistoryTurnActive( + [{ + sessionId: "session-1", + timestamp: "2026-07-29T12:00:01.000Z", + event: { type: "status", turnStatus: "started", turnId: "turn-1" }, + }], + undefined, + buildSession("session-1", { status: "idle" }), + )).toBe(true); + }); +}); + describe("mergeChatHistorySnapshot", () => { function envelope( timestamp: string, @@ -8487,6 +8552,25 @@ describe("mergeChatHistorySnapshot", () => { ]); }); + it("drops a replayed old turn that arrived after the authoritative completed tail", () => { + const older = envelope("2026-04-30T23:10:00.000Z", 10, "older scrollback"); + const completedTail = envelope("2026-04-30T23:25:10.427Z", 146, "completed tail"); + const replayedOldTurn = envelope("2026-04-30T23:14:47.751Z", 11, "replayed old turn"); + const refreshedTail = envelope("2026-04-30T23:25:10.427Z", 146, "completed tail"); + + const merged = mergeChatHistorySnapshot( + [refreshedTail], + [older, completedTail, replayedOldTurn], + ); + + expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ + "older scrollback", + "completed tail", + ]); + expect(merged[0]).toBe(older); + expect(merged[1]).toBe(completedTail); + }); + it("preserves existing event object identity when a recovery snapshot is unchanged", () => { const first = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); const second = envelope("2026-04-30T23:19:57.083Z", 1004, "second"); @@ -8845,6 +8929,17 @@ describe("resolveRenderedChatSessionId", () => { }); }); +describe("resolveChatComposerSessionId", () => { + it("withholds controls while the transcript leads the internal selection", () => { + expect(resolveChatComposerSessionId("incoming", "outgoing")).toBeNull(); + }); + + it("enables controls only for the transcript that is actually visible", () => { + expect(resolveChatComposerSessionId("incoming", "incoming")).toBe("incoming"); + expect(resolveChatComposerSessionId(null, null)).toBeNull(); + }); +}); + describe("resolveChatHistoryMissAction", () => { it("never destroys state when the runtime was merely unreachable", () => { expect(resolveChatHistoryMissAction({ unavailable: true, hasRenderedEvents: true })).toBe("sync-pending"); @@ -9559,6 +9654,45 @@ describe("AgentChatPane per-chat runtime routing", () => { expect(useAppStore.getState().projectBinding).toEqual(machineA); }); + it("captures the concrete active binding when retaining a hidden chat", async () => { + bindWindowToMachineA(); + const session = buildSession("retained-chat-on-a", { laneId: "lane-a", title: "Retained local chat" }); + installAdeMocks({ sessions: [session], eventHistory: emptyHistory(session.sessionId) }); + + const view = renderPane(session); + await waitFor(() => expect(window.ade.agentChat.onEvent).toHaveBeenCalled()); + vi.mocked(window.ade.agentChat.onEvent).mockClear(); + + view.unmount(); + + expect(window.ade.agentChat.onEvent).toHaveBeenCalledWith( + expect.any(Function), + machineA, + { forcePinned: true }, + ); + expect(isChatSessionRetained(session.sessionId)).toBe(true); + }); + + it("immediately adopts a cleanup handoff when routing changes while visible", async () => { + bindWindowToMachineA(); + const session = buildSession("visible-routing-change", { laneId: "lane-a", title: "Visible routing change" }); + installAdeMocks({ sessions: [session], eventHistory: emptyHistory(session.sessionId) }); + + renderPane(session); + const onEvent = vi.mocked(window.ade.agentChat.onEvent); + await waitFor(() => expect(onEvent).toHaveBeenCalled()); + const callsBeforeRoutingChange = onEvent.mock.calls.length; + + act(() => { + useAppStore.setState({ + projectBinding: { ...machineA }, + }); + }); + + await waitFor(() => expect(onEvent.mock.calls.length).toBeGreaterThan(callsBeforeRoutingChange)); + expect(isChatSessionRetained(session.sessionId)).toBe(false); + }); + it("passes the effective remote project binding to prompt stashes when the chat pin is null", async () => { bindWindowToMachineB(); const session = buildSession("chat-on-b", { laneId: "lane-b", title: "Remote-bound chat" }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c574ab25e..94299c81c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; import { ArrowLeft, CaretRight, CircleNotch, Cube, Desktop, DeviceMobile, ArrowBendUpRight, DownloadSimple, GitFork, Lightning, Plus, Terminal, TreeStructure, X, type Icon } from "@phosphor-icons/react"; @@ -71,6 +71,11 @@ import type { OrchestrationContextItem, } from "../../../shared/types/orchestration"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; +import { + captureAgentChatHistoryArrivalWatermark, + mergeAgentChatHistorySnapshot as mergeChatHistorySnapshot, + mergeAgentChatLiveEvents, +} from "../../../shared/chatHistoryMerge"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { deriveDeterministicLaneNameFromPrompt } from "../../../shared/laneNameFallback"; import { isRuntimeTransportTimeoutError } from "../../../shared/runtimeErrors"; @@ -128,6 +133,7 @@ export { resolveMergedSnapshotHistoryCursor, resolveSnapshotHistoryCursor, } from "./chatHistoryWindow"; +export { mergeAgentChatHistorySnapshot as mergeChatHistorySnapshot } from "../../../shared/chatHistoryMerge"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { isChatToolType } from "../../lib/sessions"; import { ToolLogo } from "../terminals/ToolLogos"; @@ -1175,6 +1181,27 @@ export function deriveRuntimeState(events: AgentChatEventEnvelope[]): { }; } +function deriveTranscriptTurnActive(events: AgentChatEventEnvelope[]): boolean { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]!.event; + if (event.type === "done") return false; + if (event.type === "status") return event.turnStatus === "started"; + } + return false; +} + +export function resolveUnchangedHistoryTurnActive( + events: AgentChatEventEnvelope[], + cachedDerivedTurnActive: boolean | undefined, + summary: AgentChatSessionSummary | null | undefined, +): boolean { + return resolveTurnActive( + events, + cachedDerivedTurnActive ?? deriveTranscriptTurnActive(events), + summary, + ); +} + type AgentChatSessionViewCache = { events: AgentChatEventEnvelope[]; turnActive: boolean; @@ -1221,6 +1248,20 @@ function readAgentChatSessionViewCache(sessionId: string | null | undefined): Ag return cached; } +function updateAgentChatSessionViewCacheHistoryCursor( + sessionId: string, + historyCursor: number | null, +): void { + const cached = peekAgentChatSessionViewCache(sessionId); + if (!cached || cached.historyCursor === historyCursor) return; + removeAgentChatSessionViewCache(sessionId); + agentChatSessionViewCacheBySessionId.set(sessionId, { + ...cached, + historyCursor, + cachedAtMs: Date.now(), + }); +} + function writeAgentChatSessionViewCache( sessionId: string, events: AgentChatEventEnvelope[], @@ -1966,19 +2007,10 @@ function appendRetainedChatSessionEvents( if (!envelopes.length) return; const cached = peekAgentChatSessionViewCache(sessionId); if (!cached) return; - const seen = new Set(); - for (const entry of cached.events) seen.add(chatEventDedupKey(entry)); - const fresh: AgentChatEventEnvelope[] = []; - for (const envelope of envelopes) { - const key = chatEventDedupKey(envelope); - // Dedupes against the batch itself too — the bridge can redeliver. - if (seen.has(key)) continue; - seen.add(key); - fresh.push(envelope); - } - if (!fresh.length) return; + const combined = mergeAgentChatLiveEvents(cached.events, envelopes); + if (combined === cached.events) return; const merged = trimChatEventHistory( - [...cached.events, ...fresh], + combined, MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS, MAX_SELECTED_CHAT_SESSION_RESIDENT_BYTES, ); @@ -2087,66 +2119,6 @@ function hasMatchingCommittedUserMessage( return events.some((event) => isMatchingOptimisticUserMessage(event, optimistic)); } -export function mergeChatHistorySnapshot( - parsed: AgentChatEventEnvelope[], - existing: AgentChatEventEnvelope[], -): AgentChatEventEnvelope[] { - if (!existing.length) return parsed; - if (!parsed.length) return existing; - - const existingByKey = new Map(); - const existingIndexByKey = new Map(); - for (let index = 0; index < existing.length; index += 1) { - const entry = existing[index]!; - const key = chatEventDedupKey(entry); - if (!existingByKey.has(key)) existingByKey.set(key, entry); - if (!existingIndexByKey.has(key)) existingIndexByKey.set(key, index); - } - const parsedKeys = new Set(); - const normalizedParsed = parsed.map((entry) => { - const key = chatEventDedupKey(entry); - parsedKeys.add(key); - return existingByKey.get(key) ?? entry; - }); - let firstOverlapIndex = -1; - for (const entry of parsed) { - const index = existingIndexByKey.get(chatEventDedupKey(entry)) ?? -1; - if (index >= 0 && (firstOverlapIndex < 0 || index < firstOverlapIndex)) { - firstOverlapIndex = index; - } - } - const lastParsedKey = chatEventDedupKey(parsed[parsed.length - 1]!); - let overlapIndex = -1; - for (let index = existing.length - 1; index >= 0; index -= 1) { - if (chatEventDedupKey(existing[index]!) === lastParsedKey) { - overlapIndex = index; - break; - } - } - - const tailCandidates = overlapIndex >= 0 - ? existing.slice(overlapIndex + 1) - : existing.filter((entry) => { - const entryTime = Date.parse(entry.timestamp); - const parsedTime = Date.parse(parsed[parsed.length - 1]!.timestamp); - if (Number.isFinite(entryTime) && Number.isFinite(parsedTime)) { - return entryTime > parsedTime; - } - return entry.timestamp > parsed[parsed.length - 1]!.timestamp; - }); - const tail = tailCandidates.filter((entry) => !parsedKeys.has(chatEventDedupKey(entry))); - const olderPrefix = firstOverlapIndex > 0 - ? existing.slice(0, firstOverlapIndex).filter((entry) => !parsedKeys.has(chatEventDedupKey(entry))) - : []; - const merged = olderPrefix.length || tail.length - ? [...olderPrefix, ...normalizedParsed, ...tail] - : normalizedParsed; - if (merged.length === existing.length && merged.every((entry, index) => entry === existing[index])) { - return existing; - } - return merged; -} - /** * Prepend an older transcript page to the in-memory event list, dropping * page entries that already exist at the seam (the hydrated tail merges the @@ -2179,6 +2151,14 @@ export function resolveRenderedChatSessionId(args: { return args.selectedSessionId; } +/** Keep interactive composer controls scoped to the transcript on screen. */ +export function resolveChatComposerSessionId( + renderedSessionId: string | null, + selectedSessionId: string | null, +): string | null { + return renderedSessionId === selectedSessionId ? selectedSessionId : null; +} + export type ChatHistoryMissAction = /** Runtime unreachable: keep everything, retry later, show a catch-up hint. */ | "sync-pending" @@ -3873,6 +3853,11 @@ export function AgentChatPane({ appliedInitialSessionId: appliedInitialSessionIdRef.current, selectedSessionId, }); + // The transcript can lead the internal selection by one render during a + // prop-driven switch. Until both ids agree, do not expose controls that + // could target the outgoing chat over the incoming transcript. + const composerSessionId = resolveChatComposerSessionId(renderedSessionId, selectedSessionId); + const chatSelectionTransitioning = composerSessionId !== renderedSessionId; const renderedSessionIdRef = useRef(renderedSessionId); renderedSessionIdRef.current = renderedSessionId; const selectedSession = useMemo( @@ -4607,7 +4592,7 @@ export function AgentChatPane({ ); const selectedTurnDiffSummaries = useMemo(() => deriveTurnDiffSummaries(selectedEvents), [selectedEvents]); const selectedTodoItems = useMemo(() => deriveTodoItems(selectedEvents), [selectedEvents]); - const selectedPendingInputs = selectedSessionId ? (pendingInputsBySession[selectedSessionId] ?? []) : []; + const selectedPendingInputs = composerSessionId ? (pendingInputsBySession[composerSessionId] ?? []) : []; const pendingInput = selectedPendingInputs[0] ?? null; const planApprovalPendingInput = selectedPendingInputs.find((entry) => isOrchestrationPlanApprovalRequest(entry.request), @@ -4619,9 +4604,11 @@ export function AgentChatPane({ } return pendingInput.request; })(); - const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true; - const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; - const selectedCodexGoalPending = selectedSessionId ? (codexGoalPendingBySession[selectedSessionId] === true) : false; + const selectedSessionAwaitingInput = + Boolean(pendingInput) + || (Boolean(composerSessionId) && selectedSession?.awaitingInput === true); + const turnActive = composerSessionId ? (turnActiveBySession[composerSessionId] ?? false) : false; + const selectedCodexGoalPending = composerSessionId ? (codexGoalPendingBySession[composerSessionId] === true) : false; const setCodexGoalFromPanel = useCallback(async (sessionId: string, nextObjective: string) => { const objective = nextObjective.replace(/\s*[\r\n]+\s*/g, " ").trim(); if (!objective) return; @@ -6080,6 +6067,9 @@ export function AgentChatPane({ } if (loadedHistoryRef.current.has(sessionId)) return; loadedHistoryRef.current.add(sessionId); + const historyArrivalWatermark = captureAgentChatHistoryArrivalWatermark( + eventsBySessionRef.current[sessionId] ?? [], + ); try { // Prefer the main-process snapshot API which merges the in-memory event @@ -6177,7 +6167,9 @@ export function AgentChatPane({ // monotonic within a single provider run; Claude fallback/resume can // restart them while keeping the same ADE chat id. const existing = eventsBySessionRef.current[sessionId] ?? []; - let merged = mergeChatHistorySnapshot(parsed, existing); + let merged = mergeChatHistorySnapshot(parsed, existing, { + arrivalWatermark: historyArrivalWatermark, + }); const selectedHistory = sessionId === selectedSessionIdRef.current || sessionId === lockSessionId; const maxHistoryEvents = selectedHistory ? MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS @@ -6191,9 +6183,6 @@ export function AgentChatPane({ maxHistoryBytes, ); - const derived = deriveRuntimeState(merged); - const sessionSummary = sessionsRef.current.find((entry) => entry.sessionId === sessionId) - ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); const historyCursor = usedSnapshotPath ? resolveMergedSnapshotHistoryCursor({ snapshotCursor: snapshotHistoryCursor, @@ -6208,6 +6197,30 @@ export function AgentChatPane({ // detached marker BEFORE caching — the merged window is cacheable again. detachedHistorySessionsRef.current.delete(sessionId); missingHistorySessionsRef.current.delete(sessionId); + delete detachedLiveEventsBySessionRef.current[sessionId]; + applyOlderHistoryCursor(sessionId, historyCursor); + setSyncPendingBySession((prev) => (prev[sessionId] ? { ...prev, [sessionId]: false } : prev)); + setOlderHistoryErrorBySession((prev) => ( + prev[sessionId] ? { ...prev, [sessionId]: null } : prev + )); + const sessionSummary = sessionsRef.current.find((entry) => entry.sessionId === sessionId) + ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); + if (merged === existing) { + updateAgentChatSessionViewCacheHistoryCursor(sessionId, historyCursor); + const nextTurnActive = resolveUnchangedHistoryTurnActive( + existing, + peekAgentChatSessionViewCache(sessionId)?.turnActive, + sessionSummary, + ); + setTurnActiveBySession((prev) => ( + prev[sessionId] === nextTurnActive + ? prev + : { ...prev, [sessionId]: nextTurnActive } + )); + return; + } + + const derived = deriveRuntimeState(merged); writeAgentChatSessionViewCache( sessionId, merged, @@ -6216,13 +6229,7 @@ export function AgentChatPane({ maxHistoryEvents, detachedHistorySessionsRef.current.has(sessionId), ); - delete detachedLiveEventsBySessionRef.current[sessionId]; - setSyncPendingBySession((prev) => (prev[sessionId] ? { ...prev, [sessionId]: false } : prev)); eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; - applyOlderHistoryCursor(sessionId, historyCursor); - setOlderHistoryErrorBySession((prev) => ( - prev[sessionId] ? { ...prev, [sessionId]: null } : prev - )); setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); setTurnActiveBySession((prev) => ({ ...prev, @@ -6409,7 +6416,11 @@ export function AgentChatPane({ // unmounts, so no timer outlives the view that scheduled it. useEffect(() => () => cancelOlderHistoryRetryWaits(), [cancelOlderHistoryRetryWaits, selectedSessionId]); - useEffect(() => { + // Prop-driven chat switches already render the incoming transcript from + // `renderedSessionId`. Apply the matching session/composer state before the + // browser paints so controls from the outgoing chat can never share that + // frame (for example, an active Stop button over a settled transcript). + useLayoutEffect(() => { if (lockSessionId) { pendingSelectedSessionIdRef.current = null; draftSelectionLockedRef.current = false; @@ -6417,14 +6428,14 @@ export function AgentChatPane({ } }, [lockSessionId]); - useEffect(() => { + useLayoutEffect(() => { if (!lockedSingleSessionMode || !lockSessionId || initialSessionSummary?.sessionId !== lockSessionId) return; setSessions([initialSessionSummary]); draftSelectionLockedRef.current = false; setSelectedSessionId(lockSessionId); }, [initialSessionSummary, lockSessionId, lockedSingleSessionMode]); - useEffect(() => { + useLayoutEffect(() => { const nextInitialSessionId = initialSessionId ?? null; if (!nextInitialSessionId) { appliedInitialSessionIdRef.current = null; @@ -6459,7 +6470,7 @@ export function AgentChatPane({ setSelectedSessionId(null); }, [forceDraft, lockSessionId]); - useEffect(() => { + useLayoutEffect(() => { syncComposerToSession(selectedSession); }, [ selectedSession?.sessionId, @@ -6881,10 +6892,24 @@ export function AgentChatPane({ // unrelated re-renders. useEffect(() => { if (!isTileVisible || !selectedSessionId) return undefined; + // A routing dependency can change while the pane stays visible. Its prior + // cleanup briefly hands the old binding to retention; adopt it immediately + // so the visible pane never leaves a second, stale subscription behind. + adoptRetainedSession(selectedSessionId); + // Capture the concrete outgoing binding. `chatRuntimePin` is intentionally + // null for the active project, but retention must remain attached to that + // project after the window switches elsewhere. + const retainedBinding = chatRuntimePin ?? projectBinding; return () => { - retainChatSession(selectedSessionId); + retainChatSession(selectedSessionId, { + subscribe: (listener) => window.ade.agentChat.onEvent( + listener, + retainedBinding, + { forcePinned: true }, + ), + }); }; - }, [isTileVisible, selectedSessionId]); + }, [chatRuntimePin, isTileVisible, projectBinding, selectedSessionId]); useEffect(() => { if (!isTileVisible || !selectedSessionId) return undefined; @@ -7081,18 +7106,12 @@ export function AgentChatPane({ for (const [sessionId, sessionQueue] of queuedBySession) { const sessionEvents = eventsBySessionRef.current[sessionId] ?? []; - const sessionEventKeys = new Set(sessionEvents.map(chatEventDedupKey)); - const freshEvents: AgentChatEventEnvelope[] = []; - for (const envelope of sessionQueue) { - const envelopeKey = chatEventDedupKey(envelope); - if (sessionEventKeys.has(envelopeKey)) continue; - sessionEventKeys.add(envelopeKey); - freshEvents.push(envelope); - } - if (!freshEvents.length) continue; if (detachedHistorySessionsRef.current.has(sessionId)) { + const detachedEvents = detachedLiveEventsBySessionRef.current[sessionId] ?? []; + const combined = mergeAgentChatLiveEvents(detachedEvents, sessionQueue); + if (combined === detachedEvents) continue; const liveEvents = trimChatEventHistory( - [...(detachedLiveEventsBySessionRef.current[sessionId] ?? []), ...freshEvents], + combined, MAX_BACKGROUND_CHAT_SESSION_EVENTS, MAX_BACKGROUND_CHAT_SESSION_RESIDENT_BYTES, ); @@ -7104,8 +7123,10 @@ export function AgentChatPane({ touchedSessionIds.add(sessionId); continue; } + const combined = mergeAgentChatLiveEvents(sessionEvents, sessionQueue); + if (combined === sessionEvents) continue; const updated = trimChatEventHistory( - [...sessionEvents, ...freshEvents], + combined, sessionId === selectedSessionIdRef.current || sessionId === lockSessionId ? MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS : MAX_BACKGROUND_CHAT_SESSION_EVENTS, @@ -7190,7 +7211,7 @@ export function AgentChatPane({ }); }, []); - useEffect(() => { + useLayoutEffect(() => { if (!isTileVisible) return undefined; const unsubscribe = window.ade.agentChat.onEvent((envelope) => { // Liveness stamp for the active-turn stall detector — recorded before any @@ -11820,7 +11841,7 @@ export function AgentChatPane({ approvalResponding={pendingInput ? respondingApprovalIds.has(pendingInput.itemId) : false} turnActive={turnActive} sendOnEnter={sendOnEnter} - busy={busy || projectTransitionBlocksChat} + busy={busy || projectTransitionBlocksChat || chatSelectionTransitioning} sessionProvider={sessionProvider} interactionMode={interactionMode} claudePermissionMode={claudePermissionMode} @@ -12110,7 +12131,7 @@ export function AgentChatPane({ onSendSteerInterrupt={selectedSession?.provider === "claude" ? () => { void submit("interrupt"); } : undefined} - sessionId={selectedSessionId} + sessionId={composerSessionId} showParallelChatToggle={Boolean( embeddedWorkLayout && forceDraft && workDraftKind === "chat" && !lockSessionId && !initialSessionId && selectedSessionId == null, )} @@ -12823,6 +12844,9 @@ export function AgentChatPane({ pendingApprovalIds={pendingApprovalIds} laneId={laneId} sessionId={renderedSessionId} + transcriptCollapseCacheKey={subagentView + ? `subagent:${renderedSessionId ?? "chat-draft"}:${subagentView.taskId}` + : undefined} onInsertDraft={insertComposerDraft} onRevealChatTerminal={revealChatTerminal} turnDiffSummaries={selectedTurnDiffSummaries} diff --git a/apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts b/apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts index da4aa19d1..0ad6051d9 100644 --- a/apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts +++ b/apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts @@ -2,9 +2,9 @@ import type { AgentChatEventEnvelope, AgentChatEventHistoryPage, } from "../../../shared/types"; +import { agentChatEventIdentityKey } from "../../../shared/chatHistoryMerge"; const chatEventResidentSizeCache = new WeakMap(); -const chatEventDedupKeyCache = new WeakMap(); export const INITIAL_SELECTED_CHAT_HISTORY_EVENTS = 1_000; export const CHAT_HISTORY_PAGE_MAX_BYTES = 256 * 1024; @@ -69,11 +69,7 @@ function trimChatEventHistoryFromStart( } export function chatEventDedupKey(entry: AgentChatEventEnvelope): string { - const cached = chatEventDedupKeyCache.get(entry); - if (cached !== undefined) return cached; - const key = `${entry.timestamp}#${entry.event.type}#${JSON.stringify(entry.event)}`; - chatEventDedupKeyCache.set(entry, key); - return key; + return agentChatEventIdentityKey(entry); } export function prependOlderChatHistoryPage( diff --git a/apps/desktop/src/renderer/components/chat/chatSessionRetention.test.ts b/apps/desktop/src/renderer/components/chat/chatSessionRetention.test.ts index ea8a44d16..8704c9917 100644 --- a/apps/desktop/src/renderer/components/chat/chatSessionRetention.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatSessionRetention.test.ts @@ -258,6 +258,33 @@ describe("chatSessionRetention", () => { expect(harness.liveListenerCount()).toBe(1); }); + it("uses the pane's pinned runtime subscription during handoff", () => { + const harness = createHarness(); + harness.cache.set("s1", { events: [], turnActive: false }); + const pinnedListeners = new Set<(entry: AgentChatEventEnvelope) => void>(); + const pinnedUnsubscribe = vi.fn(); + + expect(retainChatSession("s1", { + subscribe: (listener) => { + pinnedListeners.add(listener); + return () => { + pinnedListeners.delete(listener); + pinnedUnsubscribe(); + }; + }, + })).toBe(true); + expect(harness.subscribeCount()).toBe(0); + + for (const listener of pinnedListeners) { + listener(envelope("s1", assistantEvent("from pinned runtime"), "t1")); + } + harness.flush(); + expect(harness.cache.get("s1")?.events).toHaveLength(1); + + expect(adoptRetainedSession("s1")).toBe(true); + expect(pinnedUnsubscribe).toHaveBeenCalledTimes(1); + }); + it("drops the subscription on TTL expiry but keeps the warm cache", () => { vi.useFakeTimers(); const harness = createHarness(); diff --git a/apps/desktop/src/renderer/components/chat/chatSessionRetention.ts b/apps/desktop/src/renderer/components/chat/chatSessionRetention.ts index 773808ad5..6d3f7528a 100644 --- a/apps/desktop/src/renderer/components/chat/chatSessionRetention.ts +++ b/apps/desktop/src/renderer/components/chat/chatSessionRetention.ts @@ -178,7 +178,10 @@ export function releaseRetainedChatSession(sessionId: string): boolean { */ export function retainChatSession( sessionId: string | null | undefined, - options?: { ttlMs?: number }, + options?: { + ttlMs?: number; + subscribe?: ChatSessionRetentionHost["subscribe"]; + }, ): boolean { const host = retentionHost; if (!host || !sessionId) return false; @@ -202,7 +205,8 @@ export function retainChatSession( let unsubscribe: () => void; try { - unsubscribe = host.subscribe((envelope) => { + const subscribe = options?.subscribe ?? host.subscribe; + unsubscribe = subscribe((envelope) => { if (!envelope || envelope.sessionId !== sessionId) return; // A released entry can still see one in-flight envelope if the bridge // dispatches after unsubscribe; dropping it is correct (the pane that diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 3f6d23ed0..cc488f02d 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -2074,6 +2074,9 @@ export function collapseChatTranscriptEventsIncrementalWithContext( previousRows: ChatTranscriptRenderEnvelope[], previousContext: CollapseTranscriptContext | null, ): CollapseTranscriptResult { + if (events === previousEvents && previousContext) { + return { rows: previousRows, context: previousContext }; + } if (!previousEvents.length || events.length < previousEvents.length || !previousContext) { return collapseChatTranscriptEventsWithContext(events); } diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 384d993a2..da95aa3f8 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -932,13 +932,7 @@ describe("createAdeWebAdapter", () => { modelId: "openai/gpt-5.6", }, } as SyncChatEventPayload; - fake.emitChatSnapshot("chat-visible-oldest", { - sessionId: "chat-visible-oldest", - capturedAt: "2026-07-20T00:02:01.000Z", - truncated: false, - resumed: true, - events: [done], - }); + fake.emitChat(done); expect(received).toEqual([done]); adapter.dispose(); @@ -1079,7 +1073,7 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); - it("accepts a restarted project chat seq without duplicating a non-resumed snapshot replay", async () => { + it("replays reconnect snapshots without duplicating already-delivered chat events", async () => { fake.descriptors = descriptors(["chat.getSummary"]); fake.commandResults.set("chat.getSummary", { sessionId: "chat-restarted" }); const adapter = createAdeWebAdapter(fake.asClient()); diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index b385313cb..7ef724adc 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -13,11 +13,12 @@ import type { AdapterInfra, AdeNamespace } from "./types"; import { requestDataUrl, requestFileBlob } from "./infra/fileBlob"; import { chatEventDedupKey } from "./infra/chatEventDedup"; -// The browser gets the current chat tail through both chat_subscribe and -// chat.getChatEventHistory. Keep each initial payload small: remote hosts -// serialize those responses ahead of later summary/model commands. Older -// history remains available through getChatEventHistoryPage when the user -// scrolls back. +// The browser gets authoritative ordered history through +// chat.getChatEventHistory. chat_subscribe snapshots still matter as bounded +// reconnect recovery when the host cannot resume from the prior sequence. +// The adapter dedupes that replay, while the renderer inserts recovered rows +// chronologically rather than appending them after the real tail. Older +// history remains available through getChatEventHistoryPage on scrollback. const WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES = 128 * 1024; const WEB_CHAT_INITIAL_HISTORY_MAX_EVENTS = 512; const WEB_CHAT_INITIAL_HISTORY_MAX_BYTES = 128 * 1024; @@ -71,8 +72,12 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age sessionId, { maxBytes: WEB_CHAT_INITIAL_SNAPSHOT_MAX_BYTES }, { - snapshot: (payload) => { - for (const event of payload.events) emitChatEvent(event as SyncChatEventPayload); + // A non-resumed reconnect snapshot is the only payload carrying events + // missed while the host was unavailable or its replay ring overflowed. + // emitChatEvent removes overlap with already-delivered live rows; the + // renderer's history merge preserves chronological transcript order. + snapshot: (snapshot) => { + snapshot.events.forEach((payload) => emitChatEvent(payload)); }, event: (payload) => { emitChatEvent(payload); diff --git a/apps/desktop/src/shared/chatHistoryMerge.test.ts b/apps/desktop/src/shared/chatHistoryMerge.test.ts new file mode 100644 index 000000000..04e0aca8b --- /dev/null +++ b/apps/desktop/src/shared/chatHistoryMerge.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentChatEventEnvelope } from "./types/chat"; +import { + agentChatEventIdentityKey, + captureAgentChatHistoryArrivalWatermark, + mergeAgentChatHistorySnapshot, + mergeAgentChatLiveEvents, +} from "./chatHistoryMerge"; + +function envelope(timestamp: string, text: string): AgentChatEventEnvelope { + return { + sessionId: "session-1", + timestamp, + event: { type: "text", text }, + }; +} + +describe("chat history ordering", () => { + it("caches serialized identity across long-thread merge passes", () => { + let serializations = 0; + const cached = { + sessionId: "session-1", + timestamp: "2026-07-29T10:00:00.000Z", + event: { + type: "text", + text: "cached", + toJSON: () => { + serializations += 1; + return { type: "text", text: "cached" }; + }, + }, + } as unknown as AgentChatEventEnvelope; + + expect(agentChatEventIdentityKey(cached)).toBe(agentChatEventIdentityKey(cached)); + expect(serializations).toBe(1); + expect(mergeAgentChatLiveEvents([cached], [cached])).toEqual([cached]); + expect(serializations).toBe(1); + }); + + it("keeps the append-only live path stable and deduped", () => { + const first = envelope("2026-07-29T10:00:00.000Z", "first"); + const second = envelope("2026-07-29T10:00:01.000Z", "second"); + const existing = [first]; + + expect(mergeAgentChatLiveEvents(existing, [first])).toBe(existing); + expect(mergeAgentChatLiveEvents(existing, [second])).toEqual([first, second]); + }); + + it("inserts a delayed old envelope before the completed tail", () => { + const prompt = envelope("2026-07-29T10:00:00.000Z", "prompt"); + const done = envelope("2026-07-29T10:00:03.000Z", "done"); + const delayed = envelope("2026-07-29T10:00:01.000Z", "delayed"); + + expect(mergeAgentChatLiveEvents([prompt, done], [delayed])).toEqual([ + prompt, + delayed, + done, + ]); + }); + + it("drops replayed rows after an overlapping authoritative tail", () => { + const older = envelope("2026-07-29T10:00:00.000Z", "older"); + const tail = envelope("2026-07-29T10:00:03.000Z", "tail"); + const replayed = envelope("2026-07-29T10:00:01.000Z", "replayed"); + + expect(mergeAgentChatHistorySnapshot( + [{ ...tail }], + [older, tail, replayed], + )).toEqual([older, tail]); + }); + + it("keeps only post-snapshot rows when there is no overlap", () => { + const replayed = envelope("2026-07-29T10:00:01.000Z", "replayed"); + const snapshotTail = envelope("2026-07-29T10:00:03.000Z", "snapshot tail"); + const live = envelope("2026-07-29T10:00:04.000Z", "live"); + + expect(mergeAgentChatHistorySnapshot( + [snapshotTail], + [replayed, live], + )).toEqual([snapshotTail, live]); + }); + + it("preserves non-duplicate paged rows before the first overlap", () => { + const older = envelope("2026-07-29T10:00:00.000Z", "older"); + const tailFirst = envelope("2026-07-29T10:00:01.000Z", "tail first"); + const tailLast = envelope("2026-07-29T10:00:02.000Z", "tail last"); + const parsedFirst = envelope("2026-07-29T10:00:01.000Z", "tail first"); + const parsedLast = envelope("2026-07-29T10:00:02.000Z", "tail last"); + + const merged = mergeAgentChatHistorySnapshot( + [parsedFirst, parsedLast], + [older, tailFirst, tailLast], + ); + + expect(merged).toEqual([older, tailFirst, tailLast]); + expect(merged[0]).toBe(older); + expect(merged[1]).toBe(tailFirst); + expect(merged[2]).toBe(tailLast); + }); + + it("excludes old replay after a matched tail while retaining valid live rows", () => { + const older = envelope("2026-07-29T10:00:00.000Z", "older"); + const tail = envelope("2026-07-29T10:00:03.000Z", "tail"); + const replayed = envelope("2026-07-29T10:00:01.000Z", "replayed"); + const sameTimeLive = envelope("2026-07-29T10:00:03.000Z", "same-time live"); + const laterLive = envelope("2026-07-29T10:00:04.000Z", "later live"); + const existing = [older, tail, replayed, sameTimeLive, laterLive]; + const arrivalWatermark = captureAgentChatHistoryArrivalWatermark(existing); + + const merged = mergeAgentChatHistorySnapshot( + [{ ...tail }], + existing, + { arrivalWatermark }, + ); + + expect(merged).toEqual([older, tail, sameTimeLive, laterLive]); + expect(merged[0]).toBe(older); + expect(merged[1]).toBe(tail); + expect(merged[2]).toBe(sameTimeLive); + expect(merged[3]).toBe(laterLive); + }); + + it("preserves an in-flight delayed event that sorts inside the snapshot range", () => { + const prompt = envelope("2026-07-29T10:00:00.000Z", "prompt"); + const done = envelope("2026-07-29T10:00:03.000Z", "done"); + const arrivalWatermark = captureAgentChatHistoryArrivalWatermark([prompt, done]); + const delayedLive = envelope("2026-07-29T10:00:01.000Z", "delayed live"); + const existing = mergeAgentChatLiveEvents([prompt, done], [delayedLive]); + + const merged = mergeAgentChatHistorySnapshot( + [{ ...prompt }, { ...done }], + existing, + { arrivalWatermark }, + ); + + expect(merged).toBe(existing); + expect(merged).toEqual([prompt, delayedLive, done]); + }); +}); diff --git a/apps/desktop/src/shared/chatHistoryMerge.ts b/apps/desktop/src/shared/chatHistoryMerge.ts new file mode 100644 index 000000000..395bae0e6 --- /dev/null +++ b/apps/desktop/src/shared/chatHistoryMerge.ts @@ -0,0 +1,228 @@ +import type { AgentChatEventEnvelope } from "./types/chat"; + +export type AgentChatEventIdentity = (entry: AgentChatEventEnvelope) => string; + +export interface AgentChatHistorySnapshotMergeOptions { + /** + * Identity keys already resident when the asynchronous history read began. + * Existing entries absent from this watermark arrived while hydration was in + * flight and must survive even when their timestamp sorts inside the + * authoritative snapshot range. + */ + arrivalWatermark?: ReadonlySet; + identityKey?: AgentChatEventIdentity; +} + +const agentChatEventIdentityCache = new WeakMap(); + +/** + * Cross-run event identity. Provider sequence numbers restart, so an event is + * only a duplicate when its timestamp, type, and payload all match. + */ +export function agentChatEventIdentityKey(entry: AgentChatEventEnvelope): string { + const cached = agentChatEventIdentityCache.get(entry); + if (cached !== undefined) return cached; + const key = `${entry.timestamp}#${entry.event.type}#${JSON.stringify(entry.event)}`; + agentChatEventIdentityCache.set(entry, key); + return key; +} + +/** + * Capture the identities already resident at the start of an asynchronous + * history read so reconciliation can distinguish stale pre-read replay rows + * from events received while that read was in flight. + */ +export function captureAgentChatHistoryArrivalWatermark( + events: readonly AgentChatEventEnvelope[], + identityKey: AgentChatEventIdentity = agentChatEventIdentityKey, +): ReadonlySet { + return new Set(events.map(identityKey)); +} + +function isAtOrAfter( + candidate: AgentChatEventEnvelope, + anchor: AgentChatEventEnvelope, +): boolean { + const candidateTime = Date.parse(candidate.timestamp); + const anchorTime = Date.parse(anchor.timestamp); + if (Number.isFinite(candidateTime) && Number.isFinite(anchorTime)) { + return candidateTime >= anchorTime; + } + return candidate.timestamp >= anchor.timestamp; +} + +function isAfter( + candidate: AgentChatEventEnvelope, + anchor: AgentChatEventEnvelope, +): boolean { + const candidateTime = Date.parse(candidate.timestamp); + const anchorTime = Date.parse(anchor.timestamp); + if (Number.isFinite(candidateTime) && Number.isFinite(anchorTime)) { + return candidateTime > anchorTime; + } + return candidate.timestamp > anchor.timestamp; +} + +function compareAgentChatEventTime( + left: AgentChatEventEnvelope, + right: AgentChatEventEnvelope, +): number { + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { + return leftTime - rightTime; + } + return left.timestamp.localeCompare(right.timestamp); +} + +/** + * Keep physical event order chronological without allocating on the common + * already-ordered path. JavaScript's stable sort preserves arrival order for + * same-timestamp provider fragments. + */ +export function orderAgentChatEventsChronologically( + events: AgentChatEventEnvelope[], +): AgentChatEventEnvelope[] { + for (let index = 1; index < events.length; index += 1) { + if (compareAgentChatEventTime(events[index]!, events[index - 1]!) < 0) { + return [...events].sort(compareAgentChatEventTime); + } + } + return events; +} + +/** + * Merge genuinely live envelopes into their chronological position. The common + * append-only path keeps O(n) identity and avoids sorting; only a delayed or + * replayed envelope pays for a stable sort. + */ +export function mergeAgentChatLiveEvents( + existing: AgentChatEventEnvelope[], + incoming: readonly AgentChatEventEnvelope[], +): AgentChatEventEnvelope[] { + if (!incoming.length) return existing; + + const seen = new Set(existing.map(agentChatEventIdentityKey)); + const fresh: AgentChatEventEnvelope[] = []; + for (const entry of incoming) { + const key = agentChatEventIdentityKey(entry); + if (seen.has(key)) continue; + seen.add(key); + fresh.push(entry); + } + if (!fresh.length) return existing; + + let appendAnchor = existing[existing.length - 1]; + let appendOnly = true; + for (const entry of fresh) { + if (appendAnchor && compareAgentChatEventTime(entry, appendAnchor) < 0) { + appendOnly = false; + break; + } + appendAnchor = entry; + } + if (appendOnly) { + return [...existing, ...fresh]; + } + + return orderAgentChatEventsChronologically([...existing, ...fresh]); +} + +/** + * Reconcile an authoritative ordered history snapshot with an already-rendered + * window without disturbing either side's physical row order. + * + * The snapshot owns its covered range. Existing rows before its first overlap + * are paged scrollback; rows after its last overlap are retained only when they + * are chronologically at or after the snapshot tail. That final check is + * load-bearing: runtime subscription replay can otherwise append an old turn + * after a completed latest turn and make the composer look active again. + * Entries absent from an optional arrival watermark are the explicit exception: + * they arrived during hydration and must not be discarded by the stale snapshot. + */ +export function mergeAgentChatHistorySnapshot( + snapshot: AgentChatEventEnvelope[], + existing: AgentChatEventEnvelope[], + options: AgentChatHistorySnapshotMergeOptions = {}, +): AgentChatEventEnvelope[] { + if (!existing.length) return snapshot; + if (!snapshot.length) return existing; + + const identityKey = options.identityKey ?? agentChatEventIdentityKey; + const existingByKey = new Map(); + const existingIndexByKey = new Map(); + for (let index = 0; index < existing.length; index += 1) { + const entry = existing[index]!; + const key = identityKey(entry); + if (!existingByKey.has(key)) existingByKey.set(key, entry); + if (!existingIndexByKey.has(key)) existingIndexByKey.set(key, index); + } + + const snapshotKeys = new Set(); + const normalizedSnapshot = snapshot.map((entry) => { + const key = identityKey(entry); + snapshotKeys.add(key); + return existingByKey.get(key) ?? entry; + }); + + let firstOverlapIndex = -1; + for (const entry of snapshot) { + const index = existingIndexByKey.get(identityKey(entry)) ?? -1; + if (index >= 0 && (firstOverlapIndex < 0 || index < firstOverlapIndex)) { + firstOverlapIndex = index; + } + } + + const snapshotTail = snapshot[snapshot.length - 1]!; + const lastSnapshotKey = identityKey(snapshotTail); + let lastOverlapIndex = -1; + for (let index = existing.length - 1; index >= 0; index -= 1) { + if (identityKey(existing[index]!) === lastSnapshotKey) { + lastOverlapIndex = index; + break; + } + } + + const tailCandidates = lastOverlapIndex >= 0 + ? existing.slice(lastOverlapIndex + 1) + : existing; + const liveTail = tailCandidates.filter((entry) => ( + !snapshotKeys.has(identityKey(entry)) + && ( + lastOverlapIndex >= 0 + ? isAtOrAfter(entry, snapshotTail) + : isAfter(entry, snapshotTail) + ) + )); + const olderPrefix = firstOverlapIndex > 0 + ? existing + .slice(0, firstOverlapIndex) + .filter((entry) => !snapshotKeys.has(identityKey(entry))) + : []; + const baseMerged = olderPrefix.length || liveTail.length + ? [...olderPrefix, ...normalizedSnapshot, ...liveTail] + : normalizedSnapshot; + const baseKeys = new Set(baseMerged.map(identityKey)); + const arrivalWatermark = options.arrivalWatermark; + const inFlightEvents = arrivalWatermark + ? existing.filter((entry) => { + const key = identityKey(entry); + return ( + !snapshotKeys.has(key) + && !baseKeys.has(key) + && !arrivalWatermark.has(key) + ); + }) + : []; + const merged = inFlightEvents.length + ? orderAgentChatEventsChronologically([...baseMerged, ...inFlightEvents]) + : baseMerged; + + if ( + merged.length === existing.length + && merged.every((entry, index) => entry === existing[index]) + ) { + return existing; + } + return merged; +} diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 09561ec15..d1eede31b 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5681,6 +5681,33 @@ final class ADETests: XCTestCase { XCTAssertEqual(history.map(\.id), [earlier.id, delayedInsert.id, later.id]) } + @MainActor + func testReplayedOldPromptCannotMoveAfterCompletedChatTail() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let originalPrompt = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .userMessage(text: "Original prompt", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let completed = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:03.000Z", + event: .done(turnId: "turn-1", status: .completed, model: nil, modelId: nil, usage: nil, costUsd: nil), + sequence: 3, + provenance: nil + ) + + service.mergeChatEventHistory(sessionId: "session-1", events: [originalPrompt, completed]) + service.recordChatEventEnvelope(originalPrompt) + + XCTAssertEqual( + service.chatEventHistory(sessionId: "session-1").map(\.id), + [originalPrompt.id, completed.id] + ) + } + func testChatCommandRequestPayloadsEncodeExpectedShapes() throws { let subscribe = try jsonDictionary(from: AgentChatSubscriptionRequest(sessionId: "session-1")) XCTAssertEqual(subscribe["sessionId"] as? String, "session-1") diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index fcff8fc97..f388ed30f 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -516,6 +516,33 @@ exhausted without allowing an unbounded paging loop. An overlapping snapshot may preserve an exhausted cursor only when its oldest retained event survived the merge; a replacement snapshot or cap eviction re-arms paging. +History hydration and live delivery have separate authority. The history API +owns the ordered range it returns; the live stream owns only events outside +that range. `shared/chatHistoryMerge.ts` applies that contract across desktop +and ADE Code: exact/semantic duplicates are removed, delayed live rows are +inserted by timestamp before later terminal rows, and a replayed old turn can +never be appended after the authoritative tail. Event identity is cached by +envelope object so a 60,000-event resident window does not re-serialize every +payload on each streaming flush. Desktop installs the live listener before its +passive history read, while the local runtime pump replays the narrow handoff +window and filters older buffered events by subscription start time. The hosted +web adapter consumes `chat_subscribe` snapshots only as stream watermarks and +hydrates visible history through `chat.getChatEventHistory`; it does not +re-emit snapshot rows as new live messages. ADE Code uses its semantic +provider-run identity for overlap, then normalizes delayed events +chronologically. iOS continues to sort and dedupe its materialized event set by +the same lifecycle contract. + +The renderer keeps a bounded per-session view cache so switching back can paint +immediately. A hidden chat's retention subscription captures the concrete +outgoing project binding, even when that binding was the active unpinned path, +so a later project switch cannot silently retarget the retained stream. +Returning adopts that subscription synchronously, renders the cached tail, and +reconciles against authoritative history without blanking the list. Composer +controls are withheld for the one-frame interval where the incoming transcript +id and internal selected-session id differ; an outgoing Stop button or pending +input can therefore never appear over a settled incoming transcript. + ### `unavailable` — "could not reach the runtime", not "no such session" `sessionFound: false` is an authoritative answer: this project runtime has no