From 9d30b7e85ac9918aa1326207a4daed612702de12 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 10:52:28 +0700 Subject: [PATCH] fix: sync dev branch with main timeline/schema changes and remove stale providers Forward-port main's timeline epoch tracking, projected window selection, and schema updates into dev. Remove aider/amp/gemini providers and provider-history-compatibility layers that are no longer needed. --- package-lock.json | 22 +- package.json | 8 +- packages/app/package.json | 4 +- .../app/src/components/icons/aider-icon.tsx | 14 - .../app/src/components/icons/amp-icon.tsx | 29 - .../app/src/components/icons/gemini-icon.tsx | 17 - packages/app/src/components/provider-icons.ts | 6 - packages/app/src/contexts/session-context.tsx | 15 +- .../contexts/session-stream-reducers.test.ts | 713 ++++++++++++------ .../src/contexts/session-stream-reducers.ts | 471 +++++++----- .../session-timeline-bootstrap-policy.test.ts | 59 +- .../session-timeline-bootstrap-policy.ts | 29 +- .../session-timeline-seq-gate.test.ts | 23 +- .../src/contexts/session-timeline-seq-gate.ts | 8 +- .../hooks/use-agent-initialization.test.ts | 14 +- .../app/src/hooks/use-agent-initialization.ts | 3 +- .../src/stores/provider-recency-store.test.ts | 61 -- .../app/src/stores/provider-recency-store.ts | 131 ---- packages/app/src/stores/session-store.ts | 1 + packages/app/src/types/stream.ts | 2 +- packages/cli/src/utils/timeline.ts | 1 + .../server/src/client/daemon-client.test.ts | 30 +- packages/server/src/client/daemon-client.ts | 9 +- .../src/server/agent/agent-manager.test.ts | 56 +- .../server/src/server/agent/agent-manager.ts | 19 +- .../agent/agent-timeline-store-types.ts | 5 + .../src/server/agent/agent-timeline-store.ts | 64 +- .../src/server/agent/provider-manifest.ts | 21 - .../src/server/agent/provider-registry.ts | 7 - .../src/server/agent/providers/aider-agent.ts | 75 -- .../src/server/agent/providers/amp-agent.ts | 75 -- .../server/agent/providers/gemini-agent.ts | 75 -- .../timeline-reconnect-contract.e2e.test.ts | 52 +- .../daemon-e2e/timeline-window.e2e.test.ts | 6 +- .../src/server/persistence-hooks.test.ts | 9 +- ...der-history-compatibility-boundary.test.ts | 15 - ...ider-history-compatibility-service.test.ts | 333 -------- ...er-history-compatibility-ownership.test.ts | 389 ---------- packages/server/src/server/session.ts | 217 ++++-- .../server/test-utils/fake-agent-client.ts | 3 - .../shared/messages.stream-parsing.test.ts | 84 +-- packages/server/src/shared/messages.ts | 127 ++-- 42 files changed, 1251 insertions(+), 2051 deletions(-) delete mode 100644 packages/app/src/components/icons/aider-icon.tsx delete mode 100644 packages/app/src/components/icons/amp-icon.tsx delete mode 100644 packages/app/src/components/icons/gemini-icon.tsx delete mode 100644 packages/app/src/stores/provider-recency-store.test.ts delete mode 100644 packages/app/src/stores/provider-recency-store.ts delete mode 100644 packages/server/src/server/agent/providers/aider-agent.ts delete mode 100644 packages/server/src/server/agent/providers/amp-agent.ts delete mode 100644 packages/server/src/server/agent/providers/gemini-agent.ts delete mode 100644 packages/server/src/server/provider-history-compatibility-boundary.test.ts delete mode 100644 packages/server/src/server/provider-history-compatibility-service.test.ts delete mode 100644 packages/server/src/server/session.provider-history-compatibility-ownership.test.ts diff --git a/package-lock.json b/package-lock.json index cc64d1ba3..a8b72ec83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,8 +32,8 @@ "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", - "react": "19.1.4", - "react-dom": "19.1.4", + "react": "19.1.0", + "react-dom": "19.1.0", "typescript": "^5.9.3" } }, @@ -29122,9 +29122,9 @@ } }, "node_modules/react": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz", - "integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -29162,15 +29162,15 @@ } }, "node_modules/react-dom": { - "version": "19.1.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz", - "integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^19.1.4" + "react": "^19.1.0" } }, "node_modules/react-error-boundary": { @@ -34911,8 +34911,8 @@ "expo-web-browser": "~15.0.8", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", - "react": "19.1.4", - "react-dom": "19.1.4", + "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "^0.81.5", "react-native-css": "^3.0.1", "react-native-draggable-flatlist": "^4.0.3", diff --git a/package.json b/package.json index 9f5c23d11..c84601039 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", - "react": "19.1.4", - "react-dom": "19.1.4", + "react": "19.1.0", + "react-dom": "19.1.0", "typescript": "^5.9.3" }, "description": "Paseo: voice-controlled development environment with OpenAI Realtime API", @@ -94,8 +94,8 @@ "license": "AGPL-3.0-or-later", "overrides": { "lightningcss": "1.30.1", - "react": "19.1.4", - "react-dom": "19.1.4" + "react": "19.1.0", + "react-dom": "19.1.0" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.11", diff --git a/packages/app/package.json b/packages/app/package.json index 49d6695d4..aeaae5b55 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -81,8 +81,8 @@ "expo-web-browser": "~15.0.8", "lucide-react-native": "^0.546.0", "mnemonic-id": "^3.2.7", - "react": "19.1.4", - "react-dom": "19.1.4", + "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "^0.81.5", "react-native-css": "^3.0.1", "react-native-draggable-flatlist": "^4.0.3", diff --git a/packages/app/src/components/icons/aider-icon.tsx b/packages/app/src/components/icons/aider-icon.tsx deleted file mode 100644 index 6b2fc25e6..000000000 --- a/packages/app/src/components/icons/aider-icon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Svg, { Path } from "react-native-svg"; - -interface AiderIconProps { - size?: number; - color?: string; -} - -export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) { - return ( - - - - ); -} diff --git a/packages/app/src/components/icons/amp-icon.tsx b/packages/app/src/components/icons/amp-icon.tsx deleted file mode 100644 index 5b855867d..000000000 --- a/packages/app/src/components/icons/amp-icon.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Svg, { Path } from "react-native-svg"; - -interface AmpIconProps { - size?: number; - color?: string; -} - -export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) { - return ( - - - - - - - ); -} diff --git a/packages/app/src/components/icons/gemini-icon.tsx b/packages/app/src/components/icons/gemini-icon.tsx deleted file mode 100644 index 05d3822ce..000000000 --- a/packages/app/src/components/icons/gemini-icon.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Svg, { Path } from "react-native-svg"; - -interface GeminiIconProps { - size?: number; - color?: string; -} - -export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) { - return ( - - - - ); -} diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index efa9c8ffd..6c8a689c5 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,9 +1,6 @@ import { Bot } from "lucide-react-native"; -import { AiderIcon } from "@/components/icons/aider-icon"; -import { AmpIcon } from "@/components/icons/amp-icon"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; -import { GeminiIcon } from "@/components/icons/gemini-icon"; import { CopilotIcon } from "@/components/icons/copilot-icon"; import { OpenCodeIcon } from "@/components/icons/opencode-icon"; import { PiIcon } from "@/components/icons/pi-icon"; @@ -11,9 +8,6 @@ import { PiIcon } from "@/components/icons/pi-icon"; const PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot, - gemini: GeminiIcon as unknown as typeof Bot, - amp: AmpIcon as unknown as typeof Bot, - aider: AiderIcon as unknown as typeof Bot, copilot: CopilotIcon as unknown as typeof Bot, opencode: OpenCodeIcon as unknown as typeof Bot, pi: PiIcon as unknown as typeof Bot, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index dccf604b6..241493813 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -591,8 +591,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider void client .fetchAgentTimeline(agentId, { direction: "after", - cursor: { seq: cursor.endSeq }, + cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, limit: 0, + projection: "canonical", }) .catch((error) => { console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error); @@ -853,12 +854,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); const requestCanonicalCatchUp = useCallback( - (agentId: string, cursor: { endSeq: number }) => { + (agentId: string, cursor: { epoch: string; endSeq: number }) => { void client .fetchAgentTimeline(agentId, { direction: "after", - cursor: { seq: cursor.endSeq }, + cursor: { epoch: cursor.epoch, seq: cursor.endSeq }, limit: 0, + projection: "canonical", }) .catch((error) => { console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error); @@ -961,6 +963,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } if ( current && + current.epoch === result.cursor.epoch && current.startSeq === result.cursor.startSeq && current.endSeq === result.cursor.endSeq ) { @@ -1065,7 +1068,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubAgentStream = client.on("agent_stream", (message) => { if (message.type !== "agent_stream") return; - const { agentId, event, timestamp, seq } = message.payload; + const { agentId, event, timestamp, seq, epoch } = message.payload; const parsedTimestamp = new Date(timestamp); const streamEvent = event as AgentStreamEventPayload; if ( @@ -1107,6 +1110,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const result = processAgentStreamEvent({ event: streamEvent, seq, + epoch, currentTail, currentHead, currentCursor, @@ -1130,6 +1134,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if ( current && typeof seq === "number" && + typeof epoch === "string" && + current.epoch === epoch && seq >= current.startSeq && seq <= current.endSeq ) { @@ -1138,6 +1144,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } if ( current && + current.epoch === nextCursor.epoch && current.startSeq === nextCursor.startSeq && current.endSeq === nextCursor.endSeq ) { diff --git a/packages/app/src/contexts/session-stream-reducers.test.ts b/packages/app/src/contexts/session-stream-reducers.test.ts index cac018e35..580189da3 100644 --- a/packages/app/src/contexts/session-stream-reducers.test.ts +++ b/packages/app/src/contexts/session-stream-reducers.test.ts @@ -9,9 +9,13 @@ import { type TimelineCursor, } from "./session-stream-reducers"; +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") { return { - seq, + seqStart: seq, provider: "claude", item: { type, text }, timestamp: new Date(1000 + seq).toISOString(), @@ -29,30 +33,22 @@ function makeTimelineEvent( } as AgentStreamEventPayload; } -function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload { +function makeUserTimelineEvent(text: string): AgentStreamEventPayload { return { type: "timeline", provider: "claude", - item: { - type: "tool_call", - callId: "call-1", - name: "shell", - status, - detail: { - type: "shell", - command: "pwd", - }, - error: null, - }, - }; + item: { type: "user_message", text }, + } as AgentStreamEventPayload; } const baseTimelineInput: ProcessTimelineResponseInput = { payload: { agentId: "agent-1", direction: "after", - startSeq: null, - endSeq: null, + reset: false, + epoch: "epoch-1", + startCursor: null, + endCursor: null, entries: [], error: null, }, @@ -67,6 +63,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = { const baseStreamInput: ProcessAgentStreamEventInput = { event: makeTimelineEvent("hello"), seq: undefined, + epoch: undefined, currentTail: [], currentHead: [], currentCursor: undefined, @@ -74,6 +71,10 @@ const baseStreamInput: ProcessAgentStreamEventInput = { timestamp: new Date(2000), }; +// --------------------------------------------------------------------------- +// processTimelineResponse +// --------------------------------------------------------------------------- + describe("processTimelineResponse", () => { it("returns error path when payload.error is set", () => { const result = processTimelineResponse({ @@ -92,10 +93,35 @@ describe("processTimelineResponse", () => { expect(result.tail).toBe(baseTimelineInput.currentTail); expect(result.head).toBe(baseTimelineInput.currentHead); expect(result.cursorChanged).toBe(false); + expect(result.sideEffects).toEqual([]); }); - it("replaces tail during bootstrap tail init and schedules committed catch-up", () => { - const provisionalHead: StreamItem[] = [ + it("returns error with no init resolution when no deferred exists", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: false, + payload: { + ...baseTimelineInput.payload, + error: "timeout", + }, + }); + + expect(result.error).toBe("timeout"); + expect(result.initResolution).toBe(null); + expect(result.clearInitializing).toBe(true); + }); + + it("replaces tail and clears head when reset=true", () => { + const existingTail: StreamItem[] = [ + { + kind: "user_message", + id: "old", + text: "old message", + timestamp: new Date(500), + }, + ]; + const existingHead: StreamItem[] = [ { kind: "assistant_message", id: "head-1", @@ -106,325 +132,518 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentHead: provisionalHead, + currentTail: existingTail, + currentHead: existingHead, + payload: { + ...baseTimelineInput.payload, + reset: true, + startCursor: { seq: 1 }, + endCursor: { seq: 3 }, + entries: [ + makeTimelineEntry(1, "first"), + makeTimelineEntry(2, "second"), + makeTimelineEntry(3, "third"), + ], + }, + }); + + expect(result.tail).not.toBe(existingTail); + expect(result.tail.length).toBeGreaterThan(0); + expect(result.head).toEqual([]); + expect(result.cursorChanged).toBe(true); + expect(result.cursor).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 3, + }); + expect(result.error).toBe(null); + expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); + }); + + it("sets cursor to null when reset=true but no cursors in payload", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 }, + payload: { + ...baseTimelineInput.payload, + reset: true, + entries: [], + }, + }); + + expect(result.cursor).toBe(null); + expect(result.cursorChanged).toBe(true); + }); + + it("performs bootstrap tail init with catch-up side effect", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, isInitializing: true, hasActiveInitDeferred: true, initRequestDirection: "tail", payload: { ...baseTimelineInput.payload, direction: "tail", - startSeq: 1, - endSeq: 5, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 5 }, entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")], }, }); + // Bootstrap tail replaces expect(result.tail.length).toBeGreaterThan(0); expect(result.head).toEqual([]); expect(result.cursorChanged).toBe(true); expect(result.cursor).toEqual({ + epoch: "epoch-1", startSeq: 1, endSeq: 5, }); - const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up"); - expect(catchUp).toEqual({ - type: "catch_up", - cursor: { endSeq: 5 }, + // Should have catch-up side effect + const catchUp = result.sideEffects.find((e) => e.type === "catch_up"); + expect(catchUp).toBeDefined(); + expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({ + epoch: "epoch-1", + endSeq: 5, }); }); - it("prepends older committed history for before pagination", () => { - const currentTail: StreamItem[] = [ - { - kind: "assistant_message", - id: "tail-3", - text: "newer", - timestamp: new Date(3000), - }, - ]; - const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 }; + it("appends incrementally for contiguous seqs", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 3, + }; const result = processTimelineResponse({ ...baseTimelineInput, - currentTail, - currentCursor, + currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, - direction: "before", - startSeq: 1, - endSeq: 2, - entries: [makeTimelineEntry(1, "hello", "user_message"), makeTimelineEntry(2, "older")], + epoch: "epoch-1", + entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")], }, }); + expect(result.tail.length).toBeGreaterThan(0); expect(result.cursorChanged).toBe(true); expect(result.cursor).toEqual({ + epoch: "epoch-1", startSeq: 1, - endSeq: 4, + endSeq: 5, }); - expect(result.tail).toHaveLength(3); - expect(result.tail[0]?.kind).toBe("user_message"); - expect(result.tail[1]?.kind).toBe("assistant_message"); - expect(result.tail[2]).toBe(currentTail[0]); + expect(result.error).toBe(null); }); - it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => { - const currentHead: StreamItem[] = [ - { - kind: "assistant_message", - id: "head-assistant", - text: "partial", - timestamp: new Date(120000), - }, - ]; - const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; + it("detects gap and emits catch-up side effect", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 3, + }; const result = processTimelineResponse({ ...baseTimelineInput, - currentHead, - currentCursor, + currentCursor: existingCursor, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + entries: [makeTimelineEntry(10, "far ahead")], + }, + }); + + // Gap should trigger catch-up + const catchUp = result.sideEffects.find((e) => e.type === "catch_up"); + expect(catchUp).toBeDefined(); + expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({ + epoch: "epoch-1", + endSeq: 3, + }); + }); + + it("drops stale entries silently", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 8, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentCursor: existingCursor, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")], + }, + }); + + // No new items appended (all dropped as stale) + expect(result.tail).toBe(baseTimelineInput.currentTail); + expect(result.cursorChanged).toBe(false); + }); + + it("drops entries with epoch mismatch", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 5, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentCursor: existingCursor, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-2", + entries: [makeTimelineEntry(6, "different epoch")], + }, + }); + + expect(result.tail).toBe(baseTimelineInput.currentTail); + expect(result.cursorChanged).toBe(false); + }); + + it("resolves init when deferred matches direction", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: true, + initRequestDirection: "after", payload: { ...baseTimelineInput.payload, direction: "after", - startSeq: 121, - endSeq: 121, - entries: [makeTimelineEntry(121, "finalized reply")], - }, - }); - - expect(result.head).toEqual([]); - expect(result.cursorChanged).toBe(true); - expect(result.cursor).toEqual({ - startSeq: 1, - endSeq: 121, - }); - expect(result.tail[result.tail.length - 1]).toMatchObject({ - kind: "assistant_message", - text: "finalized reply", - }); - }); - - it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => { - const currentHead: StreamItem[] = [ - { - kind: "assistant_message", - id: "head-assistant", - text: "still streaming", - timestamp: new Date(120000), - }, - ]; - const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead, - currentCursor, - payload: { - ...baseTimelineInput.payload, - direction: "after", - startSeq: null, - endSeq: null, entries: [], }, }); - expect(result.head).toBe(currentHead); - expect(result.cursorChanged).toBe(false); - expect(result.tail).toBe(baseTimelineInput.currentTail); + expect(result.initResolution).toBe("resolve"); + expect(result.clearInitializing).toBe(true); }); - it("requests catch-up when committed rows arrive with a forward gap", () => { - const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; - + it("does not resolve init when directions differ (before vs after)", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentCursor, + isInitializing: true, + hasActiveInitDeferred: true, + initRequestDirection: "after", + payload: { + ...baseTimelineInput.payload, + direction: "before", + entries: [], + }, + }); + + // "before" direction doesn't match "after" initRequestDirection, + // and "before" is not a bootstrap tail path, so init should NOT resolve + expect(result.initResolution).toBe(null); + expect(result.clearInitializing).toBe(false); + }); + + it("clears initializing even without deferred", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: false, payload: { ...baseTimelineInput.payload, direction: "after", - startSeq: 125, - endSeq: 125, - entries: [makeTimelineEntry(125, "far ahead")], + entries: [], }, }); - expect(result.cursorChanged).toBe(false); - expect(result.tail).toBe(baseTimelineInput.currentTail); - expect(result.sideEffects).toContainEqual({ - type: "catch_up", - cursor: { endSeq: 120 }, + expect(result.clearInitializing).toBe(true); + expect(result.initResolution).toBe(null); + }); + + it("always includes flush_pending_updates side effect on success", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + payload: { + ...baseTimelineInput.payload, + entries: [], + }, + }); + + expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); + }); + + it("initializes cursor when no existing cursor on first entries", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentCursor: undefined, + payload: { + ...baseTimelineInput.payload, + epoch: "epoch-1", + entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")], + }, + }); + + expect(result.cursorChanged).toBe(true); + expect(result.cursor).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 2, }); }); }); +// --------------------------------------------------------------------------- +// processAgentStreamEvent +// --------------------------------------------------------------------------- + describe("processAgentStreamEvent", () => { - it("treats seq-less timeline events as provisional head updates", () => { + it("passes through non-timeline events without cursor changes", () => { + const turnEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + const result = processAgentStreamEvent({ ...baseStreamInput, - event: makeTimelineEvent("partial"), + event: turnEvent, seq: undefined, + epoch: undefined, }); - expect(result.changedHead).toBe(true); - expect(result.changedTail).toBe(false); - expect(result.head).toHaveLength(1); - expect(result.head[0]).toMatchObject({ - kind: "assistant_message", - text: "partial", - }); expect(result.cursorChanged).toBe(false); + expect(result.cursor).toBe(null); + expect(result.sideEffects).toEqual([]); }); - it("keeps tool call rows anchored in tail across live and committed updates", () => { - const running = processAgentStreamEvent({ + it("accepts timeline event with cursor advance", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 4, + }; + + const result = processAgentStreamEvent({ ...baseStreamInput, - event: makeToolCallEvent("running"), - seq: undefined, - }); - expect(running.head).toEqual([]); - expect(running.tail).toHaveLength(1); - expect(running.tail[0]).toMatchObject({ - kind: "tool_call", - payload: { - source: "agent", - data: { - callId: "call-1", - status: "running", - }, - }, + event: makeTimelineEvent("new chunk"), + seq: 5, + epoch: "epoch-1", + currentCursor: existingCursor, }); - const completedLive = processAgentStreamEvent({ - ...baseStreamInput, - event: makeToolCallEvent("completed"), - seq: undefined, - currentHead: running.head, - currentTail: running.tail, - currentCursor: { startSeq: 1, endSeq: 7 }, - }); - expect(completedLive.head).toEqual([]); - expect(completedLive.tail).toHaveLength(1); - expect(completedLive.tail[0]).toMatchObject({ - kind: "tool_call", - payload: { - source: "agent", - data: { - callId: "call-1", - status: "completed", - }, - }, - }); - - const committed = processAgentStreamEvent({ - ...baseStreamInput, - event: makeToolCallEvent("completed"), - seq: 8, - currentHead: completedLive.head, - currentTail: completedLive.tail, - currentCursor: { startSeq: 1, endSeq: 7 }, - }); - - expect(committed.head).toEqual([]); - expect(committed.tail).toHaveLength(1); - expect(committed.tail[0]).toMatchObject({ - kind: "tool_call", - payload: { - source: "agent", - data: { - callId: "call-1", - status: "completed", - }, - }, + expect(result.cursorChanged).toBe(true); + expect(result.cursor).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 5, }); + expect(result.sideEffects).toEqual([]); }); - it("preserves assistant/tool interleaving while a turn is streaming", () => { - const assistantBeforeTool = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("before"), - seq: undefined, - }); + it("detects gap and emits catch-up side effect", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 4, + }; - const runningTool = processAgentStreamEvent({ - ...baseStreamInput, - event: makeToolCallEvent("running"), - seq: undefined, - currentHead: assistantBeforeTool.head, - currentTail: assistantBeforeTool.tail, - }); - - const assistantAfterTool = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("after"), - seq: undefined, - currentHead: runningTool.head, - currentTail: runningTool.tail, - }); - - const completedTool = processAgentStreamEvent({ - ...baseStreamInput, - event: makeToolCallEvent("completed"), - seq: undefined, - currentHead: assistantAfterTool.head, - currentTail: assistantAfterTool.tail, - }); - - expect(completedTool.head).toEqual([]); - expect(completedTool.tail.map((item) => item.kind)).toEqual([ - "assistant_message", - "tool_call", - "assistant_message", - ]); - expect( - completedTool.tail[0]?.kind === "assistant_message" ? completedTool.tail[0].text : null, - ).toBe("before"); - expect( - completedTool.tail[2]?.kind === "assistant_message" ? completedTool.tail[2].text : null, - ).toBe("after"); - }); - - it("requests catch-up when a committed live row skips ahead", () => { const result = processAgentStreamEvent({ ...baseStreamInput, event: makeTimelineEvent("far ahead"), - seq: 125, - currentCursor: { startSeq: 1, endSeq: 120 }, + seq: 10, + epoch: "epoch-1", + currentCursor: existingCursor, }); + expect(result.cursorChanged).toBe(false); expect(result.changedTail).toBe(false); expect(result.changedHead).toBe(false); - expect(result.cursorChanged).toBe(false); - expect(result.sideEffects).toContainEqual({ - type: "catch_up", - cursor: { endSeq: 120 }, + + const catchUp = result.sideEffects.find((e) => e.type === "catch_up"); + expect(catchUp).toBeDefined(); + expect(catchUp!.cursor).toEqual({ + epoch: "epoch-1", + endSeq: 4, }); }); - it("flushes provisional head into tail on terminal turn events", () => { - const withHead = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("streaming"), - seq: undefined, - }); + it("drops stale timeline event", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 8, + }; const result = processAgentStreamEvent({ ...baseStreamInput, - event: { - type: "turn_completed", - provider: "claude", - }, - currentHead: withHead.head, - currentTail: withHead.tail, + event: makeTimelineEvent("old"), + seq: 5, + epoch: "epoch-1", + currentCursor: existingCursor, }); - expect(result.changedHead).toBe(true); - expect(result.changedTail).toBe(true); - expect(result.head).toEqual([]); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "streaming", + expect(result.cursorChanged).toBe(false); + expect(result.changedTail).toBe(false); + expect(result.changedHead).toBe(false); + expect(result.sideEffects).toEqual([]); + }); + + it("drops timeline event with epoch mismatch", () => { + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 5, + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("wrong epoch"), + seq: 6, + epoch: "epoch-2", + currentCursor: existingCursor, + }); + + expect(result.cursorChanged).toBe(false); + expect(result.changedTail).toBe(false); + expect(result.changedHead).toBe(false); + expect(result.sideEffects).toEqual([]); + }); + + it("initializes cursor when none exists", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("first"), + seq: 1, + epoch: "epoch-1", + currentCursor: undefined, + }); + + expect(result.cursorChanged).toBe(true); + expect(result.cursor).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 1, }); }); + + it("derives optimistic idle status on turn_completed for running agent", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(true); + expect(result.agent).not.toBe(null); + expect(result.agent!.status).toBe("idle"); + expect(result.agent!.updatedAt.getTime()).toBe(2000); + expect(result.agent!.lastActivityAt.getTime()).toBe(2000); + }); + + it("derives optimistic error status on turn_failed for running agent", () => { + const turnFailedEvent: AgentStreamEventPayload = { + type: "turn_failed", + provider: "claude", + error: "something broke", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnFailedEvent, + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(true); + expect(result.agent!.status).toBe("error"); + }); + + it("does not change agent when status is not running", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: { + status: "idle", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); + }); + + it("does not change agent when no agent is provided", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: null, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); + }); + + it("preserves updatedAt when agent timestamp is newer than event", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: { + status: "running", + updatedAt: new Date(5000), + lastActivityAt: new Date(5000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(true); + expect(result.agent!.updatedAt.getTime()).toBe(5000); + expect(result.agent!.lastActivityAt.getTime()).toBe(5000); + }); + + it("does not produce agent patch for non-terminal events", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("just text"), + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + seq: 1, + epoch: "epoch-1", + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); + }); }); diff --git a/packages/app/src/contexts/session-stream-reducers.ts b/packages/app/src/contexts/session-stream-reducers.ts index 1c2ac4a4d..c7fb61e51 100644 --- a/packages/app/src/contexts/session-stream-reducers.ts +++ b/packages/app/src/contexts/session-stream-reducers.ts @@ -1,7 +1,12 @@ import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { StreamItem } from "@/types/stream"; -import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; +import { + applyStreamEvent, + flushHeadToTail, + hydrateStreamState, + reduceStreamUpdate, +} from "@/types/stream"; import { classifySessionTimelineSeq, type SessionTimelineSeqDecision, @@ -12,25 +17,38 @@ import { } from "@/contexts/session-timeline-bootstrap-policy"; import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle"; +// --------------------------------------------------------------------------- +// Shared cursor type +// --------------------------------------------------------------------------- + export type TimelineCursor = { + epoch: string; startSeq: number; endSeq: number; }; +// --------------------------------------------------------------------------- +// Side-effect discriminated unions +// --------------------------------------------------------------------------- + export type TimelineReducerSideEffect = - | { type: "catch_up"; cursor: { endSeq: number } } + | { type: "catch_up"; cursor: { epoch: string; endSeq: number } } | { type: "flush_pending_updates" }; export type AgentStreamReducerSideEffect = { type: "catch_up"; - cursor: { endSeq: number }; + cursor: { epoch: string; endSeq: number }; }; +// --------------------------------------------------------------------------- +// processTimelineResponse +// --------------------------------------------------------------------------- + type TimelineDirection = "tail" | "before" | "after"; type InitRequestDirection = "tail" | "after"; type TimelineResponseEntry = { - seq: number; + seqStart: number; provider: string; item: Record; timestamp: string; @@ -40,8 +58,10 @@ export interface ProcessTimelineResponseInput { payload: { agentId: string; direction: TimelineDirection; - startSeq: number | null; - endSeq: number | null; + reset: boolean; + epoch: string; + startCursor: { seq: number } | null; + endCursor: { seq: number } | null; entries: TimelineResponseEntry[]; error: string | null; }; @@ -64,9 +84,217 @@ export interface ProcessTimelineResponseOutput { sideEffects: TimelineReducerSideEffect[]; } +export function processTimelineResponse( + input: ProcessTimelineResponseInput, +): ProcessTimelineResponseOutput { + const { + payload, + currentTail, + currentHead, + currentCursor, + isInitializing, + hasActiveInitDeferred, + initRequestDirection, + } = input; + + // ------------------------------------------------------------------ + // Error path: reject init and leave stream state unchanged + // ------------------------------------------------------------------ + if (payload.error) { + return { + tail: currentTail, + head: currentHead, + cursor: currentCursor, + cursorChanged: false, + initResolution: hasActiveInitDeferred ? "reject" : null, + clearInitializing: isInitializing, + error: payload.error, + sideEffects: [], + }; + } + + // ------------------------------------------------------------------ + // Convert entries to timeline units + // ------------------------------------------------------------------ + const timelineUnits = payload.entries.map((entry) => ({ + seq: entry.seqStart, + event: { + type: "timeline", + provider: entry.provider, + item: entry.item, + } as AgentStreamEventPayload, + timestamp: new Date(entry.timestamp), + })); + + const toHydratedEvents = ( + units: typeof timelineUnits, + ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => + units.map(({ event, timestamp }) => ({ event, timestamp })); + + // ------------------------------------------------------------------ + // Derive bootstrap policy (replace vs incremental) + // ------------------------------------------------------------------ + const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ + direction: payload.direction, + reset: payload.reset, + epoch: payload.epoch, + endCursor: payload.endCursor, + isInitializing, + hasActiveInitDeferred, + }); + const replace = bootstrapPolicy.replace; + + let nextTail = currentTail; + let nextHead = currentHead; + let nextCursor: TimelineCursor | null | undefined = currentCursor; + let cursorChanged = false; + const sideEffects: TimelineReducerSideEffect[] = []; + + if (replace) { + // ---------------------------------------------------------------- + // Replace path: full hydration from scratch + // ---------------------------------------------------------------- + nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), { + source: "canonical", + }); + nextHead = []; + + if (payload.startCursor && payload.endCursor) { + nextCursor = { + epoch: payload.epoch, + startSeq: payload.startCursor.seq, + endSeq: payload.endCursor.seq, + }; + cursorChanged = true; + } else { + nextCursor = null; + cursorChanged = true; + } + + if (bootstrapPolicy.catchUpCursor) { + sideEffects.push({ + type: "catch_up", + cursor: bootstrapPolicy.catchUpCursor, + }); + } + } else if (timelineUnits.length > 0) { + // ---------------------------------------------------------------- + // Incremental append path + // ---------------------------------------------------------------- + const acceptedUnits: typeof timelineUnits = []; + let cursor = currentCursor; + let gapCursor: { epoch: string; endSeq: number } | null = null; + + for (const unit of timelineUnits) { + const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({ + cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null, + epoch: payload.epoch, + seq: unit.seq, + }); + + if (decision === "gap") { + gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null; + break; + } + if (decision === "drop_stale" || decision === "drop_epoch") { + continue; + } + + acceptedUnits.push(unit); + if (decision === "init") { + cursor = { + epoch: payload.epoch, + startSeq: unit.seq, + endSeq: unit.seq, + }; + continue; + } + if (!cursor) { + continue; + } + cursor = { + ...cursor, + endSeq: unit.seq, + }; + } + + if (acceptedUnits.length > 0) { + // Flush head to tail before appending canonical entries so that + // chronological ordering is preserved. This matters on mobile where + // the server drops live events for backgrounded/unfocused agents: + // when the client catches up, the head may still hold stale live + // items from before the gap that must be committed ahead of the + // canonical gap-fill entries. + const baseTail = + currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail; + if (currentHead.length > 0) { + nextHead = []; + } + + nextTail = acceptedUnits.reduce( + (state, { event, timestamp }) => + reduceStreamUpdate(state, event, timestamp, { + source: "canonical", + }), + baseTail, + ); + } + + if ( + cursor && + (!currentCursor || + currentCursor.epoch !== cursor.epoch || + currentCursor.startSeq !== cursor.startSeq || + currentCursor.endSeq !== cursor.endSeq) + ) { + nextCursor = cursor; + cursorChanged = true; + } + + if (gapCursor) { + sideEffects.push({ type: "catch_up", cursor: gapCursor }); + } + } + + // ------------------------------------------------------------------ + // Flush pending agent updates side effect + // ------------------------------------------------------------------ + sideEffects.push({ type: "flush_pending_updates" }); + + // ------------------------------------------------------------------ + // Init resolution + // ------------------------------------------------------------------ + const shouldResolveDeferredInit = shouldResolveTimelineInit({ + hasActiveInitDeferred, + isInitializing, + initRequestDirection, + responseDirection: payload.direction, + reset: payload.reset, + }); + const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred); + + const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; + + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + initResolution, + clearInitializing, + error: null, + sideEffects, + }; +} + +// --------------------------------------------------------------------------- +// processAgentStreamEvent +// --------------------------------------------------------------------------- + export interface ProcessAgentStreamEventInput { event: AgentStreamEventPayload; seq: number | undefined; + epoch: string | undefined; currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; @@ -96,229 +324,57 @@ export interface ProcessAgentStreamEventOutput { sideEffects: AgentStreamReducerSideEffect[]; } -function cursorsEqual( - left: TimelineCursor | null | undefined, - right: TimelineCursor | null | undefined, -): boolean { - if (!left || !right) { - return left === right; - } - return left.startSeq === right.startSeq && left.endSeq === right.endSeq; -} - -function removeSupersededProvisionalItems( - head: StreamItem[], - event: AgentStreamEventPayload, -): StreamItem[] { - if (head.length === 0 || event.type !== "timeline") { - return head; - } - - let nextHead = head; - if (event.item.type === "assistant_message") { - nextHead = head.filter((item) => item.kind !== "assistant_message"); - } else if (event.item.type === "tool_call") { - const committedToolCall = event.item; - nextHead = head.filter( - (item) => - item.kind !== "tool_call" || - item.payload.source !== "agent" || - item.payload.data.callId !== committedToolCall.callId, - ); - } - - return nextHead.length === head.length ? head : nextHead; -} - -export function processTimelineResponse( - input: ProcessTimelineResponseInput, -): ProcessTimelineResponseOutput { - const { - payload, - currentTail, - currentHead, - currentCursor, - isInitializing, - hasActiveInitDeferred, - initRequestDirection, - } = input; - - if (payload.error) { - return { - tail: currentTail, - head: currentHead, - cursor: currentCursor, - cursorChanged: false, - initResolution: hasActiveInitDeferred ? "reject" : null, - clearInitializing: isInitializing, - error: payload.error, - sideEffects: [], - }; - } - - const timelineUnits = payload.entries.map((entry) => ({ - seq: entry.seq, - event: { - type: "timeline", - provider: entry.provider, - item: entry.item, - } as AgentStreamEventPayload, - timestamp: new Date(entry.timestamp), - })); - - const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({ - direction: payload.direction, - endSeq: payload.endSeq, - isInitializing, - hasActiveInitDeferred, - }); - - let nextTail = currentTail; - let nextHead = currentHead; - let nextCursor: TimelineCursor | null | undefined = currentCursor; - let cursorChanged = false; - const sideEffects: TimelineReducerSideEffect[] = []; - - if (bootstrapPolicy.replace) { - nextTail = hydrateStreamState( - timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })), - { source: "canonical" }, - ); - nextHead = []; - nextCursor = - typeof payload.startSeq === "number" && typeof payload.endSeq === "number" - ? { - startSeq: payload.startSeq, - endSeq: payload.endSeq, - } - : null; - cursorChanged = !cursorsEqual(currentCursor, nextCursor); - - if (bootstrapPolicy.catchUpCursor) { - sideEffects.push({ - type: "catch_up", - cursor: bootstrapPolicy.catchUpCursor, - }); - } - } else if (payload.direction === "before") { - const prepended = hydrateStreamState( - timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })), - { source: "canonical" }, - ); - nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail; - const derivedCursor = - typeof payload.startSeq === "number" - ? { - startSeq: payload.startSeq, - endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq, - } - : currentCursor; - nextCursor = derivedCursor; - cursorChanged = !cursorsEqual(currentCursor, derivedCursor); - } else if (timelineUnits.length > 0) { - const acceptedUnits: typeof timelineUnits = []; - let cursor = currentCursor; - let gapCursor: { endSeq: number } | null = null; - - for (const unit of timelineUnits) { - const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({ - cursor: cursor ? { endSeq: cursor.endSeq } : null, - seq: unit.seq, - }); - - if (decision === "gap") { - gapCursor = cursor ? { endSeq: cursor.endSeq } : null; - break; - } - if (decision === "drop_stale") { - continue; - } - - acceptedUnits.push(unit); - cursor = - decision === "init" - ? { startSeq: unit.seq, endSeq: unit.seq } - : { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq }; - nextHead = removeSupersededProvisionalItems(nextHead, unit.event); - } - - if (acceptedUnits.length > 0) { - nextTail = acceptedUnits.reduce( - (state, { event, timestamp }) => - reduceStreamUpdate(state, event, timestamp, { - source: "canonical", - }), - currentTail, - ); - } - - if (cursor && !cursorsEqual(currentCursor, cursor)) { - nextCursor = cursor; - cursorChanged = true; - } - - if (gapCursor) { - sideEffects.push({ type: "catch_up", cursor: gapCursor }); - } - } - - sideEffects.push({ type: "flush_pending_updates" }); - - const shouldResolveDeferredInit = shouldResolveTimelineInit({ - hasActiveInitDeferred, - isInitializing, - initRequestDirection, - responseDirection: payload.direction, - }); - const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred); - - return { - tail: nextTail, - head: nextHead, - cursor: nextCursor, - cursorChanged, - initResolution: shouldResolveDeferredInit ? "resolve" : null, - clearInitializing, - error: null, - sideEffects, - }; -} - export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input; + const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = + input; let shouldApplyStreamEvent = true; let nextTimelineCursor: TimelineCursor | null = null; let cursorChanged = false; const sideEffects: AgentStreamReducerSideEffect[] = []; - if (event.type === "timeline" && typeof seq === "number") { + // ------------------------------------------------------------------ + // Timeline sequencing gate + // ------------------------------------------------------------------ + if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") { const decision = classifySessionTimelineSeq({ - cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null, + cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null, + epoch, seq, }); - if (decision === "gap") { + if (decision === "init") { + nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq }; + cursorChanged = true; + } else if (decision === "accept") { + nextTimelineCursor = { + ...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }), + epoch, + endSeq: seq, + }; + cursorChanged = true; + } else if (decision === "gap") { shouldApplyStreamEvent = false; if (currentCursor) { sideEffects.push({ type: "catch_up", - cursor: { endSeq: currentCursor.endSeq }, + cursor: { + epoch: currentCursor.epoch, + endSeq: currentCursor.endSeq, + }, }); } - } else if (decision === "drop_stale") { - shouldApplyStreamEvent = false; } else { - nextTimelineCursor = - decision === "init" - ? { startSeq: seq, endSeq: seq } - : { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq }; - cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor); + // drop_stale or drop_epoch + shouldApplyStreamEvent = false; } } + // ------------------------------------------------------------------ + // Apply stream event to tail/head + // ------------------------------------------------------------------ const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent ? applyStreamEvent({ tail: currentTail, @@ -334,6 +390,9 @@ export function processAgentStreamEvent( changedHead: false, }; + // ------------------------------------------------------------------ + // Optimistic lifecycle status + // ------------------------------------------------------------------ let agentPatch: AgentPatch | null = null; let agentChanged = false; diff --git a/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts b/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts index cc8b98ce0..e233b8742 100644 --- a/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts +++ b/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts @@ -2,42 +2,26 @@ import { describe, expect, it } from "vitest"; import { classifySessionTimelineSeq } from "./session-timeline-seq-gate"; import { deriveBootstrapTailTimelinePolicy, - deriveInitialTimelineRequest, shouldResolveTimelineInit, } from "./session-timeline-bootstrap-policy"; -describe("deriveInitialTimelineRequest", () => { - it("uses tail bootstrap when history has not synced yet", () => { - expect( - deriveInitialTimelineRequest({ - cursor: { seq: 42 }, - hasAuthoritativeHistory: false, - initialTimelineLimit: 200, - }), - ).toEqual({ - direction: "tail", - limit: 200, - }); - }); - - it("uses catch-up after the committed cursor once history is synced", () => { - expect( - deriveInitialTimelineRequest({ - cursor: { seq: 42 }, - hasAuthoritativeHistory: true, - initialTimelineLimit: 200, - }), - ).toEqual({ - direction: "after", - cursor: { seq: 42 }, - limit: 0, - }); - }); -}); - describe("deriveBootstrapTailTimelinePolicy", () => { + it("always replaces on explicit reset without catch-up cursor", () => { + const policy = deriveBootstrapTailTimelinePolicy({ + direction: "after", + reset: true, + epoch: "epoch-1", + endCursor: { seq: 200 }, + isInitializing: false, + hasActiveInitDeferred: false, + }); + + expect(policy.replace).toBe(true); + expect(policy.catchUpCursor).toBeNull(); + }); + it("forces baseline replace and canonical catch-up for init tail race", () => { - const advancedCursor = { endSeq: 205 }; + const advancedCursor = { epoch: "epoch-1", endSeq: 205 }; const tailSeqStart = 101; const tailSeqEnd = 200; @@ -45,6 +29,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => { for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) { const decision = classifySessionTimelineSeq({ cursor: advancedCursor, + epoch: "epoch-1", seq, }); if (decision === "accept" || decision === "init") { @@ -55,13 +40,16 @@ describe("deriveBootstrapTailTimelinePolicy", () => { const policy = deriveBootstrapTailTimelinePolicy({ direction: "tail", - endSeq: 200, + reset: false, + epoch: "epoch-1", + endCursor: { seq: 200 }, isInitializing: true, hasActiveInitDeferred: true, }); expect(policy.replace).toBe(true); expect(policy.catchUpCursor).toEqual({ + epoch: "epoch-1", endSeq: 200, }); }); @@ -69,7 +57,9 @@ describe("deriveBootstrapTailTimelinePolicy", () => { it("does not replace non-bootstrap, non-reset responses", () => { const policy = deriveBootstrapTailTimelinePolicy({ direction: "tail", - endSeq: 200, + reset: false, + epoch: "epoch-1", + endCursor: { seq: 200 }, isInitializing: false, hasActiveInitDeferred: false, }); @@ -87,6 +77,7 @@ describe("shouldResolveTimelineInit", () => { isInitializing: true, initRequestDirection: "tail", responseDirection: "tail", + reset: false, }), ).toBe(true); }); @@ -98,6 +89,7 @@ describe("shouldResolveTimelineInit", () => { isInitializing: true, initRequestDirection: "tail", responseDirection: "after", + reset: false, }), ).toBe(false); }); @@ -109,6 +101,7 @@ describe("shouldResolveTimelineInit", () => { isInitializing: true, initRequestDirection: "after", responseDirection: "after", + reset: false, }), ).toBe(true); }); diff --git a/packages/app/src/contexts/session-timeline-bootstrap-policy.ts b/packages/app/src/contexts/session-timeline-bootstrap-policy.ts index c7ab9ded8..706ab5eee 100644 --- a/packages/app/src/contexts/session-timeline-bootstrap-policy.ts +++ b/packages/app/src/contexts/session-timeline-bootstrap-policy.ts @@ -6,6 +6,7 @@ type BootstrapTailCursor = { } | null; type InitialTimelineCursor = { + epoch: string; seq: number; } | null; @@ -19,37 +20,48 @@ export function deriveInitialTimelineRequest({ initialTimelineLimit: number; }): { direction: "tail" | "after"; - cursor?: { seq: number }; + cursor?: { epoch: string; seq: number }; limit: number; + projection: "canonical"; } { if (!hasAuthoritativeHistory || !cursor) { return { direction: "tail", limit: initialTimelineLimit, + projection: "canonical", }; } return { direction: "after", - cursor: { seq: cursor.seq }, + cursor: { epoch: cursor.epoch, seq: cursor.seq }, limit: 0, + projection: "canonical", }; } export function deriveBootstrapTailTimelinePolicy({ direction, - endSeq, + reset, + epoch, + endCursor, isInitializing, hasActiveInitDeferred, }: { direction: TimelineDirection; - endSeq: number | null; + reset: boolean; + epoch: string; + endCursor: BootstrapTailCursor; isInitializing: boolean; hasActiveInitDeferred: boolean; }): { replace: boolean; - catchUpCursor: { endSeq: number } | null; + catchUpCursor: { epoch: string; endSeq: number } | null; } { + if (reset) { + return { replace: true, catchUpCursor: null }; + } + const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred; if (!isBootstrapTailInit) { return { replace: false, catchUpCursor: null }; @@ -57,7 +69,7 @@ export function deriveBootstrapTailTimelinePolicy({ return { replace: true, - catchUpCursor: typeof endSeq === "number" ? { endSeq } : null, + catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null, }; } @@ -66,14 +78,19 @@ export function shouldResolveTimelineInit({ isInitializing, initRequestDirection, responseDirection, + reset, }: { hasActiveInitDeferred: boolean; isInitializing: boolean; initRequestDirection: InitRequestDirection; responseDirection: TimelineDirection; + reset: boolean; }): boolean { if (!hasActiveInitDeferred || !isInitializing) { return false; } + if (reset) { + return true; + } return responseDirection === initRequestDirection; } diff --git a/packages/app/src/contexts/session-timeline-seq-gate.test.ts b/packages/app/src/contexts/session-timeline-seq-gate.test.ts index 3f827dce8..d18af910a 100644 --- a/packages/app/src/contexts/session-timeline-seq-gate.test.ts +++ b/packages/app/src/contexts/session-timeline-seq-gate.test.ts @@ -5,7 +5,8 @@ describe("classifySessionTimelineSeq", () => { it("accepts contiguous forward seq", () => { expect( classifySessionTimelineSeq({ - cursor: { endSeq: 4 }, + cursor: { epoch: "epoch-1", endSeq: 4 }, + epoch: "epoch-1", seq: 5, }), ).toBe("accept"); @@ -14,7 +15,8 @@ describe("classifySessionTimelineSeq", () => { it("drops stale seq older than the current end", () => { expect( classifySessionTimelineSeq({ - cursor: { endSeq: 8 }, + cursor: { epoch: "epoch-1", endSeq: 8 }, + epoch: "epoch-1", seq: 7, }), ).toBe("drop_stale"); @@ -23,16 +25,28 @@ describe("classifySessionTimelineSeq", () => { it("drops duplicate replay seq equal to the current end", () => { expect( classifySessionTimelineSeq({ - cursor: { endSeq: 8 }, + cursor: { epoch: "epoch-1", endSeq: 8 }, + epoch: "epoch-1", seq: 8, }), ).toBe("drop_stale"); }); + it("drops epoch mismatch", () => { + expect( + classifySessionTimelineSeq({ + cursor: { epoch: "epoch-1", endSeq: 4 }, + epoch: "epoch-2", + seq: 5, + }), + ).toBe("drop_epoch"); + }); + it("initializes when cursor is null", () => { expect( classifySessionTimelineSeq({ cursor: null, + epoch: "epoch-1", seq: 1, }), ).toBe("init"); @@ -41,7 +55,8 @@ describe("classifySessionTimelineSeq", () => { it("classifies forward gaps", () => { expect( classifySessionTimelineSeq({ - cursor: { endSeq: 4 }, + cursor: { epoch: "epoch-1", endSeq: 4 }, + epoch: "epoch-1", seq: 9, }), ).toBe("gap"); diff --git a/packages/app/src/contexts/session-timeline-seq-gate.ts b/packages/app/src/contexts/session-timeline-seq-gate.ts index 1fe15597e..e7e35b567 100644 --- a/packages/app/src/contexts/session-timeline-seq-gate.ts +++ b/packages/app/src/contexts/session-timeline-seq-gate.ts @@ -1,22 +1,28 @@ export type SessionTimelineSeqCursor = | { + epoch: string; endSeq: number; } | null | undefined; -export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init"; +export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init"; export function classifySessionTimelineSeq({ cursor, + epoch, seq, }: { cursor: SessionTimelineSeqCursor; + epoch: string; seq: number; }): SessionTimelineSeqDecision { if (!cursor) { return "init"; } + if (cursor.epoch !== epoch) { + return "drop_epoch"; + } if (seq <= cursor.endSeq) { return "drop_stale"; } diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 43da108e3..dd32a15d6 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest"; import { __private__ } from "./use-agent-initialization"; describe("useAgentInitialization timeline request policy", () => { - it("uses committed tail bootstrap when history has not synced yet", () => { + it("uses canonical tail bootstrap when history has not synced yet", () => { expect( __private__.deriveInitialTimelineRequest({ cursor: { + epoch: "epoch-1", seq: 42, }, hasAuthoritativeHistory: false, @@ -14,10 +15,11 @@ describe("useAgentInitialization timeline request policy", () => { ).toEqual({ direction: "tail", limit: 200, + projection: "canonical", }); }); - it("uses committed tail bootstrap when cursor is missing", () => { + it("uses canonical tail bootstrap when cursor is missing", () => { expect( __private__.deriveInitialTimelineRequest({ cursor: null, @@ -27,13 +29,15 @@ describe("useAgentInitialization timeline request policy", () => { ).toEqual({ direction: "tail", limit: 200, + projection: "canonical", }); }); - it("uses committed catch-up after the current cursor once history is synced", () => { + it("uses canonical catch-up after the current cursor once history is synced", () => { expect( __private__.deriveInitialTimelineRequest({ cursor: { + epoch: "epoch-1", seq: 42, }, hasAuthoritativeHistory: true, @@ -41,8 +45,9 @@ describe("useAgentInitialization timeline request policy", () => { }), ).toEqual({ direction: "after", - cursor: { seq: 42 }, + cursor: { epoch: "epoch-1", seq: 42 }, limit: 0, + projection: "canonical", }); }); @@ -56,6 +61,7 @@ describe("useAgentInitialization timeline request policy", () => { ).toEqual({ direction: "tail", limit: 0, + projection: "canonical", }); }); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index 310231a04..dd1f9aafc 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -60,7 +60,7 @@ export function useAgentInitialization({ const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; const timelineRequest = deriveInitialTimelineRequest({ - cursor: cursor ? { seq: cursor.endSeq } : null, + cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null, hasAuthoritativeHistory, initialTimelineLimit, }); @@ -107,6 +107,7 @@ export function useAgentInitialization({ await client.fetchAgentTimeline(agentId, { direction: "tail", limit: initialTimelineLimit, + projection: "canonical", }); } catch (error) { setAgentInitializing(agentId, false); diff --git a/packages/app/src/stores/provider-recency-store.test.ts b/packages/app/src/stores/provider-recency-store.test.ts deleted file mode 100644 index 3bb8d0b97..000000000 --- a/packages/app/src/stores/provider-recency-store.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@react-native-async-storage/async-storage", () => { - const storage = new Map(); - return { - default: { - getItem: vi.fn(async (key: string) => storage.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - storage.set(key, value); - }), - removeItem: vi.fn(async (key: string) => { - storage.delete(key); - }), - }, - }; -}); - -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; -import { - __providerRecencyStoreTestUtils, - sortProvidersByRecency, - useProviderRecencyStore, -} from "./provider-recency-store"; - -describe("provider-recency-store", () => { - beforeEach(() => { - useProviderRecencyStore.setState({ - recentProviderIds: [], - recordUsage: useProviderRecencyStore.getState().recordUsage, - }); - }); - - it("sorts used providers first and keeps unused providers in default order", () => { - const sorted = sortProvidersByRecency(AGENT_PROVIDER_DEFINITIONS, ["codex"]); - - expect(sorted.map((provider) => provider.id)).toEqual([ - "codex", - ...AGENT_PROVIDER_DEFINITIONS.filter((provider) => provider.id !== "codex").map( - (provider) => provider.id, - ), - ]); - }); - - it("moves the latest provider to the front without duplicating prior entries", () => { - useProviderRecencyStore.getState().recordUsage("codex"); - useProviderRecencyStore.getState().recordUsage("opencode"); - useProviderRecencyStore.getState().recordUsage("codex"); - - expect(useProviderRecencyStore.getState().recentProviderIds).toEqual(["codex", "opencode"]); - }); - - it("filters invalid and duplicate providers during migration", () => { - expect( - __providerRecencyStoreTestUtils.migratePersistedState({ - recentProviderIds: ["codex", "invalid", "codex", "claude"], - }), - ).toEqual({ - recentProviderIds: ["codex", "claude"], - }); - }); -}); diff --git a/packages/app/src/stores/provider-recency-store.ts b/packages/app/src/stores/provider-recency-store.ts deleted file mode 100644 index 5dc48ac58..000000000 --- a/packages/app/src/stores/provider-recency-store.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useMemo } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; -import { - AGENT_PROVIDER_DEFINITIONS, - isValidAgentProvider, - type AgentProviderDefinition, -} from "@server/server/agent/provider-manifest"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; - -const PROVIDER_RECENCY_STORE_VERSION = 1; - -interface ProviderRecencyStoreState { - recentProviderIds: AgentProvider[]; - recordUsage: (providerId: AgentProvider) => void; -} - -function sanitizeRecentProviderIds(providerIds: readonly string[] | undefined): AgentProvider[] { - if (!providerIds || providerIds.length === 0) { - return []; - } - - const seen = new Set(); - const sanitized: AgentProvider[] = []; - for (const providerId of providerIds) { - if (!isValidAgentProvider(providerId)) { - continue; - } - if (seen.has(providerId)) { - continue; - } - seen.add(providerId); - sanitized.push(providerId); - } - return sanitized; -} - -export function sortProvidersByRecency( - providers: readonly T[], - recentProviderIds: readonly string[], -): T[] { - if (providers.length <= 1) { - return [...providers]; - } - - const recentRank = new Map(); - for (const providerId of recentProviderIds) { - if (recentRank.has(providerId)) { - continue; - } - recentRank.set(providerId, recentRank.size); - } - - return providers - .map((provider, defaultIndex) => ({ - provider, - defaultIndex, - recentIndex: recentRank.get(provider.id) ?? Number.POSITIVE_INFINITY, - })) - .sort((left, right) => { - if (left.recentIndex !== right.recentIndex) { - return left.recentIndex - right.recentIndex; - } - return left.defaultIndex - right.defaultIndex; - }) - .map((entry) => entry.provider); -} - -function migratePersistedState( - state: unknown, -): Pick { - const record = state as { recentProviderIds?: string[] } | null | undefined; - return { - recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds), - }; -} - -export const useProviderRecencyStore = create()( - persist( - (set) => ({ - recentProviderIds: [], - recordUsage: (providerId) => { - if (!isValidAgentProvider(providerId)) { - return; - } - - set((state) => ({ - recentProviderIds: [ - providerId, - ...state.recentProviderIds.filter((id) => id !== providerId), - ], - })); - }, - }), - { - name: "agent-provider-recency", - version: PROVIDER_RECENCY_STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - recentProviderIds: state.recentProviderIds, - }), - migrate: (persistedState) => migratePersistedState(persistedState), - }, - ), -); - -export function useProviderRecency( - availableProviders: readonly AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS, -): { - providers: AgentProviderDefinition[]; - recordUsage: (providerId: AgentProvider) => void; -} { - const recentProviderIds = useProviderRecencyStore((state) => state.recentProviderIds); - const recordUsage = useProviderRecencyStore((state) => state.recordUsage); - - const providers = useMemo( - () => sortProvidersByRecency(availableProviders, recentProviderIds), - [availableProviders, recentProviderIds], - ); - - return { - providers, - recordUsage, - }; -} - -export const __providerRecencyStoreTestUtils = { - migratePersistedState, - sanitizeRecentProviderIds, -}; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c77e9d720..74bec9bf5 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -212,6 +212,7 @@ export type DaemonServerInfo = { }; export interface AgentTimelineCursorState { + epoch: string; startSeq: number; endSeq: number; } diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 677667a2e..86f979870 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -729,7 +729,7 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] { /** * Flush head items to tail, avoiding duplicates. */ -function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] { +export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] { if (head.length === 0) { return tail; } diff --git a/packages/cli/src/utils/timeline.ts b/packages/cli/src/utils/timeline.ts index 2390b00a5..e49b0244e 100644 --- a/packages/cli/src/utils/timeline.ts +++ b/packages/cli/src/utils/timeline.ts @@ -11,6 +11,7 @@ export async function fetchProjectedTimelineItems( const timeline = await input.client.fetchAgentTimeline(input.agentId, { direction: "tail", limit: 0, + projection: "projected", }); return timeline.entries.map((entry) => entry.item); } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 04cc58d90..e51419cc4 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -2032,15 +2032,24 @@ describe("DaemonClient", () => { agentId: "agent_cli", agent: null, direction: "tail", - startSeq: 1, - endSeq: 1, + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 1 }, hasOlder: false, hasNewer: false, entries: [ { timestamp: "2026-02-08T20:20:00.000Z", provider: "codex", - seq: 1, + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], item: { type: "tool_call", callId: "call_cli_snapshot", @@ -2117,15 +2126,24 @@ describe("DaemonClient", () => { agentId: "agent_cli", agent: null, direction: "tail", - startSeq: 1, - endSeq: 1, + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 1 }, hasOlder: false, hasNewer: false, entries: [ { timestamp: "2026-02-08T20:20:00.000Z", provider: "codex", - seq: 1, + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], item: { type: "tool_call", callId: "call_cli_invalid", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 81019f555..3ebe2ed9a 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -152,6 +152,7 @@ export type DaemonEvent = event: AgentStreamEventPayload; timestamp: string; seq?: number; + epoch?: string; } | { type: "status"; payload: { status: string } & Record } | { type: "agent_deleted"; agentId: string } @@ -336,13 +337,13 @@ type ScheduleDeletePayload = Extract< export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; -export type FetchAgentTimelineCursor = NonNullable< - Extract["cursor"] ->; +export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; +export type FetchAgentTimelineCursor = NonNullable; export type FetchAgentTimelineOptions = { direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; + projection?: FetchAgentTimelineProjection; requestId?: string; }; @@ -1673,6 +1674,7 @@ export class DaemonClient { ...(options.direction ? { direction: options.direction } : {}), ...(options.cursor ? { cursor: options.cursor } : {}), ...(typeof options.limit === "number" ? { limit: options.limit } : {}), + ...(options.projection ? { projection: options.projection } : {}), }); const payload = await this.sendRequest({ @@ -3899,6 +3901,7 @@ export class DaemonClient { event: msg.payload.event, timestamp: msg.payload.timestamp, ...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}), + ...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}), }; case "status": return { type: "status", payload: msg.payload }; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 8aba788a1..6904d529f 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1273,7 +1273,7 @@ describe("AgentManager", () => { } }); - test("fetchTimeline returns committed rows after a known seq without reset metadata", async () => { + test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -1291,42 +1291,40 @@ describe("AgentManager", () => { cwd: workdir, }); - for (let seq = 1; seq <= 120; seq += 1) { - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: `committed row ${seq}`, - }); - } - - const baseline = await manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, - }); - expect(baseline.rows).toHaveLength(120); - - await manager.emitLiveTimelineItem(snapshot.id, { + await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", - text: "partial reply", + text: "one", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", - text: "finalized reply", + text: "two", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "three", }); - const result = await manager.fetchTimeline(snapshot.id, { + const baseline = manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 2, + }); + expect(baseline.rows).toHaveLength(2); + + const result = manager.fetchTimeline(snapshot.id, { direction: "after", cursor: { - seq: 120, + epoch: "stale-epoch", + seq: baseline.rows[baseline.rows.length - 1]!.seq, }, - limit: 0, + limit: 1, }); - expect(result.rows).toHaveLength(1); - expect(result.rows[0]?.seq).toBe(121); - expect(result.rows[0]?.item).toEqual({ - type: "assistant_message", - text: "finalized reply", - }); + expect(result.reset).toBe(true); + expect(result.staleCursor).toBe(true); + expect(result.gap).toBe(false); + expect(result.rows).toHaveLength(3); + expect(result.rows[0]?.seq).toBe(1); + expect(result.rows[result.rows.length - 1]?.seq).toBe(3); }); test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { @@ -1448,6 +1446,7 @@ describe("AgentManager", () => { const streamEvents: Array<{ seq?: number; + epoch?: string; eventType?: string; itemType?: string; text?: string; @@ -1459,6 +1458,7 @@ describe("AgentManager", () => { } streamEvents.push({ seq: event.seq, + epoch: event.epoch, eventType: event.event.type, itemType: event.event.type === "timeline" ? event.event.item.type : undefined, text: @@ -1487,12 +1487,14 @@ describe("AgentManager", () => { itemType: "assistant_message", text: "final ", seq: 1, + epoch: expect.any(String), }); expect(assistantTimelineEvents[1]).toMatchObject({ eventType: "timeline", itemType: "assistant_message", text: "reply", seq: 2, + epoch: expect.any(String), }); expect(manager.getTimeline(snapshot.id)).toEqual([ @@ -1510,6 +1512,8 @@ describe("AgentManager", () => { limit: 0, }); expect(fetched.rows).toHaveLength(2); + expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch); + expect(assistantTimelineEvents[1]?.epoch).toBe(fetched.epoch); expect(fetched.rows[0]?.item).toEqual({ type: "assistant_message", text: "final ", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c0dbd729d..39f9673a1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -63,6 +63,7 @@ export type AgentManagerEvent = agentId: string; event: AgentStreamEvent; seq?: number; + epoch?: string; }; export type AgentSubscriber = (event: AgentManagerEvent) => void; @@ -584,18 +585,8 @@ export class AgentManager { return this.timelineStore.getRows(id); } - async fetchTimeline( - id: string, - options?: AgentTimelineFetchOptions, - ): Promise { + fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { this.requireAgent(id); - const agent = this.agents.get(id); - if (agent && !agent.historyPrimed) { - await this.hydrateTimelineFromLegacyProviderHistory(agent); - } - if (this.durableTimelineStore) { - return await this.durableTimelineStore.fetchCommitted(id, options); - } return this.timelineStore.fetch(id, options); } @@ -1053,6 +1044,7 @@ export class AgentManager { }, { seq: row.seq, + epoch: this.timelineStore.getEpoch(agentId), }, ); if (options?.emitState !== false) { @@ -1073,6 +1065,7 @@ export class AgentManager { }, { seq: row.seq, + epoch: this.timelineStore.getEpoch(agentId), }, ); await this.persistSnapshot(agent); @@ -2404,6 +2397,7 @@ export class AgentManager { timelineRow ? { seq: timelineRow.seq, + epoch: this.timelineStore.getEpoch(agent.id), } : undefined, ); @@ -2445,6 +2439,7 @@ export class AgentManager { }, { seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), }, ); } @@ -2604,7 +2599,7 @@ export class AgentManager { private dispatchStream( agentId: string, event: AgentStreamEvent, - metadata?: { seq?: number }, + metadata?: { seq?: number; epoch?: string }, ): void { this.dispatch({ type: "agent_stream", agentId, event, ...metadata }); } diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 1543baa18..55774d6fc 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -7,6 +7,7 @@ export type AgentTimelineRow = { }; export type AgentTimelineCursor = { + epoch: string; seq: number; }; @@ -30,7 +31,11 @@ export type AgentTimelineWindow = { }; export type AgentTimelineFetchResult = { + epoch: string; direction: AgentTimelineFetchDirection; + reset: boolean; + staleCursor: boolean; + gap: boolean; window: AgentTimelineWindow; hasOlder: boolean; hasNewer: boolean; diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 4ac8531ed..c3b2ecd01 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -8,11 +9,13 @@ import type { export type SeedAgentTimelineOptions = { items?: readonly AgentTimelineItem[]; rows?: readonly AgentTimelineRow[]; + epoch?: string; nextSeq?: number; timestamp?: string; }; type AgentTimelineState = { + epoch: string; rows: AgentTimelineRow[]; nextSeq: number; }; @@ -45,6 +48,7 @@ export class InMemoryAgentTimelineStore { : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1); this.states.set(agentId, { + epoch: options?.epoch ?? randomUUID(), rows, nextSeq, }); @@ -62,6 +66,10 @@ export class InMemoryAgentTimelineStore { return this.requireState(agentId).rows.map(cloneRow); } + getEpoch(agentId: string): string { + return this.requireState(agentId).epoch; + } + fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; @@ -81,9 +89,43 @@ export class InMemoryAgentTimelineStore { nextSeq: state.nextSeq, }; + if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) { + return { + epoch: state.epoch, + direction, + reset: true, + staleCursor: true, + gap: false, + window, + hasOlder: false, + hasNewer: false, + rows: state.rows.map(cloneRow), + }; + } + + const cloneRows = (items: AgentTimelineRow[]) => items.map(cloneRow); + + if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) { + return { + epoch: state.epoch, + direction, + reset: true, + staleCursor: false, + gap: true, + window, + hasOlder: false, + hasNewer: false, + rows: cloneRows(state.rows), + }; + } + if (state.rows.length === 0) { return { + epoch: state.epoch, direction, + reset: false, + staleCursor: false, + gap: false, window, hasOlder: false, hasNewer: false, @@ -97,11 +139,15 @@ export class InMemoryAgentTimelineStore { ? state.rows : state.rows.slice(state.rows.length - limit); return { + epoch: state.epoch, direction, + reset: false, + staleCursor: false, + gap: false, window, hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, hasNewer: false, - rows: selected.map(cloneRow), + rows: cloneRows(selected), }; } @@ -110,7 +156,11 @@ export class InMemoryAgentTimelineStore { const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); if (startIdx < 0) { return { + epoch: state.epoch, direction, + reset: false, + staleCursor: false, + gap: false, window, hasOlder: baseSeq >= minSeq, hasNewer: false, @@ -123,11 +173,15 @@ export class InMemoryAgentTimelineStore { : state.rows.slice(startIdx, startIdx + limit); const lastSelected = selected[selected.length - 1]; return { + epoch: state.epoch, direction, + reset: false, + staleCursor: false, + gap: false, window, hasOlder: selected[0]!.seq > minSeq, hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq), - rows: selected.map(cloneRow), + rows: cloneRows(selected), }; } @@ -139,11 +193,15 @@ export class InMemoryAgentTimelineStore { ? boundedRows : boundedRows.slice(boundedRows.length - limit); return { + epoch: state.epoch, direction, + reset: false, + staleCursor: false, + gap: false, window, hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, hasNewer: endExclusive >= 0, - rows: selected.map(cloneRow), + rows: cloneRows(selected), }; } diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 4eed75478..5884fd8e9 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -141,27 +141,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModel: "gpt-5.4-mini", }, }, - { - id: "gemini", - label: "Gemini CLI", - description: "Google's coding agent CLI", - defaultModeId: null, - modes: [], - }, - { - id: "amp", - label: "AMP", - description: "Sourcegraph's coding agent CLI", - defaultModeId: null, - modes: [], - }, - { - id: "aider", - label: "Aider", - description: "Paul Gauthier's coding assistant CLI", - defaultModeId: null, - modes: [], - }, { id: "copilot", label: "Copilot", diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index baf42c3a3..0707a8534 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -20,13 +20,9 @@ import type { ProviderProfileModel, ProviderRuntimeSettings, } from "./provider-launch-config.js"; - -import { AiderAgentClient } from "./providers/aider-agent.js"; -import { AmpAgentClient } from "./providers/amp-agent.js"; import { ClaudeAgentClient } from "./providers/claude-agent.js"; import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; -import { GeminiAgentClient } from "./providers/gemini-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js"; import { PiACPAgentClient } from "./providers/pi-acp-agent.js"; @@ -72,9 +68,6 @@ const PROVIDER_CLIENT_FACTORIES: Record = { runtimeSettings, }), codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings), - gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings), - amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings), - aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings), copilot: (logger, runtimeSettings) => new CopilotACPAgentClient({ logger, diff --git a/packages/server/src/server/agent/providers/aider-agent.ts b/packages/server/src/server/agent/providers/aider-agent.ts deleted file mode 100644 index 2b063babb..000000000 --- a/packages/server/src/server/agent/providers/aider-agent.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { existsSync } from "node:fs"; - -import type { - AgentCapabilityFlags, - AgentClient, - AgentLaunchContext, - AgentModelDefinition, - AgentPersistenceHandle, - AgentSession, - AgentSessionConfig, - ListModelsOptions, -} from "../agent-sdk-types.js"; -import { - findExecutable, - isProviderCommandAvailable, - type ProviderRuntimeSettings, -} from "../provider-launch-config.js"; - -const AIDER_PROVIDER = "aider" as const; - -const AIDER_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - -function resolveAiderBinary(): string { - const found = findExecutable("aider"); - if (found) { - return found; - } - throw new Error( - "Aider binary not found. Install Aider and ensure 'aider' is available in your shell PATH.", - ); -} - -function createUnsupportedSessionError(): Error { - return new Error("Aider does not support session-backed agents in Paseo."); -} - -export class AiderAgentClient implements AgentClient { - readonly provider = AIDER_PROVIDER; - readonly capabilities = AIDER_CAPABILITIES; - - constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {} - - async createSession( - _config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, - ): Promise { - throw createUnsupportedSessionError(); - } - - async resumeSession( - _handle: AgentPersistenceHandle, - _overrides?: Partial, - _launchContext?: AgentLaunchContext, - ): Promise { - throw createUnsupportedSessionError(); - } - - async listModels(_options?: ListModelsOptions): Promise { - return []; - } - - async isAvailable(): Promise { - if (this.runtimeSettings?.command?.mode === "replace") { - return existsSync(this.runtimeSettings.command.argv[0]); - } - return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary); - } -} diff --git a/packages/server/src/server/agent/providers/amp-agent.ts b/packages/server/src/server/agent/providers/amp-agent.ts deleted file mode 100644 index a676db808..000000000 --- a/packages/server/src/server/agent/providers/amp-agent.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { existsSync } from "node:fs"; - -import type { - AgentCapabilityFlags, - AgentClient, - AgentLaunchContext, - AgentModelDefinition, - AgentPersistenceHandle, - AgentSession, - AgentSessionConfig, - ListModelsOptions, -} from "../agent-sdk-types.js"; -import { - findExecutable, - isProviderCommandAvailable, - type ProviderRuntimeSettings, -} from "../provider-launch-config.js"; - -const AMP_PROVIDER = "amp" as const; - -const AMP_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - -function resolveAmpBinary(): string { - const found = findExecutable("amp"); - if (found) { - return found; - } - throw new Error( - "AMP binary not found. Install AMP and ensure 'amp' is available in your shell PATH.", - ); -} - -function createUnsupportedSessionError(): Error { - return new Error("AMP does not support session-backed agents in Paseo."); -} - -export class AmpAgentClient implements AgentClient { - readonly provider = AMP_PROVIDER; - readonly capabilities = AMP_CAPABILITIES; - - constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {} - - async createSession( - _config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, - ): Promise { - throw createUnsupportedSessionError(); - } - - async resumeSession( - _handle: AgentPersistenceHandle, - _overrides?: Partial, - _launchContext?: AgentLaunchContext, - ): Promise { - throw createUnsupportedSessionError(); - } - - async listModels(_options?: ListModelsOptions): Promise { - return []; - } - - async isAvailable(): Promise { - if (this.runtimeSettings?.command?.mode === "replace") { - return existsSync(this.runtimeSettings.command.argv[0]); - } - return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary); - } -} diff --git a/packages/server/src/server/agent/providers/gemini-agent.ts b/packages/server/src/server/agent/providers/gemini-agent.ts deleted file mode 100644 index 61dd18a17..000000000 --- a/packages/server/src/server/agent/providers/gemini-agent.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { existsSync } from "node:fs"; - -import type { - AgentCapabilityFlags, - AgentClient, - AgentLaunchContext, - AgentModelDefinition, - AgentPersistenceHandle, - AgentSession, - AgentSessionConfig, - ListModelsOptions, -} from "../agent-sdk-types.js"; -import { - findExecutable, - isProviderCommandAvailable, - type ProviderRuntimeSettings, -} from "../provider-launch-config.js"; - -const GEMINI_PROVIDER = "gemini" as const; - -const GEMINI_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - -function resolveGeminiBinary(): string { - const found = findExecutable("gemini"); - if (found) { - return found; - } - throw new Error( - "Gemini CLI binary not found. Install Gemini CLI and ensure 'gemini' is available in your shell PATH.", - ); -} - -function createUnsupportedSessionError(): Error { - return new Error("Gemini CLI does not support session-backed agents in Paseo."); -} - -export class GeminiAgentClient implements AgentClient { - readonly provider = GEMINI_PROVIDER; - readonly capabilities = GEMINI_CAPABILITIES; - - constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {} - - async createSession( - _config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, - ): Promise { - throw createUnsupportedSessionError(); - } - - async resumeSession( - _handle: AgentPersistenceHandle, - _overrides?: Partial, - _launchContext?: AgentLaunchContext, - ): Promise { - throw createUnsupportedSessionError(); - } - - async listModels(_options?: ListModelsOptions): Promise { - return []; - } - - async isAvailable(): Promise { - if (this.runtimeSettings?.command?.mode === "replace") { - return existsSync(this.runtimeSettings.command.argv[0]); - } - return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary); - } -} diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 5fe1683d7..a90f5852f 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -29,9 +29,10 @@ async function waitFor( } } -function isSeqLessAssistantTimeline( +function isLiveAssistantTimeline( message: SessionOutboundMessage, agentId: string, + epoch?: string, text?: string, ): boolean { return ( @@ -40,6 +41,8 @@ function isSeqLessAssistantTimeline( message.payload.event.type === "timeline" && message.payload.event.item.type === "assistant_message" && message.payload.seq === undefined && + typeof message.payload.epoch === "string" && + (epoch === undefined || message.payload.epoch === epoch) && (text === undefined || message.payload.event.item.text === text) ); } @@ -73,6 +76,14 @@ describe("daemon E2E - timeline reconnect contract", () => { text: `committed row ${seq}`, }); } + const baseline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const epoch = baseline.epoch; + expect(epoch).not.toBe(""); + expect(baseline.endCursor?.epoch).toBe(epoch); primaryCollector.clear(); await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { @@ -81,7 +92,7 @@ describe("daemon E2E - timeline reconnect contract", () => { }); await waitFor(() => primaryCollector.messages.some((message) => - isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), ), ); @@ -105,18 +116,26 @@ describe("daemon E2E - timeline reconnect contract", () => { expect( reconnectCollector.messages.some((message) => - isSeqLessAssistantTimeline(message, agent.id), + isLiveAssistantTimeline(message, agent.id, epoch), ), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { direction: "after", - cursor: { seq: 120 }, + cursor: { epoch, seq: 120 }, limit: 0, + projection: "canonical", }); + expect(catchUp.epoch).toBe(epoch); + expect(catchUp.reset).toBe(false); + expect(catchUp.staleCursor).toBe(false); + expect(catchUp.gap).toBe(false); expect(catchUp.entries).toHaveLength(1); - expect(catchUp.entries[0]?.seq).toBe(121); + expect(catchUp.startCursor).toEqual({ epoch, seq: 121 }); + expect(catchUp.endCursor).toEqual({ epoch, seq: 121 }); + expect(catchUp.entries[0]?.seqStart).toBe(121); + expect(catchUp.entries[0]?.seqEnd).toBe(121); expect(catchUp.entries[0]?.item).toEqual({ type: "assistant_message", text: "finalized while disconnected", @@ -149,6 +168,14 @@ describe("daemon E2E - timeline reconnect contract", () => { text: `committed row ${seq}`, }); } + const baseline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const epoch = baseline.epoch; + expect(epoch).not.toBe(""); + expect(baseline.endCursor?.epoch).toBe(epoch); primaryCollector.clear(); await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { @@ -157,7 +184,7 @@ describe("daemon E2E - timeline reconnect contract", () => { }); await waitFor(() => primaryCollector.messages.some((message) => - isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), ), ); @@ -176,17 +203,24 @@ describe("daemon E2E - timeline reconnect contract", () => { expect( reconnectCollector.messages.some((message) => - isSeqLessAssistantTimeline(message, agent.id), + isLiveAssistantTimeline(message, agent.id, epoch), ), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { direction: "after", - cursor: { seq: 120 }, + cursor: { epoch, seq: 120 }, limit: 0, + projection: "canonical", }); + expect(catchUp.epoch).toBe(epoch); + expect(catchUp.reset).toBe(false); + expect(catchUp.staleCursor).toBe(false); + expect(catchUp.gap).toBe(false); expect(catchUp.entries).toHaveLength(0); + expect(catchUp.startCursor).toBeNull(); + expect(catchUp.endCursor).toBeNull(); reconnectCollector.clear(); await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { @@ -195,7 +229,7 @@ describe("daemon E2E - timeline reconnect contract", () => { }); await waitFor(() => reconnectCollector.messages.some((message) => - isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"), + isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"), ), ); } finally { diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index ae46e9d72..04e1b0369 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -38,6 +38,7 @@ describe("daemon E2E - timeline window", () => { const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1, + projection: "canonical", }); const assistantTexts = timeline.entries @@ -45,7 +46,7 @@ describe("daemon E2E - timeline window", () => { .map((entry) => entry.item.text); expect(assistantTexts).toEqual([expected]); - expect(timeline.startSeq).toBe(timeline.endSeq); + expect(timeline.startCursor?.seq).toBe(timeline.endCursor?.seq); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -71,6 +72,7 @@ describe("daemon E2E - timeline window", () => { const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1, + projection: "canonical", }); const assistantTexts = timeline.entries @@ -79,7 +81,7 @@ describe("daemon E2E - timeline window", () => { expect(assistantTexts.join("")).toBe(expected); expect(timeline.hasOlder).toBe(true); - expect(timeline.startSeq).toBeGreaterThan(1); + expect(timeline.startCursor?.seq).toBeGreaterThan(1); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 3878e5648..d1ce731fc 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; +import type { ManagedAgent } from "./agent/agent-manager.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence, @@ -173,16 +174,16 @@ describe("persistence hooks", () => { test("buildSessionConfig accepts providers from the canonical manifest", () => { const record = createRecord({ - provider: "gemini", + provider: "claude", persistence: { - provider: "gemini", + provider: "claude", sessionId: "session-123", }, config: {}, }); expect(buildSessionConfig(record)).toMatchObject({ - provider: "gemini", + provider: "claude", cwd: "/tmp/project", }); }); diff --git a/packages/server/src/server/provider-history-compatibility-boundary.test.ts b/packages/server/src/server/provider-history-compatibility-boundary.test.ts deleted file mode 100644 index df3e50ff9..000000000 --- a/packages/server/src/server/provider-history-compatibility-boundary.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, test } from "vitest"; - -describe("agent loading boundary", () => { - test("session runtime code does not directly hydrate provider history", () => { - const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8"); - const agentLoadingSource = readFileSync( - new URL("./agent-loading-service.ts", import.meta.url), - "utf8", - ); - - expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); - expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/); - }); -}); diff --git a/packages/server/src/server/provider-history-compatibility-service.test.ts b/packages/server/src/server/provider-history-compatibility-service.test.ts deleted file mode 100644 index f4d3829c3..000000000 --- a/packages/server/src/server/provider-history-compatibility-service.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import pino from "pino"; -import { describe, expect, test, vi } from "vitest"; - -import { AgentLoadingService } from "./agent-loading-service.js"; -import { AgentManager } from "./agent/agent-manager.js"; -import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; -import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; - -function createStoredAgentRecord(overrides?: Partial): StoredAgentRecord { - const now = "2026-03-25T00:00:00.000Z"; - return { - id: "agent-compat-1", - provider: "codex", - cwd: "/tmp/project", - createdAt: now, - updatedAt: now, - title: null, - labels: {}, - lastStatus: "idle", - config: { - model: "gpt-5.1-codex-mini", - }, - persistence: { - provider: "codex", - sessionId: "provider-session-1", - }, - ...overrides, - }; -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function createCompatibilitySnapshot(overrides?: Partial>) { - return { - id: "agent-compat-1", - provider: "codex", - cwd: "/tmp/project", - persistence: { - provider: "codex", - sessionId: "provider-session-1", - }, - ...overrides, - }; -} - -describe("AgentLoadingService", () => { - test("ensureAgentLoaded succeeds when provider history is absent", async () => { - const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-")); - const logger = pino({ level: "silent" }); - - try { - const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger); - const manager = new AgentManager({ - clients: createTestAgentClients(), - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000302", - }); - const service = new AgentLoadingService({ - agentManager: manager as any, - agentStorage: storage as any, - logger, - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workspaceRoot, - model: "gpt-5.1-codex-mini", - }); - await manager.flush(); - await storage.flush(); - await manager.closeAgent(snapshot.id); - - const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id }); - - expect(loaded.id).toBe(snapshot.id); - expect(manager.getTimeline(snapshot.id)).toEqual([]); - } finally { - rmSync(workspaceRoot, { recursive: true, force: true }); - } - }); - - test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => { - const deferred = createDeferred(); - let currentAgent: any = null; - const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" }); - const agentStorage = { - get: vi.fn(async () => - createStoredAgentRecord({ - id: "agent-compat-dedupe", - cwd: "/tmp/dedupe", - persistence: { - provider: "codex", - sessionId: "provider-session-dedupe", - }, - }), - ), - }; - const agentManager = { - getAgent: vi.fn(() => currentAgent), - resumeAgentFromPersistence: vi.fn(async () => deferred.promise), - createAgent: vi.fn(), - reloadAgentSession: vi.fn(), - }; - const logger = { - child: () => logger, - info: vi.fn(), - warn: vi.fn(), - }; - const service = new AgentLoadingService({ - agentManager: agentManager as any, - agentStorage: agentStorage as any, - logger: logger as any, - }); - - const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" }); - const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" }); - deferred.resolve(snapshot); - - const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]); - - expect(firstResult).toEqual(snapshot); - expect(secondResult).toEqual(snapshot); - expect(agentStorage.get).toHaveBeenCalledTimes(1); - expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1); - }); - - test("resumeAgent delegates to manager resume", async () => { - const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" }); - const agentManager = { - getAgent: vi.fn(() => null), - resumeAgentFromPersistence: vi.fn(async () => snapshot), - createAgent: vi.fn(), - reloadAgentSession: vi.fn(), - }; - const logger = { - child: () => logger, - info: vi.fn(), - warn: vi.fn(), - }; - const service = new AgentLoadingService({ - agentManager: agentManager as any, - agentStorage: { - get: async () => null, - } as any, - logger: logger as any, - }); - - const result = await service.resumeAgent({ - handle: { - provider: "codex", - sessionId: "provider-session-resume", - }, - overrides: { - model: "gpt-5.4", - }, - }); - - expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( - { - provider: "codex", - sessionId: "provider-session-resume", - }, - { - model: "gpt-5.4", - }, - ); - expect(result).toEqual(snapshot); - }); - - test("refreshAgent reloads loaded persisted agents", async () => { - const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" }); - const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" }); - let currentAgent: any = existing; - const agentManager = { - getAgent: vi.fn(() => currentAgent), - resumeAgentFromPersistence: vi.fn(), - createAgent: vi.fn(), - reloadAgentSession: vi.fn(async () => { - currentAgent = reloaded; - return reloaded; - }), - }; - const logger = { - child: () => logger, - info: vi.fn(), - warn: vi.fn(), - }; - const service = new AgentLoadingService({ - agentManager: agentManager as any, - agentStorage: { - get: async () => null, - } as any, - logger: logger as any, - }); - - const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" }); - - expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded"); - expect(result).toEqual(reloaded); - }); - - test("refreshAgent keeps loaded non-persisted agents without reloading", async () => { - const existing = createCompatibilitySnapshot({ - id: "agent-compat-refresh-live", - persistence: null, - }); - const agentManager = { - getAgent: vi.fn(() => existing), - resumeAgentFromPersistence: vi.fn(), - createAgent: vi.fn(), - reloadAgentSession: vi.fn(), - }; - const logger = { - child: () => logger, - info: vi.fn(), - warn: vi.fn(), - }; - const service = new AgentLoadingService({ - agentManager: agentManager as any, - agentStorage: { - get: async () => null, - } as any, - logger: logger as any, - }); - - const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" }); - - expect(agentManager.reloadAgentSession).not.toHaveBeenCalled(); - expect(result).toEqual(existing); - }); - - test("refreshAgent resumes unloaded persisted agents", async () => { - const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" }); - const record = createStoredAgentRecord({ - id: "agent-compat-refresh-cold", - cwd: "/tmp/refresh-cold", - persistence: { - provider: "codex", - sessionId: "provider-session-refresh-cold", - }, - }); - const agentManager = { - getAgent: vi.fn(() => null), - resumeAgentFromPersistence: vi.fn(async () => snapshot), - createAgent: vi.fn(), - reloadAgentSession: vi.fn(), - }; - const logger = { - child: () => logger, - info: vi.fn(), - warn: vi.fn(), - }; - const service = new AgentLoadingService({ - agentManager: agentManager as any, - agentStorage: { - get: vi.fn(async () => record), - } as any, - logger: logger as any, - }); - - const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" }); - - expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith( - { - provider: "codex", - sessionId: "provider-session-refresh-cold", - nativeHandle: undefined, - metadata: undefined, - }, - { - cwd: "/tmp/refresh-cold", - modeId: undefined, - model: "gpt-5.1-codex-mini", - thinkingOptionId: undefined, - title: undefined, - extra: undefined, - systemPrompt: undefined, - mcpServers: undefined, - }, - "agent-compat-refresh-cold", - { - createdAt: new Date("2026-03-25T00:00:00.000Z"), - updatedAt: new Date("2026-03-25T00:00:00.000Z"), - lastUserMessageAt: null, - labels: {}, - }, - ); - expect(result).toEqual(snapshot); - }); - - test("refreshAgent preserves the unloaded no-persistence error", async () => { - const service = new AgentLoadingService({ - agentManager: { - getAgent: vi.fn(() => null), - resumeAgentFromPersistence: vi.fn(), - createAgent: vi.fn(), - reloadAgentSession: vi.fn(), - } as any, - agentStorage: { - get: async () => - createStoredAgentRecord({ - id: "agent-compat-no-persistence", - persistence: null, - }), - } as any, - logger: { - child: () => ({ - child: () => null, - info: vi.fn(), - warn: vi.fn(), - }), - info: vi.fn(), - warn: vi.fn(), - } as any, - }); - - await expect(service.refreshAgent({ agentId: "agent-compat-no-persistence" })).rejects.toThrow( - "Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence", - ); - }); -}); diff --git a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts deleted file mode 100644 index 287df24ca..000000000 --- a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; - -import { Session } from "./session.js"; - -function createStoredAgentRecord(overrides?: Partial>) { - return { - id: "agent-1", - provider: "codex", - cwd: "/tmp/project", - createdAt: "2026-03-24T00:00:00.000Z", - updatedAt: "2026-03-24T00:00:00.000Z", - title: null, - labels: {}, - lastStatus: "idle", - config: null, - persistence: { - provider: "codex", - sessionId: "provider-session-1", - }, - archivedAt: null, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - ...overrides, - }; -} - -function createCompatibilitySnapshot(overrides?: Partial>) { - return { - id: "agent-1", - provider: "codex", - cwd: "/tmp/project", - persistence: { - provider: "codex", - sessionId: "provider-session-1", - }, - ...overrides, - }; -} - -function createSessionForOwnershipTests(options?: { - agentLoadingService?: { - ensureAgentLoaded?: (options: { agentId: string }) => Promise; - resumeAgent?: (options: { - handle: { provider: string; sessionId: string }; - overrides?: Record; - }) => Promise; - refreshAgent?: (options: { agentId: string }) => Promise; - }; - storedRecord?: Record | null; - loadedAgent?: Record | null; - timelineRows?: Array<{ seq: number; item: Record; timestamp: Date }>; -}) { - const emitted: Array<{ type: string; payload: unknown }> = []; - const logger = { - child: () => logger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - - const agentManager = { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: vi.fn(() => options?.loadedAgent ?? null), - createAgent: vi.fn(async () => { - throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService"); - }), - resumeAgentFromPersistence: vi.fn(async () => { - throw new Error("Session should delegate persistence resume to AgentLoadingService"); - }), - reloadAgentSession: vi.fn(async () => { - throw new Error("Session should delegate refresh reload to AgentLoadingService"); - }), - hydrateTimelineFromProvider: vi.fn(async () => { - throw new Error("Session should not call hydrateTimelineFromProvider directly"); - }), - fetchTimeline: vi.fn(async () => ({ - rows: options?.timelineRows ?? [], - hasOlder: false, - hasNewer: false, - })), - recordUserMessage: vi.fn(), - waitForAgentRunStart: vi.fn(async () => undefined), - getTimeline: vi.fn(() => []), - }; - - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: logger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: agentManager as any, - agentStorage: { - list: async () => (options?.storedRecord ? [options.storedRecord as any] : []), - get: async () => (options?.storedRecord as any) ?? null, - } as any, - projectRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as any, - workspaceRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, - stt: null, - tts: null, - terminalManager: null, - agentLoadingService: options?.agentLoadingService, - } as any) as any; - - return { session, emitted, agentManager }; -} - -describe("provider history compatibility ownership", () => { - test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => { - const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); - const { session, emitted } = createSessionForOwnershipTests({ - storedRecord: createStoredAgentRecord(), - timelineRows: [ - { - seq: 1, - item: { type: "assistant_message", text: "rehydrated from provider history" }, - timestamp: new Date("2026-03-24T00:00:01.000Z"), - }, - ], - agentLoadingService: { - ensureAgentLoaded, - }, - }); - - session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" })); - - await session.handleMessage({ - type: "fetch_agent_timeline_request", - requestId: "req-fetch", - agentId: "agent-1", - }); - - expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" }); - expect(emitted).toContainEqual({ - type: "fetch_agent_timeline_response", - payload: expect.objectContaining({ - requestId: "req-fetch", - agentId: "agent-1", - error: null, - entries: [ - expect.objectContaining({ - seq: 1, - }), - ], - }), - }); - }); - - test("fetch_agent_timeline_request emits legacy compatibility fields for older clients", async () => { - const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); - const { session, emitted } = createSessionForOwnershipTests({ - storedRecord: createStoredAgentRecord(), - timelineRows: [ - { - seq: 7, - item: { type: "assistant_message", text: "compat row" }, - timestamp: new Date("2026-03-24T00:00:07.000Z"), - }, - ], - agentLoadingService: { - ensureAgentLoaded, - }, - }); - - session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" })); - - await session.handleMessage({ - type: "fetch_agent_timeline_request", - requestId: "req-compat", - agentId: "agent-1", - direction: "tail", - }); - - expect(emitted).toContainEqual({ - type: "fetch_agent_timeline_response", - payload: expect.objectContaining({ - requestId: "req-compat", - agentId: "agent-1", - epoch: "compat:agent-1", - reset: false, - staleCursor: false, - gap: false, - projection: "projected", - startCursor: { seq: 7, epoch: "compat:agent-1" }, - endCursor: { seq: 7, epoch: "compat:agent-1" }, - window: { - minSeq: 7, - maxSeq: 7, - nextSeq: 8, - startSeq: 7, - endSeq: 7, - hasOlder: false, - hasNewer: false, - }, - entries: [ - expect.objectContaining({ - seq: 7, - seqStart: 7, - seqEnd: 7, - sourceSeqRanges: [{ startSeq: 7, endSeq: 7 }], - collapsed: [], - }), - ], - }), - }); - }); - - test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => { - const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot()); - const { session, agentManager, emitted } = createSessionForOwnershipTests({ - storedRecord: createStoredAgentRecord(), - agentLoadingService: { - ensureAgentLoaded, - }, - }); - - session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" })); - session.unarchiveAgentState = vi.fn(async () => true); - session.buildAgentPrompt = vi.fn((text: string) => text); - session.startAgentStream = vi.fn(() => ({ ok: true })); - - await session.handleMessage({ - type: "send_agent_message_request", - requestId: "req-send", - agentId: "agent-1", - text: "hello", - images: [], - messageId: "msg-1", - }); - - expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" }); - expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan( - agentManager.recordUserMessage.mock.invocationCallOrder[0], - ); - expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", { - messageId: "msg-1", - emitState: false, - }); - expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello"); - expect(emitted).toContainEqual({ - type: "send_agent_message_response", - payload: { - requestId: "req-send", - agentId: "agent-1", - accepted: true, - error: null, - }, - }); - }); - - test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => { - const resumeAgent = vi.fn(async () => createCompatibilitySnapshot()); - const { session, emitted } = createSessionForOwnershipTests({ - agentLoadingService: { - resumeAgent, - }, - }); - - session.unarchiveAgentByHandle = vi.fn(async () => undefined); - session.unarchiveAgentState = vi.fn(async () => true); - session.forwardAgentUpdate = vi.fn(async () => undefined); - session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" })); - - await session.handleMessage({ - type: "resume_agent_request", - requestId: "req-resume", - handle: { - provider: "codex", - sessionId: "provider-session-1", - }, - overrides: { - model: "gpt-5.4", - }, - }); - - expect(resumeAgent).toHaveBeenCalledWith({ - handle: { - provider: "codex", - sessionId: "provider-session-1", - }, - overrides: { - model: "gpt-5.4", - }, - }); - expect(emitted).toContainEqual({ - type: "status", - payload: expect.objectContaining({ - status: "agent_resumed", - requestId: "req-resume", - agentId: "agent-1", - }), - }); - }); - - test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => { - const refreshAgent = vi.fn(async () => - createCompatibilitySnapshot({ - persistence: { - provider: "codex", - sessionId: "provider-session-1", - }, - }), - ); - const { session, emitted } = createSessionForOwnershipTests({ - loadedAgent: createCompatibilitySnapshot(), - agentLoadingService: { - refreshAgent, - }, - }); - - session.unarchiveAgentState = vi.fn(async () => true); - session.interruptAgentIfRunning = vi.fn(async () => undefined); - session.forwardAgentUpdate = vi.fn(async () => undefined); - - await session.handleMessage({ - type: "refresh_agent_request", - requestId: "req-refresh-loaded", - agentId: "agent-1", - }); - - expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); - expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" }); - expect(emitted).toContainEqual({ - type: "status", - payload: { - status: "agent_refreshed", - requestId: "req-refresh-loaded", - agentId: "agent-1", - timelineSize: 0, - }, - }); - }); - - test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => { - const refreshAgent = vi.fn(async () => createCompatibilitySnapshot()); - const { session, emitted } = createSessionForOwnershipTests({ - storedRecord: createStoredAgentRecord(), - agentLoadingService: { - refreshAgent, - }, - }); - - session.unarchiveAgentState = vi.fn(async () => true); - session.interruptAgentIfRunning = vi.fn(async () => undefined); - session.forwardAgentUpdate = vi.fn(async () => undefined); - - await session.handleMessage({ - type: "refresh_agent_request", - requestId: "req-refresh-unloaded", - agentId: "agent-1", - }); - - expect(session.interruptAgentIfRunning).not.toHaveBeenCalled(); - expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" }); - expect(emitted).toContainEqual({ - type: "status", - payload: { - status: "agent_refreshed", - requestId: "req-refresh-unloaded", - agentId: "agent-1", - timelineSize: 0, - }, - }); - }); -}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bad1d7b7c..fcc9ffd99 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -92,6 +92,11 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "./agent/timeline-append.js"; +import { + projectTimelineRows, + selectTimelineWindowByProjectedLimit, + type TimelineProjectionMode, +} from "./agent/timeline-projection.js"; import { DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, @@ -1143,6 +1148,7 @@ export class Session { event: serializedEvent, timestamp: new Date().toISOString(), ...(typeof event.seq === "number" ? { seq: event.seq } : {}), + ...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}), } as const; this.emit({ @@ -1705,7 +1711,7 @@ export class Session { break; case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId); + await this.handleCancelAgentRequest(msg.agentId, msg.requestId); break; case "restart_server_request": @@ -1886,7 +1892,7 @@ export class Session { break; case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId); + await this.handleClearAgentAttention(msg.agentId, msg.requestId); break; case "client_heartbeat": @@ -3337,11 +3343,23 @@ export class Session { } } - private async handleCancelAgentRequest(agentId: string): Promise { + private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise { this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`); try { await this.interruptAgentIfRunning(agentId); + if (requestId) { + const agent = this.agentManager.getAgent(agentId); + const payload = agent ? await this.buildAgentPayload(agent) : null; + this.emit({ + type: "cancel_agent_response", + payload: { + requestId, + agentId, + agent: payload, + }, + }); + } } catch (error) { this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); } @@ -4005,11 +4023,32 @@ export class Session { /** * Handle clearing agent attention flag */ - private async handleClearAgentAttention(agentId: string | string[]): Promise { + private async handleClearAgentAttention( + agentId: string | string[], + requestId?: string, + ): Promise { const agentIds = Array.isArray(agentId) ? agentId : [agentId]; try { await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id))); + if (requestId) { + const agents = ( + await Promise.all( + agentIds.map(async (id) => { + const agent = this.agentManager.getAgent(id); + return agent ? this.buildAgentPayload(agent) : null; + }), + ) + ).filter((payload): payload is NonNullable => payload !== null); + this.emit({ + type: "clear_agent_attention_response", + payload: { + requestId, + agentId, + agents, + }, + }); + } } catch (error: any) { this.sessionLogger.error({ err: error, agentIds }, "Failed to clear agent attention"); // Don't throw - this is not critical @@ -6884,10 +6923,17 @@ export class Session { msg: Extract, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); + const projection: TimelineProjectionMode = msg.projection ?? "projected"; const requestedLimit = msg.limit; const limit = requestedLimit ?? (direction === "after" ? 0 : undefined); + const shouldLimitByProjectedWindow = + projection === "canonical" && + direction === "tail" && + typeof requestedLimit === "number" && + requestedLimit > 0; const cursor: AgentTimelineCursor | undefined = msg.cursor ? { + epoch: msg.cursor.epoch, seq: msg.cursor.seq, } : undefined; @@ -6895,46 +6941,86 @@ export class Session { try { const snapshot = await this.ensureAgentLoaded(msg.agentId); const agentPayload = await this.buildAgentPayload(snapshot); - const timeline = await this.agentManager.fetchTimeline(msg.agentId, { + + let timeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit, + limit: + shouldLimitByProjectedWindow && typeof requestedLimit === "number" + ? Math.max(1, Math.floor(requestedLimit)) + : limit, }); - const firstRow = timeline.rows[0]; - const lastRow = timeline.rows[timeline.rows.length - 1]; - const timelineWindow = - timeline.window ?? - (() => { - const minSeq = firstRow?.seq ?? 0; - const maxSeq = lastRow?.seq ?? 0; - return { - minSeq, - maxSeq, - nextSeq: maxSeq > 0 ? maxSeq + 1 : 0, - }; - })(); - const epoch = `compat:${msg.agentId}`; - const startCursor = firstRow ? { seq: firstRow.seq, epoch } : null; - const endCursor = lastRow ? { seq: lastRow.seq, epoch } : null; - const window = { - minSeq: timelineWindow.minSeq, - maxSeq: timelineWindow.maxSeq, - nextSeq: timelineWindow.nextSeq, - startSeq: firstRow?.seq ?? null, - endSeq: lastRow?.seq ?? null, - hasOlder: timeline.hasOlder, - hasNewer: timeline.hasNewer, - }; - const entries = timeline.rows.map((row) => ({ - provider: snapshot.provider, - item: row.item, - timestamp: row.timestamp, - seq: row.seq, - seqStart: row.seq, - seqEnd: row.seq, - sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], - collapsed: [], - })); + let hasOlder = timeline.hasOlder; + let hasNewer = timeline.hasNewer; + let startCursor: { epoch: string; seq: number } | null = null; + let endCursor: { epoch: string; seq: number } | null = null; + let entries: ReturnType; + + if (shouldLimitByProjectedWindow) { + const projectedLimit = Math.max(1, Math.floor(requestedLimit)); + let fetchLimit = projectedLimit; + let projectedWindow = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + provider: snapshot.provider, + direction, + limit: projectedLimit, + collapseToolLifecycle: false, + }); + + while (timeline.hasOlder) { + const needsMoreProjectedEntries = + projectedWindow.projectedEntries.length < projectedLimit; + const firstLoadedRow = timeline.rows[0]; + const firstSelectedRow = projectedWindow.selectedRows[0]; + const startsAtLoadedBoundary = + firstLoadedRow != null && + firstSelectedRow != null && + firstSelectedRow.seq === firstLoadedRow.seq; + const boundaryIsAssistantChunk = + startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message"; + + if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) { + break; + } + + const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1); + const nextFetchLimit = Math.min(maxRows, fetchLimit * 2); + if (nextFetchLimit <= fetchLimit) { + break; + } + + fetchLimit = nextFetchLimit; + timeline = this.agentManager.fetchTimeline(msg.agentId, { + direction, + cursor, + limit: fetchLimit, + }); + projectedWindow = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + provider: snapshot.provider, + direction, + limit: projectedLimit, + collapseToolLifecycle: false, + }); + } + + const selectedRows = projectedWindow.selectedRows; + + entries = projectTimelineRows(selectedRows, snapshot.provider, projection); + + if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) { + startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq }; + endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq }; + hasOlder = projectedWindow.minSeq > timeline.window.minSeq; + hasNewer = false; + } + } else { + const firstRow = timeline.rows[0]; + const lastRow = timeline.rows[timeline.rows.length - 1]; + startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null; + endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null; + entries = projectTimelineRows(timeline.rows, snapshot.provider, projection); + } this.emit({ type: "fetch_agent_timeline_response", @@ -6943,24 +7029,21 @@ export class Session { agentId: msg.agentId, agent: agentPayload, direction, - startSeq: firstRow?.seq ?? null, - endSeq: lastRow?.seq ?? null, - hasOlder: timeline.hasOlder, - hasNewer: timeline.hasNewer, - entries, - error: null, - epoch, - reset: false, - staleCursor: false, - gap: false, - window, + projection, + epoch: timeline.epoch, + reset: timeline.reset, + staleCursor: timeline.staleCursor, + gap: timeline.gap, + window: timeline.window, startCursor, endCursor, - projection: msg.projection ?? "projected", + hasOlder, + hasNewer, + entries, + error: null, }, }); } catch (error) { - const epoch = `compat:${msg.agentId}`; this.sessionLogger.error( { err: error, agentId: msg.agentId }, "Failed to handle fetch_agent_timeline_request", @@ -6972,28 +7055,18 @@ export class Session { agentId: msg.agentId, agent: null, direction, - startSeq: null, - endSeq: null, + projection, + epoch: "", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, + startCursor: null, + endCursor: null, hasOlder: false, hasNewer: false, entries: [], error: error instanceof Error ? error.message : String(error), - epoch, - reset: false, - staleCursor: false, - gap: false, - window: { - minSeq: 0, - maxSeq: 0, - nextSeq: 0, - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }, - startCursor: null, - endCursor: null, - projection: msg.projection ?? "projected", }, }); } diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index e79fe6b47..bfa99d532 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -935,9 +935,6 @@ export function createTestAgentClients(): Record { return { claude: new FakeAgentClient("claude"), codex: new FakeAgentClient("codex"), - gemini: new FakeAgentClient("gemini"), - amp: new FakeAgentClient("amp"), - aider: new FakeAgentClient("aider"), opencode: new FakeAgentClient("opencode"), }; } diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index b645d5d11..c5698c901 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { AgentStreamMessageSchema, - FetchAgentTimelineRequestMessageSchema, FetchAgentTimelineResponseMessageSchema, SessionInboundMessageSchema, SessionOutboundMessageSchema, @@ -18,55 +17,16 @@ describe("shared messages stream parsing", () => { agentId: "agent_live", agent: null, direction: "tail", - startSeq: 1, - endSeq: 2, + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 2 }, hasOlder: false, hasNewer: false, - entries: [ - { - provider: "codex", - item: { type: "assistant_message", text: "hello" }, - timestamp: "2026-02-08T20:10:00.000Z", - seq: 2, - }, - ], - error: null, - }, - }); - - expect(parsed.payload.entries).toHaveLength(1); - expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message"); - }); - - it("parses legacy fetch timeline request baggage at the parser boundary", () => { - const parsed = FetchAgentTimelineRequestMessageSchema.parse({ - type: "fetch_agent_timeline_request", - agentId: "agent_live", - requestId: "req-legacy", - direction: "after", - cursor: { - seq: 12, - epoch: "legacy-epoch", - }, - projection: "canonical", - }); - - expect(parsed.cursor?.epoch).toBe("legacy-epoch"); - expect(parsed.projection).toBe("canonical"); - }); - - it("parses legacy fetch timeline response baggage at the parser boundary", () => { - const parsed = FetchAgentTimelineResponseMessageSchema.parse({ - type: "fetch_agent_timeline_response", - payload: { - requestId: "req-1", - agentId: "agent_live", - agent: null, - direction: "tail", - reset: false, - startCursor: { seq: 1, epoch: "legacy-epoch" }, - endCursor: { seq: 2, epoch: "legacy-epoch" }, - projection: "canonical", entries: [ { provider: "codex", @@ -82,10 +42,8 @@ describe("shared messages stream parsing", () => { }, }); - expect(parsed.payload.startSeq).toBe(1); - expect(parsed.payload.endSeq).toBe(2); - expect(parsed.payload.projection).toBe("canonical"); - expect(parsed.payload.entries[0]?.seq).toBe(2); + expect(parsed.payload.entries).toHaveLength(1); + expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message"); }); it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => { @@ -112,7 +70,6 @@ describe("shared messages stream parsing", () => { payload: { agentId: "agent_live", timestamp: "2026-02-08T20:10:00.000Z", - seq: 12, event: { type: "timeline", provider: "claude", @@ -140,27 +97,6 @@ describe("shared messages stream parsing", () => { } }); - it("parses legacy agent_stream baggage at the parser boundary", () => { - const parsed = AgentStreamMessageSchema.parse({ - type: "agent_stream", - payload: { - agentId: "agent_live", - timestamp: "2026-02-08T20:10:00.000Z", - epoch: "legacy-epoch", - event: { - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "hello", - }, - }, - }, - }); - - expect(parsed.payload.epoch).toBe("legacy-epoch"); - }); - it("parses representative sub_agent tool_call event", () => { const parsed = AgentStreamMessageSchema.parse({ type: "agent_stream", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 8d72782fa..c61832748 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -908,6 +908,7 @@ export const RefreshAgentRequestMessageSchema = z.object({ export const CancelAgentRequestMessageSchema = z.object({ type: z.literal("cancel_agent_request"), agentId: z.string(), + requestId: z.string().optional(), }); export const RestartServerRequestMessageSchema = z.object({ @@ -922,9 +923,8 @@ export const ShutdownServerRequestMessageSchema = z.object({ }); export const AgentTimelineCursorSchema = z.object({ + epoch: z.string(), seq: z.number().int().nonnegative(), - // COMPAT(timeline): retain legacy cursor epoch for older clients. - epoch: z.string().optional(), }); export const FetchAgentTimelineRequestMessageSchema = z.object({ @@ -935,8 +935,8 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ cursor: AgentTimelineCursorSchema.optional(), // 0 means "all matching rows for this query window". limit: z.number().int().nonnegative().optional(), - // COMPAT(timeline): retain removed projection so older clients can still send it. - projection: z.enum(["canonical", "projected"]).optional(), + // Default should be projected for app timeline loading. + projection: z.enum(["projected", "canonical"]).optional(), }); export const SetAgentModeRequestMessageSchema = z.object({ @@ -1367,6 +1367,7 @@ export const FileDownloadTokenRequestSchema = z.object({ export const ClearAgentAttentionMessageSchema = z.object({ type: z.literal("clear_agent_attention"), agentId: z.union([z.string(), z.array(z.string())]), + requestId: z.string().optional(), }); export const ClientHeartbeatMessageSchema = z.object({ @@ -2036,9 +2037,8 @@ export const AgentStreamMessageSchema = z.object({ agentId: z.string(), event: AgentStreamEventPayloadSchema, timestamp: z.string(), - // Present only for committed timeline events. + // Present for timeline events. Maps 1:1 to canonical in-memory timeline rows. seq: z.number().int().nonnegative().optional(), - // COMPAT(timeline): retain removed epoch for older clients. epoch: z.string().optional(), }), }); @@ -2196,72 +2196,63 @@ export const FetchAgentResponseMessageSchema = z.object({ }), }); -const TimelineProjectionKindSchema = z.enum(["assistant_merge", "tool_lifecycle"]); - -const TimelineSeqRangeSchema = z.object({ +const AgentTimelineSeqRangeSchema = z.object({ startSeq: z.number().int().nonnegative(), endSeq: z.number().int().nonnegative(), }); -export const AgentTimelineEntryPayloadSchema = z - .object({ - provider: AgentProviderSchema, - item: AgentTimelineItemPayloadSchema, - timestamp: z.string(), - seq: z.number().int().nonnegative().optional(), - // COMPAT(timeline): retain legacy projected timeline fields. - seqStart: z.number().int().nonnegative().optional(), - seqEnd: z.number().int().nonnegative().optional(), - sourceSeqRanges: z.array(TimelineSeqRangeSchema).optional(), - collapsed: z.array(TimelineProjectionKindSchema).optional(), - }) - .transform((entry, ctx) => { - const seq = entry.seq ?? entry.seqEnd ?? entry.seqStart; - if (typeof seq !== "number") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Agent timeline entry must include seq or legacy seqStart/seqEnd", - }); - return z.NEVER; - } - - return { - ...entry, - seq, - }; - }); +export const AgentTimelineEntryPayloadSchema = z.object({ + provider: AgentProviderSchema, + item: AgentTimelineItemPayloadSchema, + timestamp: z.string(), + seqStart: z.number().int().nonnegative(), + seqEnd: z.number().int().nonnegative(), + sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema), + collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])), +}); export const FetchAgentTimelineResponseMessageSchema = z.object({ type: z.literal("fetch_agent_timeline_response"), - payload: z - .object({ - requestId: z.string(), - agentId: z.string(), - agent: AgentSnapshotPayloadSchema.nullable(), - direction: z.enum(["tail", "before", "after"]), - startSeq: z.number().int().nonnegative().nullable().optional(), - endSeq: z.number().int().nonnegative().nullable().optional(), - hasOlder: z.boolean().optional(), - hasNewer: z.boolean().optional(), - entries: z.array(AgentTimelineEntryPayloadSchema), - error: z.string().nullable(), - // COMPAT(timeline): retain legacy response baggage for older clients. - epoch: z.string().optional(), - reset: z.boolean().optional(), - staleCursor: z.boolean().optional(), - gap: z.unknown().optional(), - window: z.unknown().optional(), - startCursor: AgentTimelineCursorSchema.nullable().optional(), - endCursor: AgentTimelineCursorSchema.nullable().optional(), - projection: z.enum(["canonical", "projected"]).optional(), - }) - .transform((payload) => ({ - ...payload, - startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null, - endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null, - hasOlder: payload.hasOlder ?? false, - hasNewer: payload.hasNewer ?? false, - })), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + direction: z.enum(["tail", "before", "after"]), + projection: z.enum(["projected", "canonical"]), + epoch: z.string(), + reset: z.boolean(), + staleCursor: z.boolean(), + gap: z.boolean(), + window: z.object({ + minSeq: z.number().int().nonnegative(), + maxSeq: z.number().int().nonnegative(), + nextSeq: z.number().int().nonnegative(), + }), + startCursor: AgentTimelineCursorSchema.nullable(), + endCursor: AgentTimelineCursorSchema.nullable(), + hasOlder: z.boolean(), + hasNewer: z.boolean(), + entries: z.array(AgentTimelineEntryPayloadSchema), + error: z.string().nullable(), + }), +}); + +export const CancelAgentResponseMessageSchema = z.object({ + type: z.literal("cancel_agent_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + }), +}); + +export const ClearAgentAttentionResponseMessageSchema = z.object({ + type: z.literal("clear_agent_attention_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string().or(z.array(z.string())), + agents: z.array(AgentSnapshotPayloadSchema), + }), }); export const SendAgentMessageResponseMessageSchema = z.object({ @@ -2960,6 +2951,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + CancelAgentResponseMessageSchema, + ClearAgentAttentionResponseMessageSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, GetDaemonConfigResponseMessageSchema, @@ -3085,6 +3078,7 @@ export type FetchAgentResponseMessage = z.infer; +export type CancelAgentResponseMessage = z.infer; export type SendAgentMessageResponseMessage = z.infer; export type SetVoiceModeResponseMessage = z.infer; export type SetAgentModeResponseMessage = z.infer; @@ -3256,6 +3250,9 @@ export type FileDownloadTokenResponse = z.infer; export type ShutdownServerRequestMessage = z.infer; export type ClearAgentAttentionMessage = z.infer; +export type ClearAgentAttentionResponseMessage = z.infer< + typeof ClearAgentAttentionResponseMessageSchema +>; export type ClientHeartbeatMessage = z.infer; export type ListCommandsRequest = z.infer; export type ListCommandsResponse = z.infer;