diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts index 8d4e773259..14aa4ca95f 100644 --- a/packages/app/e2e/browser/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -11,6 +11,7 @@ import { import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor"; import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { primeAdditionalPage, resetSeededPageState } from "../support/helpers/archive-tab"; async function completeSubmittedTurn( page: Page, @@ -46,26 +47,61 @@ async function expectTurnCompletesNormally( await expectAgentIdle(page); } +async function openAgentOnAnotherClient( + page: Page, + agent: Awaited>, +): Promise { + await primeAdditionalPage(page); + await resetSeededPageState(page); + await openAgentRoute(page, agent); + await expectComposerVisible(page); +} + +/** The prompts the chat shows, in order, each exactly once. */ +async function expectPrompts(page: Page, prompts: string[]): Promise { + const messages = page.getByTestId("user-message"); + await expect(messages).toHaveCount(prompts.length); + await expect(messages).toContainText(prompts); +} + +// A row React remounts loses this attribute; one that stays mounted keeps it. +async function markMounted(row: Locator): Promise { + await row.evaluate((element) => element.setAttribute("data-e2e-mounted", "before-rewind")); +} + +async function expectStillMounted(row: Locator): Promise { + await expect(row).toHaveAttribute("data-e2e-mounted", "before-rewind"); +} + test.describe("Agent message rewind", () => { - test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({ + test("rewinds a prompt in place on every open client and keeps a human draft", async ({ page, }) => { - test.setTimeout(90_000); + test.setTimeout(150_000); const retainedPrompt = "Keep this completed turn after rewind."; const rewoundPrompt = "Remove this completed turn after rewind."; + const nextPrompt = "Continue after the rewind."; const preservedDraft = "Keep this human draft after rewind."; const agent = await seedMockAgentWorkspace({ repoPrefix: "message-rewind-roundtrip-", title: "Message rewind roundtrip", model: "ten-second-stream", }); + const otherClient = await page.context().newPage(); try { const rewindCompletion = await holdRewindCompletion(page, agent.agentId); await openAgentRoute(page, agent); await expectComposerVisible(page); - await completeSubmittedTurn(page, agent, retainedPrompt); + const retainedMessage = await completeSubmittedTurn(page, agent, retainedPrompt); const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt); + + await test.step("a second client opens the same chat", async () => { + await openAgentOnAnotherClient(otherClient, agent); + await expectPrompts(otherClient, [retainedPrompt, rewoundPrompt]); + }); + + await markMounted(retainedMessage); await scrollChatAwayFromBottom(page, { deltaY: -900, minDistanceFromBottom: 300, @@ -73,19 +109,36 @@ test.describe("Agent message rewind", () => { await fillComposerDraft(page, preservedDraft); rewindCompletion.clearTimelineStreamCount(); - const rewind = rewindConversation(page, userMessage, rewoundPrompt); - await rewindCompletion.waitForDelayedResponse(); - await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled(); - expect(rewindCompletion.timelineStreamCount()).toBe(0); - rewindCompletion.release(); - await rewind; + await test.step("the rewind stays pending until the daemon answers", async () => { + const rewind = rewindConversation(page, userMessage, rewoundPrompt); + await rewindCompletion.waitForDelayedResponse(); + await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled(); + expect(rewindCompletion.timelineStreamCount()).toBe(0); + rewindCompletion.release(); + await rewind; + }); - await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount( - 0, - ); - await expectComposerDraft(page, preservedDraft); - await expectNearBottom(page); + await test.step("the rewound turn leaves without reloading the chat", async () => { + await expectPrompts(page, [retainedPrompt]); + await expectStillMounted(retainedMessage); + expect(rewindCompletion.timelineFetchCount()).toBe(0); + await expectComposerDraft(page, preservedDraft); + await expectNearBottom(page); + await expectPrompts(otherClient, [retainedPrompt]); + }); + + await test.step("the next prompt appears once on both clients", async () => { + await completeSubmittedTurn(otherClient, agent, nextPrompt); + await expectPrompts(otherClient, [retainedPrompt, nextPrompt]); + await expectPrompts(page, [retainedPrompt, nextPrompt]); + }); + + await test.step("a reconnecting client loads the rewound conversation", async () => { + await otherClient.reload(); + await expectPrompts(otherClient, [retainedPrompt, nextPrompt]); + }); } finally { + await otherClient.close(); await agent.cleanup(); } }); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 89a1d8c364..5386011252 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -32,6 +32,8 @@ export interface RewindCompletionGate { clearTimelineStreamCount(): void; release(): void; timelineStreamCount(): number; + /** Timeline reads this page sent since the last clear. */ + timelineFetchCount(): number; waitForDelayedResponse(): Promise; } @@ -187,12 +189,20 @@ export async function holdDaemonHydration(page: Page): Promise { let released = false; let timelineStreamCount = 0; + let timelineFetchCount = 0; const delayedForwards: Array<() => void> = []; let resolveDelayedResponse: (() => void) | null = null; const delayedResponse = new Promise((resolve) => { @@ -201,7 +211,16 @@ export async function holdRewindCompletion( await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); - ws.onMessage((message) => server.send(message)); + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if ( + sessionMessage?.type === "fetch_agent_timeline_request" && + sessionMessage.agentId === agentId + ) { + timelineFetchCount += 1; + } + server.send(message); + }); server.onMessage((message) => { const sessionMessage = getSessionMessage(message); const payload = sessionMessage ? getPayload(sessionMessage) : null; @@ -217,11 +236,11 @@ export async function holdRewindCompletion( } if ( !released && - (sessionMessage?.type === "agent.rewind.response" || - sessionMessage?.type === "agent.timeline.replacement") && + sessionMessage && + REWIND_COMPLETION_MESSAGES.has(String(sessionMessage.type)) && payload?.agentId === agentId ) { - // The replacement also signals completion and removes the rewound row/menu. + // The rewind also signals completion and removes the rewound row/menu. // Hold it with the response so the pending-state assertion observes a pending rewind. delayedForwards.push(() => ws.send(message)); if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.(); @@ -234,6 +253,7 @@ export async function holdRewindCompletion( return { clearTimelineStreamCount() { timelineStreamCount = 0; + timelineFetchCount = 0; }, release() { released = true; @@ -242,6 +262,7 @@ export async function holdRewindCompletion( } }, timelineStreamCount: () => timelineStreamCount, + timelineFetchCount: () => timelineFetchCount, waitForDelayedResponse: () => delayedResponse, }; } diff --git a/packages/app/src/agent-stream/chat-find/model.test.ts b/packages/app/src/agent-stream/chat-find/model.test.ts index abc3ade6c6..9dab7b337d 100644 --- a/packages/app/src/agent-stream/chat-find/model.test.ts +++ b/packages/app/src/agent-stream/chat-find/model.test.ts @@ -18,7 +18,7 @@ function message(seq: number, text = "target"): StreamItem { kind: "assistant_message", text, timestamp: new Date(0), - timelineCursor: { epoch: "epoch", seq }, + timelineCursor: { epoch: "epoch", seq, anchorSeq: seq }, }; } @@ -39,7 +39,7 @@ it("loads an older location and searches a response merged across suppressed tas source.map((item, index) => ({ event: { type: "timeline" as const, provider: "claude" as const, item }, timestamp: new Date(0), - timelineCursor: { epoch: "epoch", seq: index + 1 }, + timelineCursor: { epoch: "epoch", seq: index + 1, anchorSeq: index + 1 }, })), { source: "canonical" }, ); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 890f6eaae7..ae2d3385dd 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -139,7 +139,7 @@ describe("useChatOutline", () => { kind: "user_message", text: "live prompt", timestamp: new Date(seq), - timelineCursor: { epoch: "epoch-1", seq }, + timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq }, }); rerender({ tail: [loadedPrompt(2)] }); await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2)); @@ -198,7 +198,7 @@ describe("useChatOutline", () => { kind: "assistant_message", text: "chunk", timestamp: new Date(), - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 }, }, ], timelineEpoch: "epoch-1", @@ -219,7 +219,7 @@ describe("useChatOutline", () => { kind: "user_message", text: "Replaced conversation", timestamp: new Date(2), - timelineCursor: { epoch: "epoch-2", seq: 1 }, + timelineCursor: { epoch: "epoch-2", seq: 1, anchorSeq: 1 }, }, ], timelineEpoch: "epoch-2", @@ -275,7 +275,7 @@ describe("useChatOutline", () => { kind: "user_message" as const, text: "prompt", timestamp: new Date(1), - timelineCursor: { epoch: "epoch-1", seq: 1 }, + timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 }, }, ]; const { result, rerender } = renderHook( @@ -326,7 +326,7 @@ describe("useChatOutline", () => { kind: "user_message" as const, text: "prompt", timestamp: new Date(1), - timelineCursor: { epoch: "epoch-1", seq: 1 }, + timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 }, }; const { result, rerender } = renderHook( ({ tail, mountedHistoryMessageIds }) => @@ -381,7 +381,7 @@ describe("useChatOutline", () => { kind: "user_message" as const, text: "prompt", timestamp: new Date(2), - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 }, }; const revealLoadedMessage = vi.fn(); revealLoadedMessage.mockReturnValue(false); diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index ce3c0352ff..b48297481b 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -317,7 +317,7 @@ describe("stream presentation through installed plugins", () => { const source = harness.source(); const live = source.head[0]; if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message"); - const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } }; + const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 } }; const present = createStreamPresentation(); present({ ...presentationOptions, tail: [], head: [live], transform: undefined }); const repointed = rows( @@ -327,7 +327,7 @@ describe("stream presentation through installed plugins", () => { { id: streamed[0]!.id, text: "Streamed answer", - timelineCursor: { epoch: "epoch-1", seq: 42 }, + timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 }, }, ]); }); @@ -514,7 +514,7 @@ describe("timeline presentation", () => { messageId: "provider-message", clientMessageId: "client-message", turnId: "turn-1", - timelineCursor: { epoch: "epoch", seq: 12 }, + timelineCursor: { epoch: "epoch", seq: 12, anchorSeq: 12 }, }); const presentMessage = () => present({ diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index 14e5ea776d..a731370ec3 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -33,7 +33,7 @@ describe("resolveAssistantTurnForkBoundary", () => { it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => { const failedTurn = { ...assistantMessage("assistant-error", 2), - timelineCursor: { epoch: "timeline-1", seq: 42 }, + timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 }, }; expect( @@ -50,7 +50,7 @@ describe("resolveAssistantTurnForkBoundary", () => { it("includes the provider message id with a supported timeline cursor", () => { const selected = { ...assistantMessage("assistant-1", 2, "msg-assistant-1"), - timelineCursor: { epoch: "timeline-1", seq: 42 }, + timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 }, }; expect( @@ -68,7 +68,7 @@ describe("resolveAssistantTurnForkBoundary", () => { it("falls back to the provider message id when timeline cursors are unsupported", () => { const selected = { ...assistantMessage("assistant-1", 2, "msg-assistant-1"), - timelineCursor: { epoch: "timeline-1", seq: 42 }, + timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 }, }; expect( diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts index f7cbe8c00f..c64d5f66c0 100644 --- a/packages/app/src/agent-stream/turn-boundary.ts +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -1,7 +1,13 @@ -import type { StreamItem, TimelinePosition } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; + +/** The daemon's timeline cursor; the row's anchor stays in the client. */ +interface TimelineBoundaryCursor { + epoch: string; + seq: number; +} export type AssistantTurnForkBoundary = - | { boundaryCursor: TimelinePosition; boundaryMessageId?: string } + | { boundaryCursor: TimelineBoundaryCursor; boundaryMessageId?: string } | { boundaryCursor?: undefined; boundaryMessageId: string }; export function resolveAssistantTurnForkBoundary(input: { @@ -15,7 +21,7 @@ export function resolveAssistantTurnForkBoundary(input: { } if (input.supportsTimelineCursor && item.timelineCursor) { return { - boundaryCursor: item.timelineCursor, + boundaryCursor: { epoch: item.timelineCursor.epoch, seq: item.timelineCursor.seq }, ...(item.messageId ? { boundaryMessageId: item.messageId } : {}), }; } diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index f6d3d0325c..c8a8aa040c 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -9,6 +9,7 @@ import { useRewindComposerRestore } from "./composer-restore"; import { useSessionStore } from "@/stores/session-store"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { hostSupportsFeature } from "@/runtime/host-features"; interface UseRewindAgentMutationInput { serverId?: string; @@ -35,7 +36,16 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { throw new Error(t("common.errors.daemonClientUnavailable")); } await input.client.rewindAgent(input.agentId, input.messageId, mode); - if (mode !== "files") { + // COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10. A daemon with + // the feature delivers the rewind on the live timeline; an older one replaced the timeline + // and this client has to read it again. + const appliesLive = + input.serverId !== undefined && + hostSupportsFeature( + useSessionStore.getState().sessions[input.serverId]?.serverInfo, + "timelineRewindOperations", + ); + if (mode !== "files" && !appliesLive) { const cursor = input.serverId ? useSessionStore .getState() diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 89c32c0bd5..1307407647 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -399,6 +399,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider console.warn("[Session] timeline replacement refresh failed", { serverId, error }); }); }; + const onRewind = (message: SessionOutboundMessage) => { + if (message.type !== "agent.timeline.rewind") return; + const { agentId, epoch, seq, retireFromSeq } = message.payload; + sync.enqueueStreamEvent(agentId, { + event: { type: "rewind", retireFromSeq }, + seq, + epoch, + timestamp: new Date(), + }); + }; const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { observe: (agentIds) => { const observation = client.observeTimeline(agentIds); @@ -407,6 +417,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider update: (message) => { if (message.type === "agent_stream") onStream(message); else if (message.type === "agent.timeline.replacement") onReplacement(message); + else if (message.type === "agent.timeline.rewind") onRewind(message); }, }); return observation; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index e517021b6b..fb7ac14d02 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -166,7 +166,7 @@ describe("ensureAgentIsInitialized", () => { kind: "assistant_message", id: "canonical-item", text: "Canonical before restart", - timelineCursor: { epoch: "epoch-1", seq: 12 }, + timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 }, timestamp: new Date("2026-07-27T10:00:00.000Z"), }, ], diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 648c66c590..67a83cee87 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -77,7 +77,7 @@ describe("AgentDirectoryReplica", () => { id: "reply", text: "server history", timestamp: new Date(), - timelineCursor: { epoch: "e", seq: 1 }, + timelineCursor: { epoch: "e", seq: 1, anchorSeq: 1 }, }, ], head: [], diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0c7c9f4660..386908da15 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -243,6 +243,7 @@ const PROBE_STEADY_MS = 10_000; const PROBE_MAX_BACKOFF_MS = 30_000; const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000; const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; +const MAX_STALE_TIMELINE_PAGE_ATTEMPTS = 3; const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; @@ -2807,12 +2808,19 @@ export class HostRuntimeStore { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync; - const page = await directory.fetchTimeline(agentId, request); - if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) { + // A page served before a rewind this client applied is asked for again. Seqs only grow within + // an epoch, so the next page includes the rewind unless another one landed meanwhile. + for (let attempt = 1; ; attempt += 1) { + const page = await directory.fetchTimeline(agentId, request); + if (!owner || useSessionStore.getState().sessions[serverId]?.viewedTimelineSync !== owner) { + return page; + } owner.flushStreamAgent(agentId); - owner.applyTimelineResponse(page); + if (owner.applyTimelineResponse(page) === "applied") return page; + if (attempt === MAX_STALE_TIMELINE_PAGE_ATTEMPTS) { + throw new Error(`Timeline page for ${agentId} stayed behind a rewind`); + } } - return page; } createViewedTimelineOwner( diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index d91d8e7ba4..e4c76e4b60 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -184,7 +184,7 @@ function timelineItem(text = "Cached"): StreamItem { id: "message-1", text, timestamp: new Date("2026-07-18T08:02:00.000Z"), - timelineCursor: { epoch: "epoch-1", seq: 12 }, + timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 }, }; } @@ -739,7 +739,7 @@ describe("ReplicaCache", () => { version: 1, data: { passed: 4, failed: 0 }, timestamp: new Date("2026-07-18T08:02:00.000Z"), - timelineCursor: { epoch: "epoch-1", seq: 12 }, + timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 }, }; writer.commitTimeline(SERVER_ID, "agent-1", { agentId: "agent-1", @@ -784,6 +784,32 @@ describe("ReplicaCache", () => { expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); }); + it("keeps where each cached row started, and refetches a timeline cached without it", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const updatedAfterItStarted: StreamItem = { + ...timelineItem(), + timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 9 }, + }; + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [updatedAfterItStarted] }); + await writer.flush(); + expect((await createCache(storage).readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + updatedAfterItStarted, + ]); + + const row = requireTimelineRow(storage); + const payload = JSON.parse(row.payload) as { + items: Array<{ timelineCursor?: Record }>; + }; + delete payload.items[0]?.timelineCursor?.anchorSeq; + storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { + ...row, + payload: JSON.stringify(payload), + }); + + expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); + }); + it("refetches old cached Markdown fragments instead of offering them as whole messages", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); @@ -1147,7 +1173,7 @@ it("retains semantic agent messages across disk-cache reconstruction", async () kind: "tool_call", id: "delivery", timestamp: new Date(1), - timelineCursor: { epoch: "epoch-1", seq: 12 }, + timelineCursor: { epoch: "epoch-1", seq: 12, anchorSeq: 12 }, payload: { source: "agent", data: { diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 9ad604c8eb..f4ebf0f001 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -19,6 +19,7 @@ import { isUnreconciledLocalUserMessage, type AgentToolCallData, type StreamItem, + type TimelinePosition, } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { clearLegacyReplicaCache } from "./legacy-cleanup"; @@ -76,7 +77,15 @@ const IsoDateSchema = z.iso.datetime(); const TimelinePositionSchema = z.strictObject({ epoch: z.string(), seq: z.number().int().nonnegative(), + // COMPAT(timelineRewindOperations): absent on caches written before rows were anchored, added in + // v0.14.1, remove after 2027-05-10. Such a cache is not restored; the chat loads its tail instead. + anchorSeq: z.number().int().nonnegative().optional(), }); +type StoredTimelinePosition = z.infer; + +function isAnchored(position: StoredTimelinePosition | undefined): boolean { + return position === undefined || position.anchorSeq !== undefined; +} const PluginTimelineDataSchema: z.ZodType = z.lazy(() => z.union([ z.null(), @@ -403,6 +412,10 @@ function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | nu if (!stored) { return null; } + // A rewind retires rows by anchor, so an unanchored row could not be retired. + if (!stored.items.every((item) => isAnchored(item.timelineCursor))) { + return null; + } return { agentId: stored.agentId, items: stored.items.map(deserializeTimelineItem), @@ -504,11 +517,20 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { } } +function anchoredPosition(position: StoredTimelinePosition | undefined): { + timelineCursor?: TimelinePosition; +} { + if (!position || position.anchorSeq === undefined) return {}; + return { + timelineCursor: { epoch: position.epoch, seq: position.seq, anchorSeq: position.anchorSeq }, + }; +} + function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { if (item.kind === "plugin") { return { id: item.id, - ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...anchoredPosition(item.timelineCursor), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), kind: item.kind, @@ -527,7 +549,7 @@ function deserializeBuiltinTimelineItem( ): StreamItem { const base = { id: item.id, - ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + ...anchoredPosition(item.timelineCursor), ...(item.turnId ? { turnId: item.turnId } : {}), timestamp: new Date(item.timestamp), }; diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 859ae8e6b7..dbb42a97b0 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -27,7 +27,7 @@ function item(id: string, text: string, seq: number): StreamItem { id, text, timestamp: new Date("2026-08-26T10:00:00.000Z"), - timelineCursor: { epoch: "epoch-1", seq }, + timelineCursor: { epoch: "epoch-1", seq, anchorSeq: seq }, }; } @@ -543,6 +543,229 @@ describe("viewed timeline persistence", () => { }); }); +describe("conversation rewind through the owner", () => { + const page = { + requestId: "page-1", + agentId: AGENT_ID, + agent: null, + direction: "tail", + projection: "projected", + reset: false, + epoch: "epoch-1", + error: null, + hasNewer: false, + hasOlder: false, + staleCursor: false, + gap: false, + } as const; + + function entry(seq: number, type: "user_message" | "assistant_message", text: string) { + return { + seqStart: seq, + seqEnd: seq, + sourceSeqRanges: [{ startSeq: seq, endSeq: seq }], + collapsed: [], + provider: "codex", + item: { type, text }, + timestamp: `2026-08-26T10:00:0${seq}.000Z`, + }; + } + + function texts(items: readonly StreamItem[]): string[] { + return items.flatMap((row) => + row.kind === "user_message" || row.kind === "assistant_message" ? [row.text] : [], + ); + } + + function viewed(): string[] { + const session = useSessionStore.getState().sessions[SERVER_ID]; + const timeline = selectAgentTimelineState(session, AGENT_ID); + const tail = timeline.status === "cold" ? [] : timeline.items; + return texts([...tail, ...(session?.agentStreamHead.get(AGENT_ID) ?? [])]); + } + + it("retires B from the viewed and persisted timeline without refetching", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const commits: CachedTimeline[] = []; + const owner = createOwner({ + readTimeline: async () => undefined, + commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline), + }); + owner.applyTimelineResponse({ + ...page, + window: { minSeq: 1, maxSeq: 4, nextSeq: 5 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 4 }, + entries: [ + entry(1, "user_message", "A"), + entry(2, "assistant_message", "reply A"), + entry(3, "user_message", "B"), + entry(4, "assistant_message", "reply B"), + ], + }); + + owner.enqueueStreamEvent(AGENT_ID, { + event: { type: "rewind", retireFromSeq: 3 }, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:05.000Z"), + }); + owner.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "C", messageId: "c" }, + } as AgentStreamEventPayload, + seq: 6, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:06.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + + expect(viewed()).toEqual(["A", "reply A", "C"]); + expect(texts(commits.at(-1)?.items ?? [])).toEqual(["A", "reply A", "C"]); + expect(commits.at(-1)?.range).toMatchObject({ epoch: "epoch-1", endSeq: 6 }); + owner.dispose(); + }); + + it("brings a second client that loads after the rewind to the same conversation", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} }); + + owner.applyTimelineResponse({ + ...page, + window: { minSeq: 1, maxSeq: 6, nextSeq: 7 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 6 }, + entries: [ + entry(1, "user_message", "A"), + entry(2, "assistant_message", "reply A"), + entry(6, "user_message", "C"), + ], + retirements: [{ seq: 5, retireFromSeq: 3 }], + }); + + expect(viewed()).toEqual(["A", "reply A", "C"]); + owner.dispose(); + }); + + function liveRewindOfB(owner: ViewedTimelineOwner, retirement: { seq: number }): void { + owner.enqueueStreamEvent(AGENT_ID, { + event: { type: "rewind", retireFromSeq: 3 }, + seq: retirement.seq, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:06.000Z"), + }); + owner.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "C", messageId: "c" }, + } as AgentStreamEventPayload, + seq: retirement.seq + 1, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:07.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + } + + /** A, reply A, B, reply B as a page served before B was rewound. */ + const pageServedBeforeTheRewind: Parameters[0] = { + ...page, + direction: "before", + hasOlder: false, + window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 4 }, + entries: [ + entry(1, "user_message", "A"), + entry(2, "assistant_message", "reply A"), + entry(3, "user_message", "B"), + entry(4, "assistant_message", "reply B"), + ], + }; + + function loadTheEndOfB(owner: ViewedTimelineOwner): void { + owner.applyTimelineResponse({ + ...page, + hasOlder: true, + window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, + startCursor: { epoch: "epoch-1", seq: 5 }, + endCursor: { epoch: "epoch-1", seq: 5 }, + entries: [entry(5, "assistant_message", "more of B")], + }); + } + + it("refuses an older page served before the rewind and takes the one served after it", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} }); + loadTheEndOfB(owner); + liveRewindOfB(owner, { seq: 6 }); + expect(viewed()).toEqual(["C"]); + + const outcome = owner.applyTimelineResponse(pageServedBeforeTheRewind); + expect(viewed()).toEqual(["C"]); + expect(outcome).toBe("stale"); + + owner.applyTimelineResponse({ + ...pageServedBeforeTheRewind, + window: { minSeq: 1, maxSeq: 7, nextSeq: 8 }, + // Retired rows count as covered, so the page reaches the loaded start. + endCursor: { epoch: "epoch-1", seq: 4 }, + entries: [entry(1, "user_message", "A"), entry(2, "assistant_message", "reply A")], + }); + expect(viewed()).toEqual(["A", "reply A", "C"]); + owner.dispose(); + }); + + it("refuses a prompt jump window served before the rewind", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} }); + loadTheEndOfB(owner); + liveRewindOfB(owner, { seq: 6 }); + + const outcome = owner.applyTimelineResponse({ + ...pageServedBeforeTheRewind, + mergeWindow: true, + }); + expect(viewed()).toEqual(["C"]); + expect(outcome).toBe("stale"); + owner.dispose(); + }); + + it("refuses a first page served before a rewind that reached the chat while it loaded", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const owner = createOwner({ readTimeline: async () => undefined, commitTimeline: () => {} }); + liveRewindOfB(owner, { seq: 5 }); + + const outcome = owner.applyTimelineResponse({ + ...pageServedBeforeTheRewind, + direction: "tail", + window: { minSeq: 1, maxSeq: 4, nextSeq: 5 }, + }); + expect(viewed()).toEqual(["C"]); + expect(outcome).toBe("stale"); + + owner.applyTimelineResponse({ + ...page, + window: { minSeq: 1, maxSeq: 6, nextSeq: 7 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 6 }, + entries: [ + entry(1, "user_message", "A"), + entry(2, "assistant_message", "reply A"), + { + ...entry(6, "user_message", "C"), + item: { type: "user_message", text: "C", messageId: "c" }, + }, + ], + retirements: [{ seq: 5, retireFromSeq: 3 }], + }); + expect(viewed()).toEqual(["A", "reply A", "C"]); + owner.dispose(); + }); +}); + describe("create handoff lifetime", () => { const DRAFT_ID = "draft-1"; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 0e401ad7a0..7dd185929c 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -396,7 +396,7 @@ describe("processTimelineResponse", () => { messageId: "provider-message", text: "local prompt", timestamp: new Date(1000), - timelineCursor: { epoch: "epoch-1", seq: 1 }, + timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 }, turnId: "turn-1", }); const hello = createUserMessage({ @@ -404,7 +404,7 @@ describe("processTimelineResponse", () => { clientMessageId: "hello-client", text: "hello", timestamp: new Date(1500), - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 }, turnId: "turn-1", }); const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail"), hello]; @@ -454,7 +454,7 @@ describe("processTimelineResponse", () => { id: "stale-history", text: "stale history", timestamp: new Date(1040), - timelineCursor: { epoch: "epoch-1", seq: 40 }, + timelineCursor: { epoch: "epoch-1", seq: 40, anchorSeq: 40 }, }, ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, @@ -482,11 +482,11 @@ describe("processTimelineResponse", () => { const unresolved = makeSubmittedUserMessage("unresolved prompt", "client-unresolved"); const coveredLive: StreamItem = { ...makeAssistantItem("covered live", "covered-live"), - timelineCursor: { epoch: "epoch-1", seq: 235 }, + timelineCursor: { epoch: "epoch-1", seq: 235, anchorSeq: 235 }, }; const newerLive: StreamItem = { ...makeAssistantItem("newer live", "newer-live"), - timelineCursor: { epoch: "epoch-1", seq: 245 }, + timelineCursor: { epoch: "epoch-1", seq: 245, anchorSeq: 245 }, }; const result = processTimelineResponse({ @@ -571,7 +571,7 @@ describe("processTimelineResponse", () => { expect.objectContaining({ kind: "assistant_message", text: "[System Error] failed", - timelineCursor: { epoch: "timeline-1", seq: 42 }, + timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 40 }, }), ]); }); @@ -743,7 +743,7 @@ describe("processTimelineResponse", () => { }).item, } as AgentStreamEventPayload, timestamp: new Date(2000), - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 }, }, ], { source: "canonical" }, @@ -773,7 +773,7 @@ describe("processTimelineResponse", () => { expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([ expect.objectContaining({ - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 }, payload: expect.objectContaining({ data: expect.objectContaining({ callId, status: "completed" }), }), @@ -794,7 +794,7 @@ describe("processTimelineResponse", () => { }, } as AgentStreamEventPayload, timestamp: new Date(2000), - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 }, }, ], { source: "canonical" }, @@ -829,7 +829,7 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].filter((item) => item.kind === "todo_list")).toEqual([ expect.objectContaining({ - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 }, items: [{ text: "Verify hydration", completed: true }], }), ]); @@ -845,7 +845,7 @@ describe("processTimelineResponse", () => { item: { type: "compaction", status: "completed", trigger: "auto", preTokens: 1200 }, } as AgentStreamEventPayload, timestamp: new Date(2000), - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 2 }, }, ], { source: "canonical" }, @@ -877,7 +877,7 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].filter((item) => item.kind === "compaction")).toEqual([ expect.objectContaining({ - timelineCursor: { epoch: "epoch-1", seq: 2 }, + timelineCursor: { epoch: "epoch-1", seq: 2, anchorSeq: 1 }, status: "completed", trigger: "auto", preTokens: 1200, @@ -916,7 +916,7 @@ describe("processTimelineResponse", () => { messageId: "assistant-live", text: " continuation", timestamp: new Date(2001), - timelineCursor: { epoch: "epoch-1", seq: 101 }, + timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 }, }, ], isInitializing: true, @@ -944,7 +944,7 @@ describe("processTimelineResponse", () => { ); expect(assistants).toHaveLength(1); expect(assistants[0]?.text).toBe("canonical prefix continuation"); - expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 }); + expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101, anchorSeq: 100 }); }); it("keeps assistant segments separate when a live tool sits between them", () => { @@ -961,12 +961,12 @@ describe("processTimelineResponse", () => { }).item, } as AgentStreamEventPayload, timestamp: new Date(2101), - timelineCursor: { epoch: "epoch-1", seq: 101 }, + timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 }, }, { event: makeAssistantTimelineEvent("after tool", messageId), timestamp: new Date(2102), - timelineCursor: { epoch: "epoch-1", seq: 102 }, + timelineCursor: { epoch: "epoch-1", seq: 102, anchorSeq: 102 }, }, ], { source: "canonical" }, @@ -1017,7 +1017,7 @@ describe("processTimelineResponse", () => { id: "assistant-live", text: "canonical prefix continuation", timestamp: new Date(2001), - timelineCursor: { epoch: "epoch-1", seq: 101 }, + timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 }, }, ], isInitializing: true, @@ -1036,7 +1036,7 @@ describe("processTimelineResponse", () => { ); expect(assistants).toHaveLength(1); expect(assistants[0]?.text).toBe("canonical prefix continuation"); - expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 }); + expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101, anchorSeq: 100 }); }); it("keeps a canonical live user row newer than the bootstrap page", () => { @@ -1058,7 +1058,7 @@ describe("processTimelineResponse", () => { }); expect(live.head[0]).toMatchObject({ kind: "user_message", - timelineCursor: { epoch: "epoch-1", seq: 101 }, + timelineCursor: { epoch: "epoch-1", seq: 101, anchorSeq: 101 }, }); const result = processTimelineResponse({ @@ -1121,7 +1121,7 @@ describe("processTimelineResponse", () => { kind: "user_message", clientMessageId: "client-message-1", messageId: "provider-message-1", - timelineCursor: { epoch: "epoch-1", seq: 51 }, + timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 }, }); }); @@ -2806,7 +2806,7 @@ describe("processTimelineResponse", () => { id: "newest", text: "newest", timestamp: new Date(1000), - timelineCursor: { epoch: "epoch-1", seq: 100 }, + timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 }, }, makeSubmittedUserMessage("pending submission", "pending-submission"), ], @@ -2884,14 +2884,14 @@ describe("processTimelineResponse", () => { id: "oldest", text: "oldest", timestamp: new Date(1000), - timelineCursor: { epoch: "epoch-1", seq: 1 }, + timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 }, }, { kind: "user_message", id: "newest", text: "newest", timestamp: new Date(100_000), - timelineCursor: { epoch: "epoch-1", seq: 100 }, + timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 }, }, ]; @@ -2933,7 +2933,7 @@ describe("processTimelineResponse", () => { id: "newest", text: "newest", timestamp: new Date(100_000), - timelineCursor: { epoch: "epoch-1", seq: 100 }, + timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 }, }, ...(lane === "tail" ? [pending] : []), ], @@ -2974,9 +2974,13 @@ describe("processTimelineResponse", () => { { text: "local pending prompt", messageId: "canonical-message", - timelineCursor: { epoch: "epoch-1", seq: 1 }, + timelineCursor: { epoch: "epoch-1", seq: 1, anchorSeq: 1 }, + }, + { + text: "newest", + messageId: undefined, + timelineCursor: { epoch: "epoch-1", seq: 100, anchorSeq: 100 }, }, - { text: "newest", messageId: undefined, timelineCursor: { epoch: "epoch-1", seq: 100 } }, ], head: [], acknowledgedClientMessageIds: [clientMessageId], @@ -2990,7 +2994,7 @@ describe("processTimelineResponse", () => { { event: makeToolCallTimelineEvent("call-1"), timestamp: new Date(500), - timelineCursor: { epoch: "epoch-1", seq: 500 }, + timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 500 }, }, ], { source: "canonical" }, @@ -3036,7 +3040,7 @@ describe("processTimelineResponse", () => { { callId: "call-1", status: "completed", - timelineCursor: { epoch: "epoch-1", seq: 500 }, + timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 1 }, }, ]); }); @@ -3048,7 +3052,7 @@ describe("processTimelineResponse", () => { ...makeAssistantItem("projected response", "loaded-assistant"), messageId: "assistant-message", timestamp, - timelineCursor: { epoch: "epoch-1", seq: 500 }, + timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 500 }, }, ]; const result = processTimelineResponse({ @@ -3098,7 +3102,7 @@ describe("processTimelineResponse", () => { id: "loaded-assistant", text: "projected response", messageId: "assistant-message", - timelineCursor: { epoch: "epoch-1", seq: 500 }, + timelineCursor: { epoch: "epoch-1", seq: 500, anchorSeq: 1 }, }, ]); }); @@ -3110,7 +3114,7 @@ describe("processTimelineResponse", () => { id: "current-200", text: "current-200", timestamp: new Date(3200), - timelineCursor: { epoch: "epoch-1", seq: 200 }, + timelineCursor: { epoch: "epoch-1", seq: 200, anchorSeq: 200 }, }, ]; const existingCursor: TimelineCursor = { @@ -3217,7 +3221,7 @@ describe("processTimelineResponse", () => { const currentTail = [ { ...makeAssistantItem("newer chunk", "assistant-newer"), - timelineCursor: { epoch: "epoch-1", seq: 3 }, + timelineCursor: { epoch: "epoch-1", seq: 3, anchorSeq: 3 }, }, ]; const existingCursor: TimelineCursor = { @@ -3244,7 +3248,7 @@ describe("processTimelineResponse", () => { expect(result.tail[0]).toEqual( expect.objectContaining({ id: "assistant-newer", - timelineCursor: { epoch: "epoch-1", seq: 3 }, + timelineCursor: { epoch: "epoch-1", seq: 3, anchorSeq: 3 }, }), ); expect(result.cursor).toEqual({ @@ -3683,7 +3687,7 @@ describe("processAgentStreamEvent", () => { expect.objectContaining({ kind: "assistant_message", text: "[System Error] failed", - timelineCursor: { epoch: "timeline-1", seq: 42 }, + timelineCursor: { epoch: "timeline-1", seq: 42, anchorSeq: 42 }, }), ]); }); @@ -3912,7 +3916,7 @@ describe("processAgentStreamEvent", () => { expect(getUserTexts(result.head)).toEqual(["live before bootstrap"]); expect(result.head[0]).toMatchObject({ kind: "user_message", - timelineCursor: { epoch: "epoch-1", seq: 51 }, + timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 }, }); expect(result.cursorChanged).toBe(false); expect(result.cursor).toBeNull(); @@ -3947,7 +3951,7 @@ describe("processAgentStreamEvent", () => { clientMessageId: "client-message-1", messageId: "provider-message-1", text: "submitted before bootstrap", - timelineCursor: { epoch: "epoch-1", seq: 51 }, + timelineCursor: { epoch: "epoch-1", seq: 51, anchorSeq: 51 }, }), ]); }); @@ -4536,3 +4540,276 @@ describe("createAgentStreamReducerQueue", () => { expect(scheduler.size).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// Conversation rewind: A, B, rewind B, C +// --------------------------------------------------------------------------- + +describe("conversation rewind", () => { + const rewindB = { seq: 5, retireFromSeq: 3 }; + + function loadAB(): { tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor } { + const result = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 4, nextSeq: 5 }, + startCursor: { seq: 1 }, + endCursor: { seq: 4 }, + entries: [ + makeTimelineEntry(1, "A", "user_message"), + makeTimelineEntry(2, "reply A"), + makeTimelineEntry(3, "B", "user_message"), + makeTimelineEntry(4, "reply B"), + ], + }, + }); + if (!result.cursor) throw new Error("expected a loaded range"); + return { tail: result.tail, head: result.head, cursor: result.cursor }; + } + + function live( + state: { tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor }, + events: AgentStreamReducerEvent[], + ) { + const result = processAgentStreamEvents({ + events, + currentTail: state.tail, + currentHead: state.head, + currentCursor: state.cursor, + hasAuthoritativeBaseline: true, + }); + return { + tail: result.tail, + head: result.head, + cursor: result.cursor ?? state.cursor, + sideEffects: result.sideEffects, + }; + } + + function rewindEvent(retirement: { seq: number; retireFromSeq: number }) { + return { + event: { type: "rewind" as const, retireFromSeq: retirement.retireFromSeq }, + seq: retirement.seq, + epoch: "epoch-1", + timestamp: new Date(1000 + retirement.seq), + }; + } + + const promptC = [ + makeStreamReducerEvent(makeTimelineEvent("C", "user_message"), 6), + makeStreamReducerEvent(makeTimelineEvent("reply C"), 7), + ]; + + function conversation(state: { tail: StreamItem[]; head: StreamItem[] }) { + return [...state.tail, ...state.head].map((item) => + item.kind === "user_message" || item.kind === "assistant_message" ? item.text : item.kind, + ); + } + + it("retires B in place, keeps A mounted, and shows C once", () => { + const loaded = loadAB(); + const [promptA, replyA] = loaded.tail; + + const rewound = live(loaded, [rewindEvent(rewindB), ...promptC]); + + expect(conversation(rewound)).toEqual(["A", "reply A", "C", "reply C"]); + expect(rewound.tail[0]).toBe(promptA); + expect(rewound.tail[1]).toBe(replyA); + expect(rewound.cursor).toMatchObject({ epoch: "epoch-1", startSeq: 1, endSeq: 7 }); + expect(rewound.sideEffects).toEqual([]); + }); + + it("ignores a duplicate rewind so C is not retired", () => { + const rewound = live(loadAB(), [rewindEvent(rewindB), ...promptC]); + + const replayed = live(rewound, [rewindEvent(rewindB)]); + + expect(conversation(replayed)).toEqual(["A", "reply A", "C", "reply C"]); + expect(replayed.cursor.endSeq).toBe(7); + }); + + it("does not let an old page resurrect B after the rewind applied", () => { + const rewound = live(loadAB(), [rewindEvent(rewindB), ...promptC]); + + const stale = processTimelineResponse({ + ...baseTimelineInput, + currentTail: rewound.tail, + currentHead: rewound.head, + currentCursor: rewound.cursor, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 4, nextSeq: 5 }, + startCursor: { seq: 3 }, + endCursor: { seq: 4 }, + entries: [makeTimelineEntry(3, "B", "user_message"), makeTimelineEntry(4, "reply B")], + }, + }); + + expect(conversation(stale)).toEqual(["A", "reply A", "C", "reply C"]); + expect(stale.cursorChanged).toBe(false); + }); + + it("drops a late live delivery for the retired range", () => { + const rewound = live(loadAB(), [rewindEvent(rewindB)]); + + const late = live(rewound, [makeStreamReducerEvent(makeTimelineEvent("late B"), 4)]); + + expect(conversation(late)).toEqual(["A", "reply A"]); + }); + + it("catches up through a page that only carries the retirement", () => { + const loaded = loadAB(); + + const caughtUp = processTimelineResponse({ + ...baseTimelineInput, + currentTail: loaded.tail, + currentHead: loaded.head, + currentCursor: loaded.cursor, + payload: { + ...baseTimelineInput.payload, + direction: "after", + window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, + startCursor: { seq: 5 }, + endCursor: { seq: 5 }, + entries: [], + retirements: [rewindB], + }, + }); + if (!caughtUp.cursor) throw new Error("expected a loaded range"); + + expect(conversation(caughtUp)).toEqual(["A", "reply A"]); + expect(caughtUp.cursor.endSeq).toBe(5); + expect(caughtUp.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" })); + + const continued = live({ ...caughtUp, cursor: caughtUp.cursor }, promptC); + expect(conversation(continued)).toEqual(["A", "reply A", "C", "reply C"]); + }); + + it("converges after reconnect from a resume page that crosses the rewind", () => { + const loaded = loadAB(); + + const resumed = processTimelineResponse({ + ...baseTimelineInput, + currentTail: loaded.tail, + currentHead: loaded.head, + currentCursor: loaded.cursor, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 7, nextSeq: 8 }, + startCursor: { seq: 1 }, + endCursor: { seq: 7 }, + entries: [ + makeTimelineEntry(1, "A", "user_message"), + makeTimelineEntry(2, "reply A"), + makeTimelineEntry(6, "C", "user_message"), + makeTimelineEntry(7, "reply C"), + ], + retirements: [rewindB], + }, + }); + + expect(conversation(resumed)).toEqual(["A", "reply A", "C", "reply C"]); + expect(resumed.tail[0]).toBe(loaded.tail[0]); + expect(resumed.cursor).toMatchObject({ endSeq: 7 }); + }); + + it("recovers a rewind that skips past the loaded end", () => { + const loaded = loadAB(); + + const gapped = live(loaded, [rewindEvent({ seq: 9, retireFromSeq: 3 })]); + + expect(conversation(gapped)).toEqual(["A", "reply A", "B", "reply B"]); + expect(gapped.sideEffects).toEqual([ + { type: "catch_up", cursor: { epoch: "epoch-1", endSeq: 4 } }, + ]); + }); + + it("keeps an A row whose late update carries a seq inside B's range", () => { + const loaded = loadAB(); + const [promptA, replyA, ...rest] = loaded.tail; + const lateTool = hydrateStreamState( + [ + { + event: makeToolCallTimelineEvent("call-a"), + timestamp: new Date(1002), + timelineCursor: { epoch: "epoch-1", seq: 4, anchorSeq: 2 }, + }, + ], + { source: "canonical" }, + ); + const state = { ...loaded, tail: [promptA, replyA, ...lateTool, ...rest] }; + + const rewound = live(state, [rewindEvent(rewindB)]); + + expect(conversation(rewound)).toEqual(["A", "reply A", "tool_call"]); + }); + + it("keeps a tool that started before B when B's prompt is not loaded and the tool updated after B", () => { + const toolA = { + ...makeToolCallTimelineEntry(2, "call-a", "completed", { + type: "read", + filePath: "/tmp/a.ts", + }), + seqEnd: 6, + sourceSeqRanges: [ + { startSeq: 2, endSeq: 2 }, + { startSeq: 6, endSeq: 6 }, + ], + }; + const loaded = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 6, nextSeq: 7 }, + startCursor: { seq: 1 }, + endCursor: { seq: 6 }, + entries: [makeTimelineEntry(1, "A", "user_message"), toolA], + }, + }); + if (!loaded.cursor) throw new Error("expected a loaded range"); + + const rewound = live({ tail: loaded.tail, head: loaded.head, cursor: loaded.cursor }, [ + rewindEvent({ seq: 7, retireFromSeq: 3 }), + ]); + + expect(conversation(rewound)).toEqual(["A", "tool_call"]); + }); + + it("keeps C from the live overlay when the first page brings the retirement C followed", () => { + const overlay = processAgentStreamEvents({ + events: [rewindEvent(rewindB), ...promptC], + currentTail: [], + currentHead: [], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + + const initialized = processTimelineResponse({ + ...baseTimelineInput, + isInitializing: true, + hasActiveInitDeferred: true, + currentTail: overlay.tail, + currentHead: overlay.head, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 5, nextSeq: 6 }, + startCursor: { seq: 1 }, + endCursor: { seq: 5 }, + entries: [makeTimelineEntry(1, "A", "user_message"), makeTimelineEntry(2, "reply A")], + retirements: [rewindB], + }, + }); + + expect(conversation(initialized)).toEqual(["A", "reply A", "C", "reply C"]); + }); +}); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index a46ec8188f..45eacf274c 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,7 +1,13 @@ import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; +import { + advanceTimelinePosition, + type AssistantMessageItem, + type StreamItem, + type TimelinePosition, + type TodoEntry, +} from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, @@ -71,6 +77,64 @@ interface TimelineSeqRange { endSeq: number; } +/** A conversation rewind took the rows anchored in `[retireFromSeq, seq)` out of the conversation. */ +export interface TimelineRetirement { + seq: number; + retireFromSeq: number; +} + +/** The live form of a retirement; it is sequenced with the stream events around it. */ +export interface TimelineRewindEvent { + type: "rewind"; + retireFromSeq: number; +} + +/** + * Drops the rows a retirement took out of the conversation: those anchored in + * `[retireFromSeq, seq)`. Anchors do not move on update, so an earlier row updated after the + * rewound prompt stays, and a row that started after the retirement stays. Items without a + * timeline position are local sends the daemon has not echoed and stay. + */ +function retireRewoundStreamItems(input: { + tail: StreamItem[]; + head: StreamItem[]; + epoch: string; + retirement: TimelineRetirement; +}): { tail: StreamItem[]; head: StreamItem[] } { + const { tail, head, epoch, retirement } = input; + const isRetired = (item: StreamItem) => { + const position = item.timelineCursor; + return ( + position?.epoch === epoch && + position.anchorSeq >= retirement.retireFromSeq && + position.anchorSeq < retirement.seq + ); + }; + const nextTail = tail.filter((item) => !isRetired(item)); + const nextHead = head.filter((item) => !isRetired(item)); + return { + tail: nextTail.length === tail.length ? tail : nextTail, + head: nextHead.length === head.length ? head : nextHead, + }; +} + +/** + * Applies a page's retirements to what this client holds. The page's own entries are already + * active. Applying a retirement twice changes nothing, since its range is bounded. + */ +function retireForPage(input: { + payload: ProcessTimelineResponseInput["payload"]; + tail: StreamItem[]; + head: StreamItem[]; +}): { tail: StreamItem[]; head: StreamItem[] } { + const { payload } = input; + let held = { tail: input.tail, head: input.head }; + for (const retirement of payload.retirements ?? []) { + held = retireRewoundStreamItems({ ...held, epoch: payload.epoch, retirement }); + } + return held; +} + function mergeTimelineCoverage( current: TimelineCursor, added: TimelineLoadedRange, @@ -150,6 +214,7 @@ export interface ProcessTimelineResponseInput { hasNewer: boolean; hasOlder: boolean; mergeWindow?: boolean; + retirements?: TimelineRetirement[]; }; currentTail: StreamItem[]; currentHead: StreamItem[]; @@ -391,7 +456,7 @@ function mergeTimelineWindow(args: { toHydratedEvents: (units: TimelineUnit[]) => Array<{ event: AgentStreamEventPayload; timestamp: Date; - timelineCursor: { epoch: string; seq: number }; + timelineCursor: TimelinePosition; }>; }): TimelinePathResult { const { timelineUnits, payload, currentTail, currentHead, currentCursor, toHydratedEvents } = @@ -755,7 +820,7 @@ function replaceLiveAssistantWithProjectedText(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; - timelineCursor: { epoch: string; seq: number }; + timelineCursor: TimelinePosition; }): StreamItem[] | null { const { head, event, timestamp, timelineCursor } = params; if (event.type !== "timeline" || event.item.type !== "assistant_message") { @@ -774,7 +839,7 @@ function replaceLiveAssistantWithProjectedText(params: { ...current, text: event.item.text, timestamp, - timelineCursor, + ...advanceTimelinePosition(current.timelineCursor, timelineCursor), }; return next; } @@ -824,7 +889,7 @@ function reconcileOverlappingProjectedAssistant(params: { ...(messageId !== undefined ? { messageId } : {}), text: projectedText, timestamp: unit.timestamp, - timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, + timelineCursor: { epoch: params.epoch, seq: unit.seqEnd, anchorSeq: unit.seq }, }; const replaceMatch = (items: StreamItem[], index: number) => { const next = [...items]; @@ -941,8 +1006,8 @@ function applyCanonicalForwardUnit(params: { unit: TimelineUnit; epoch: string; }): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { - const { event, timestamp, seqEnd } = params.unit; - const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + const { event, timestamp, seq, seqEnd } = params.unit; + const timelineCursor: TimelinePosition = { epoch: params.epoch, seq: seqEnd, anchorSeq: seq }; if (event.type === "timeline" && event.item.type === "user_message") { const applied = applyStreamEvent({ tail: params.tail, @@ -1141,10 +1206,10 @@ function applyAcceptedTimelinePage(input: { }); } const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ + acceptedUnits.map(({ event, timestamp, seq, seqEnd }) => ({ event, timestamp, - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + timelineCursor: { epoch: payload.epoch, seq: seqEnd, anchorSeq: seq }, })), { source: "canonical", @@ -1176,7 +1241,12 @@ function applyTimelineIncrementalPath(args: { let cursorChanged = false; const sideEffects: TimelineReducerSideEffect[] = []; - if (timelineUnits.length === 0 && payload.direction !== "before") { + // A forward page of retirements alone still advances the loaded end. + if ( + timelineUnits.length === 0 && + payload.direction !== "before" && + (payload.retirements?.length ?? 0) === 0 + ) { return { tail: currentTail, head: currentHead, @@ -1234,8 +1304,6 @@ export function processTimelineResponse( ): ProcessTimelineResponseOutput { const { payload, - currentTail, - currentHead, currentCursor, isInitializing, hasActiveInitDeferred, @@ -1249,8 +1317,8 @@ export function processTimelineResponse( if (payload.error) { return { commit: "apply", - tail: currentTail, - head: currentHead, + tail: input.currentTail, + head: input.currentHead, cursor: currentCursor, cursorChanged: false, older: "unchanged", @@ -1262,6 +1330,13 @@ export function processTimelineResponse( }; } + // Entries are already free of retired rows; the retirements apply to what this client holds. + const { tail: currentTail, head: currentHead } = retireForPage({ + payload, + tail: input.currentTail, + head: input.currentHead, + }); + // ------------------------------------------------------------------ // Convert entries to timeline units // ------------------------------------------------------------------ @@ -1286,12 +1361,12 @@ export function processTimelineResponse( ): Array<{ event: AgentStreamEventPayload; timestamp: Date; - timelineCursor: { epoch: string; seq: number }; + timelineCursor: TimelinePosition; }> => - units.map(({ event, timestamp, seqEnd }) => ({ + units.map(({ event, timestamp, seq, seqEnd }) => ({ event, timestamp, - timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + timelineCursor: { epoch: payload.epoch, seq: seqEnd, anchorSeq: seq }, })); // ------------------------------------------------------------------ @@ -1426,7 +1501,7 @@ export function processTimelineResponse( // --------------------------------------------------------------------------- export interface ProcessAgentStreamEventInput { - event: AgentStreamEventPayload; + event: AgentStreamEventPayload | TimelineRewindEvent; seq: number | undefined; epoch: string | undefined; currentTail: StreamItem[]; @@ -1449,7 +1524,7 @@ export interface ProcessAgentStreamEventOutput { } export interface AgentStreamReducerEvent { - event: AgentStreamEventPayload; + event: AgentStreamEventPayload | TimelineRewindEvent; seq: number | undefined; epoch: string | undefined; timestamp: Date; @@ -1567,11 +1642,70 @@ function processTimelineSequencingGate(input: { }; } +/** + * Applies a live rewind in sequence with the stream. One that does not follow the loaded end is + * stale (already applied, or older than a page this client took) or leaves a gap to catch up. + */ +function processTimelineRewind( + input: ProcessAgentStreamEventInput & { event: TimelineRewindEvent }, +): ProcessAgentStreamEventOutput { + const { event, seq, epoch, currentTail, currentHead, currentCursor } = input; + const unchanged: ProcessAgentStreamEventOutput = { + tail: currentTail, + head: currentHead, + changedTail: false, + changedHead: false, + cursor: null, + cursorChanged: false, + acknowledgedClientMessageIds: [], + sideEffects: [], + }; + if (seq === undefined || epoch === undefined) return unchanged; + const decision = + input.hasAuthoritativeBaseline === false + ? "overlay" + : classifySessionTimelineSeq({ + cursor: currentCursor + ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } + : null, + epoch, + seq, + }); + if (decision === "gap" && currentCursor) { + return { + ...unchanged, + sideEffects: [ + { type: "catch_up", cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } }, + ], + }; + } + if (decision !== "accept" && decision !== "init" && decision !== "overlay") return unchanged; + const retired = retireRewoundStreamItems({ + tail: currentTail, + head: currentHead, + epoch, + retirement: { seq, retireFromSeq: event.retireFromSeq }, + }); + let cursor: TimelineCursor | null = null; + if (decision === "accept" && currentCursor) cursor = { ...currentCursor, endSeq: seq }; + if (decision === "init") cursor = { epoch, startSeq: seq, endSeq: seq }; + return { + ...unchanged, + tail: retired.tail, + head: retired.head, + changedTail: retired.tail !== currentTail, + changedHead: retired.head !== currentHead, + cursor, + cursorChanged: cursor !== null, + }; +} + export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { + const event = input.event; + if (event.type === "rewind") return processTimelineRewind({ ...input, event }); const { - event, seq, epoch, currentTail, @@ -1590,7 +1724,7 @@ export function processAgentStreamEvent( }); const timelineCursor = event.type === "timeline" && seq !== undefined && epoch !== undefined - ? { epoch, seq } + ? { epoch, seq, anchorSeq: seq } : undefined; // ------------------------------------------------------------------ // Apply stream event to tail/head diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 67defcec04..58142c918c 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -359,12 +359,49 @@ export type ViewedTimelineOwnerPorts = Omit< "prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded" >; +/** + * `stale`: the page was served before a rewind this client already applied, so it can hold rows + * that rewind retired. Nothing was applied; request the page again. + */ +export type TimelineResponseOutcome = "applied" | "stale"; + export interface ViewedTimelineOwner extends ViewedTimelineSync { - applyTimelineResponse(payload: TimelineResponsePayload): void; + applyTimelineResponse(payload: TimelineResponsePayload): TimelineResponseOutcome; enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void; flushStreamAgent(agentId: string): void; } +/** + * The latest retirement each chat has seen, live or on a page. Tail, older and prompt-jump pages + * are served from active rows at one moment; one served before that retirement can still hold the + * rows it retired, and has no retirement to drop them with. Forward pages are sequenced against + * the loaded end instead, so they are never stale here. + */ +function createRetirementHighWater() { + const latest = new Map(); + function note(agentId: string, retirement: { epoch: string; seq: number }): void { + const current = latest.get(agentId); + if (current?.epoch === retirement.epoch && current.seq >= retirement.seq) return; + latest.set(agentId, retirement); + } + return { + note, + notePage(payload: TimelineResponsePayload): void { + for (const retirement of payload.retirements ?? []) { + note(payload.agentId, { epoch: payload.epoch, seq: retirement.seq }); + } + }, + servedBefore(payload: TimelineResponsePayload): boolean { + const known = latest.get(payload.agentId); + return ( + payload.direction !== "after" && + known?.epoch === payload.epoch && + payload.window.maxSeq < known.seq + ); + }, + }; +} + export function createViewedTimelineOwner(input: { serverId: string; replica: TimelineReplica; @@ -373,6 +410,7 @@ export function createViewedTimelineOwner(input: { ports: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { const forcedTailReplacements = new Set(); + const retirements = createRetirementHighWater(); const sync = createViewedTimelineSync({ ...input.ports, fetchLatestTail: async (agentId) => { @@ -399,6 +437,8 @@ export function createViewedTimelineOwner(input: { return { ...sync, applyTimelineResponse(receivedPayload) { + if (retirements.servedBefore(receivedPayload)) return "stale"; + retirements.notePage(receivedPayload); const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements); const accepted = applyAuthoritativeTimelineResponse({ serverId: input.serverId, @@ -408,8 +448,12 @@ export function createViewedTimelineOwner(input: { drainQueuedAgentMessage: input.drainQueuedAgentMessage, }); if (accepted) input.replica.timelineUpdated(payload.agentId); + return "applied"; }, enqueueStreamEvent(agentId, event) { + if (event.event.type === "rewind" && event.epoch !== undefined && event.seq !== undefined) { + retirements.note(agentId, { epoch: event.epoch, seq: event.seq }); + } streamQueue.enqueue(agentId, event); }, flushStreamAgent(agentId) { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 376d04ea67..2b3fc10c8a 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -1009,7 +1009,7 @@ describe("stream reducer canonical tool calls", () => { }); it("keeps the timeline position on the whole assistant message", () => { - const timelineCursor = { epoch: "epoch-1", seq: 42 }; + const timelineCursor = { epoch: "epoch-1", seq: 42, anchorSeq: 42 }; const result = applyStreamEvent({ tail: [], head: [], @@ -1890,7 +1890,7 @@ describe("turn lifecycle events", () => { new Date("2025-01-01T15:03:11Z"), { source: "canonical", - timelineCursor: { epoch: "epoch-1", seq: 42 }, + timelineCursor: { epoch: "epoch-1", seq: 42, anchorSeq: 42 }, }, ); @@ -1902,7 +1902,11 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessage.messageId, "provider-owned-canonical"); assert.strictEqual(userMessage.text, "Analyze this"); assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); - assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 }); + assert.deepStrictEqual(userMessage.timelineCursor, { + epoch: "epoch-1", + seq: 42, + anchorSeq: 42, + }); assert.deepStrictEqual(userMessage.images, [image]); assert.deepStrictEqual(userMessage.attachments, [attachment]); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 0226993a8c..e8597ccabc 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -262,7 +262,7 @@ function produceUserMessage( ...presentation, clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, messageId: incoming.messageId ?? existing.messageId, - timelineCursor: incoming.timelineCursor ?? existing.timelineCursor, + ...advanceTimelinePosition(existing.timelineCursor, incoming.timelineCursor), }); if ( existing.id === merged.id && @@ -478,7 +478,7 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S const next = [...tail]; next[tailIndex] = { ...existing, - timelineCursor: retained.timelineCursor, + ...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor), timestamp: retained.timestamp, items: retained.items, }; @@ -495,7 +495,7 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S const next = [...tail]; next[tailIndex] = { ...existing, - timelineCursor: retained.timelineCursor, + ...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor), status: "completed", trigger: retained.trigger ?? existing.trigger, preTokens: retained.preTokens ?? existing.preTokens, @@ -515,7 +515,10 @@ function replaceTimelineIdentityItem( const existing = items[index]; if (index < 0 || !existing || existing.kind !== "plugin") return null; const next = [...items]; - next[index] = retained; + next[index] = { + ...retained, + ...advanceTimelinePosition(existing.timelineCursor, retained.timelineCursor), + }; return next; } @@ -606,7 +609,11 @@ function preserveReplacementHead( : `${tailAssistant.text}${liveAssistant.text}`; const head = [ ...unreconciledHead.slice(0, liveAssistantIndex), - { ...liveAssistant, text }, + { + ...liveAssistant, + text, + ...advanceTimelinePosition(tailAssistant.timelineCursor, liveAssistant.timelineCursor), + }, ...unreconciledHead.slice(liveAssistantIndex + 1), ]; return { @@ -621,7 +628,13 @@ function preserveReplacementHead( const head = [ ...unreconciledHead.slice(0, liveAssistantIndex), - { ...liveAssistant, text: tailAssistant.text }, + { + ...liveAssistant, + text: tailAssistant.text, + ...(liveAssistant.timelineCursor + ? advanceTimelinePosition(tailAssistant.timelineCursor, liveAssistant.timelineCursor) + : {}), + }, ...unreconciledHead.slice(liveAssistantIndex + 1), ]; return { @@ -718,7 +731,27 @@ export interface AssistantMessageItem { export interface TimelinePosition { epoch: string; + /** The latest seq that changed this row. */ seq: number; + /** The seq the row started at. Updates never move it; rewinds retire rows by it. */ + anchorSeq: number; +} + +/** + * The position of a row after an update: it moves to the update's seq and stays anchored where it + * started. Without an incoming position the row keeps the one it has. + */ +export function advanceTimelinePosition( + existing: TimelinePosition | undefined, + incoming: TimelinePosition | undefined, +): { timelineCursor?: TimelinePosition } { + if (!incoming) return existing ? { timelineCursor: existing } : {}; + if (!existing || existing.epoch !== incoming.epoch) return { timelineCursor: incoming }; + const anchorSeq = Math.min(existing.anchorSeq, incoming.anchorSeq); + if (existing.seq === incoming.seq && existing.anchorSeq === anchorSeq) { + return { timelineCursor: existing }; + } + return { timelineCursor: { ...incoming, anchorSeq } }; } export type ThoughtStatus = "loading" | "ready"; @@ -943,7 +976,7 @@ function appendAssistantMessage( ...last, text: `${last.text}${chunk}`, timestamp, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(last.timelineCursor, timelineCursor), }; return [...state.slice(0, -1), updated]; } @@ -961,7 +994,7 @@ function appendAssistantMessage( ...secondLast, text: `${secondLast.text}${chunk}`, timestamp, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(secondLast.timelineCursor, timelineCursor), }; return [...state.slice(0, -2), updated, last]; } @@ -999,7 +1032,7 @@ function appendThought( if (last && last.kind === "thought" && last.turnId === turnId) { const updated: ThoughtItem = { ...last, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(last.timelineCursor, timelineCursor), text: `${last.text}${chunk}`, timestamp, status: "loading", @@ -1179,7 +1212,7 @@ export function mergeAgentToolCallItem( return { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(existing.timelineCursor, timelineCursor), timestamp, payload: { source: "agent", @@ -1285,7 +1318,11 @@ function appendPluginTimelineItem( const existing = state[existingIndex]; if (!existing || existing.kind !== "plugin") return state; const next = [...state]; - next[existingIndex] = { ...nextItem, id: existing.id }; + next[existingIndex] = { + ...nextItem, + id: existing.id, + ...advanceTimelinePosition(existing.timelineCursor, timelineCursor), + }; return next; } @@ -1316,7 +1353,7 @@ function appendTodoList( const next = [...state]; next[previousIndex] = { ...previous, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(previous.timelineCursor, timelineCursor), items: normalizedItems, timestamp, }; @@ -1335,7 +1372,7 @@ function appendTodoList( const next = [...state]; next[next.length - 1] = { ...lastItem, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(lastItem.timelineCursor, timelineCursor), items: normalizedItems, activity: { type: "created", count: normalizedItems.length }, timestamp, @@ -1480,7 +1517,7 @@ function reduceTimelineCompaction( if (loadingIdx >= 0 && existing && existing.kind === "compaction") { const updated: CompactionItem = { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), + ...advanceTimelinePosition(existing.timelineCursor, timelineCursor), status: "completed", trigger: item.trigger ?? existing.trigger, preTokens: item.preTokens ?? existing.preTokens, diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index a881f2d7f8..6b14d9d855 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -146,6 +146,10 @@ export async function runAttachCommand( const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => { if (message.type === "agent.timeline.replacement") { console.log("\n[Timeline replaced; earlier output is no longer current]"); + } else if (message.type === "agent.timeline.rewind") { + console.log( + "\n[Conversation rewound; output since the rewound message is no longer current]", + ); } else if (message.type === "agent.timeline.subscription_restored") { console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); } else if (message.type === "agent.timeline.error") { diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index ed77d4be62..27b5cc0ecf 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -370,6 +370,13 @@ async function runFollowMode({ console.log("\n[Timeline replaced; earlier output is no longer current]"); return; } + if (message.type === "agent.timeline.rewind") { + writer.end(); + console.log( + "\n[Conversation rewound; output since the rewound message is no longer current]", + ); + return; + } if (message.type === "agent.timeline.error") { writer.end(); diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts index a3d12e48f6..9a197af068 100644 --- a/packages/client/src/connection.test.ts +++ b/packages/client/src/connection.test.ts @@ -209,6 +209,37 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi } }); +test("SDK subscribers receive a conversation rewind as a retired range", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection(); + const received: unknown[] = []; + try { + const ready = h.client.connect(); + h.open(); + await ready; + const off = createPaseoApi(h.client) + .agents.ref("agent") + .timeline.subscribe((event) => received.push(event)); + await off.ready; + h.receive({ + type: "agent.timeline.rewind", + payload: { + agentId: "agent", + epoch: "epoch-1", + seq: 12, + retireFromSeq: 7, + subscriptionId: off.subscriptionId, + }, + }); + expect(received).toEqual([ + { agentId: "agent", event: { type: "rewind", epoch: "epoch-1", seq: 12, retireFromSeq: 7 } }, + ]); + off(); + } finally { + await h.client.close(); + } +}); + test("local listeners create no demand before or after reconnect", async () => { const h = connection(); try { diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index 62753bca5c..398526cac9 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -140,6 +140,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.compactProviderSnapshots]: true, [CLIENT_CAPS.providerSnapshotReferences]: true, [CLIENT_CAPS.timelineReplacementInvalidation]: true, + [CLIENT_CAPS.timelineRewindOperations]: true, [CLIENT_CAPS.timelineNotifications]: true, [CLIENT_CAPS.pluginTimelineItems]: true, [CLIENT_CAPS.workspaceSetupBlocked]: true, diff --git a/packages/client/src/connection/legacy.ts b/packages/client/src/connection/legacy.ts index 5dec96e428..c3aeff715c 100644 --- a/packages/client/src/connection/legacy.ts +++ b/packages/client/src/connection/legacy.ts @@ -246,7 +246,9 @@ export class LegacySubscriptions { ); case "agent.timeline.set_subscription.request": return ( - (message.type === "agent_stream" || message.type === "agent.timeline.replacement") && + (message.type === "agent_stream" || + message.type === "agent.timeline.replacement" || + message.type === "agent.timeline.rewind") && (query.agentIds as string[]).includes(message.payload.agentId) ); case "session.events.set_subscription.request": diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3655b0023a..246c5169e8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -317,6 +317,14 @@ export type PaseoAgentTimelineEvent = agentId: string; event: { type: "replacement"; epoch: string }; } + | { + /** + * A conversation rewind removed the rows anchored in `[retireFromSeq, seq)` from the active + * history. Rows after `seq` belong to the rewound conversation. + */ + agentId: string; + event: { type: "rewind"; epoch: string; seq: number; retireFromSeq: number }; + } | { agentId: string; subscriptionId: string; @@ -338,7 +346,9 @@ export interface PaseoAgentTimelineHandle { * Delivers live events only. After reconnect, subscription_restored precedes * subsequent updates. History may have been missed; use refetch() to request * the range you need. No history is fetched automatically. A replacement event - * invalidates the previous epoch. Subscription errors release this observation. + * invalidates the previous epoch. A rewind event removes `[retireFromSeq, seq)` + * from the active history without changing the epoch. Subscription errors + * release this observation. * Await the returned unsubscribe function's `ready` promise before starting * work that must be observed. It rejects if establishment fails. */ @@ -942,6 +952,16 @@ function createAgentHandleFactory( agentId: id, event: { type: "replacement", epoch: message.payload.epoch }, }); + case "agent.timeline.rewind": + return handler({ + agentId: id, + event: { + type: "rewind", + epoch: message.payload.epoch, + seq: message.payload.seq, + retireFromSeq: message.payload.retireFromSeq, + }, + }); } }), }, diff --git a/packages/client/src/timeline-subscription/index.ts b/packages/client/src/timeline-subscription/index.ts index 9293a0a739..6f210d31bc 100644 --- a/packages/client/src/timeline-subscription/index.ts +++ b/packages/client/src/timeline-subscription/index.ts @@ -3,7 +3,7 @@ import type { OwnedSubscription, TimelineSubscription } from "../connection/inde type TimelineUpdate = Extract< SessionOutboundMessage, - { type: "agent_stream" | "agent.timeline.replacement" } + { type: "agent_stream" | "agent.timeline.replacement" | "agent.timeline.rewind" } >; /** Local SDK lifecycle messages; these are not additional wire RPCs. */ @@ -59,7 +59,11 @@ export function subscribeTimeline( }); }, update: (message) => { - if (message.type === "agent_stream" || message.type === "agent.timeline.replacement") + if ( + message.type === "agent_stream" || + message.type === "agent.timeline.replacement" || + message.type === "agent.timeline.rewind" + ) notify(message); }, error: fail, diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 3ae02671e7..6f88b0fdbc 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -39,6 +39,9 @@ export const CLIENT_CAPS = { // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy // reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation. timelineReplacementInvalidation: "timeline_replacement_invalidation", + // COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 once the client + // floor applies rewinds. Clients without it see a rewound history under a derived epoch. + timelineRewindOperations: "timeline_rewind_operations", // COMPAT(timelineNotifications): added in v0.7.2. The daemon omits notification // timeline items for older clients whose strict timeline union rejects them. // Remove after 2027-03-03 once the supported client floor is >= v0.7.2. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index dc63d56b75..959a32e3a1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3753,6 +3753,9 @@ export const ServerInfoStatusPayloadSchema = z "terminal-size-ownership": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), + // COMPAT(timelineRewindOperations): added in v0.14.1, remove gate after 2027-05-10. + // A conversation rewind arrives as `agent.timeline.rewind`, not a replacement. + timelineRewindOperations: z.boolean().optional(), // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. agentTimelinePromptIndex: z.boolean().optional(), // COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07. @@ -4752,6 +4755,12 @@ export const AgentTimelineEntryPayloadSchema = z.object({ collapsed: z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle", "identity"])), }); +/** Active rows anchored in `[retireFromSeq, seq)` left the conversation. `seq` is the retirement's own. */ +export const AgentTimelineRetirementSchema = z.object({ + seq: z.number().int().nonnegative(), + retireFromSeq: z.number().int().nonnegative(), +}); + export const FetchAgentTimelineResponseMessageSchema = z.object({ type: z.literal("fetch_agent_timeline_response"), payload: z.object({ @@ -4775,6 +4784,9 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ hasNewer: z.boolean(), mergeWindow: z.boolean().optional(), entries: z.array(AgentTimelineEntryPayloadSchema), + // COMPAT(timelineRewindOperations): added in v0.14.1. Retirements whose seq lies in the page's + // span; apply them before the entries. Sent only to clients with the capability. + retirements: z.array(AgentTimelineRetirementSchema).optional(), error: z.string().nullable(), }), }); @@ -4788,6 +4800,18 @@ export const AgentTimelineReplacementMessageSchema = z.object({ }), }); +/** A conversation rewind retired active rows. Apply it when `seq` follows the loaded end. */ +export const AgentTimelineRewindMessageSchema = z.object({ + type: z.literal("agent.timeline.rewind"), + payload: z.object({ + subscriptionId: z.string().optional(), + agentId: z.string(), + epoch: z.string(), + seq: z.number().int().nonnegative(), + retireFromSeq: z.number().int().nonnegative(), + }), +}); + export const AgentTimelineSearchResponseMessageSchema = z.object({ type: z.literal("agent.timeline.search.response"), payload: z.object({ @@ -7090,6 +7114,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, AgentTimelineReplacementMessageSchema, + AgentTimelineRewindMessageSchema, AgentTimelineSearchResponseMessageSchema, AgentTimelineListPromptsResponseMessageSchema, ProviderSubagentListResponseMessageSchema, @@ -7735,6 +7760,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(), [CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(), [CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(), + [CLIENT_CAPS.timelineRewindOperations]: z.boolean().optional(), [CLIENT_CAPS.timelineNotifications]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index abc89b06aa..9ce4f2061d 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -124,6 +124,64 @@ describe("wire schema compatibility", () => { }); }); + test("rewind operations are opt-in and older fetch responses still parse", () => { + expect( + WSHelloMessageSchema.parse({ + type: "hello", + clientId: "capable-client", + clientType: "mobile", + protocolVersion: 1, + capabilities: { timeline_rewind_operations: true }, + }).capabilities, + ).toEqual({ timeline_rewind_operations: true }); + + const rewind = { + type: "agent.timeline.rewind", + payload: { + subscriptionId: "sub-1", + agentId: "agent-1", + epoch: "epoch-1", + seq: 12, + retireFromSeq: 7, + }, + }; + expect(SessionOutboundMessageSchema.parse(rewind)).toEqual(rewind); + + const page = { + requestId: "fetch-1", + agentId: "agent-1", + agent: null, + direction: "after", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 14, nextSeq: 15 }, + startCursor: { epoch: "epoch-1", seq: 13 }, + endCursor: { epoch: "epoch-1", seq: 14 }, + hasOlder: true, + hasNewer: false, + entries: [], + error: null, + }; + const withRetirements = { ...page, retirements: [{ seq: 12, retireFromSeq: 7 }] }; + expect( + SessionOutboundMessageSchema.parse({ + type: "fetch_agent_timeline_response", + payload: withRetirements, + }), + ).toMatchObject({ payload: { retirements: [{ seq: 12, retireFromSeq: 7 }] } }); + // A daemon before rewind operations sends no retirements. + const older = SessionOutboundMessageSchema.parse({ + type: "fetch_agent_timeline_response", + payload: page, + }); + expect(older.type === "fetch_agent_timeline_response" && older.payload.retirements).toBe( + undefined, + ); + }); + test("server info strips unknown legacy features while accepting former turn identity", () => { const parsed = ServerInfoStatusPayloadSchema.parse({ status: "server_info", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c85c21a619..7d014ef122 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -984,6 +984,7 @@ test("unavailable steer interrupts once and starts one replacement turn", async session.steerResult = "unavailable"; const { manager, agentId, workdir } = await startAndSteerThroughManager(session); try { + await manager.waitForAgentRunStart(agentId); expect(session.interruptCount).toBe(1); expect(session.startCount).toBe(2); expect(await manager.getTimeline(agentId)).toContainEqual( @@ -1470,6 +1471,7 @@ test("missing steer operation interrupts once and starts one replacement turn", const session = new UnsupportedSteeringSession({ provider: "codex", cwd: process.cwd() }); const { manager, agentId, workdir } = await startAndSteerThroughManager(session); try { + await manager.waitForAgentRunStart(agentId); expect(session.interruptCount).toBe(1); expect(session.startCount).toBe(2); expect(await manager.getTimeline(agentId)).toContainEqual( diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 69694738d3..6125e3fc64 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -53,9 +53,11 @@ import { type AgentTimelineItem, type AgentUsage, type AgentRuntimeInfo, + type ConversationRewindResult, type ImportedTimelineEntry, type ImportableProviderSession, type ListImportableSessionsOptions, + RewindNotAppliedError, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import { toStoredAgentRecord } from "./agent-projections.js"; @@ -72,6 +74,8 @@ import { type TimelineCommit, type TimelineEntry, type TimelineIngestResult, + type TimelineRetirementCommit, + type TimelineRewindIntent, type TimelineScope, type TimelineSeed, } from "./timeline/index.js"; @@ -85,7 +89,11 @@ import { type ForegroundTurnWaiter, type PendingForegroundRun, } from "./agent-run-state.js"; -import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; +import { + invokeRewindCapability, + RewindOutcomeUnknownError, + type RewindMode, +} from "./rewind/rewind.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { @@ -121,6 +129,9 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { type TimeoutResult = "completed" | "timed_out"; +/** How settling an open rewind ended: nothing open, retired, abandoned unchanged, or still open. */ +type RewindSettlement = "none" | "retired" | "abandoned" | "open"; + function submittedPromptText(prompt: AgentPromptInput): string { if (typeof prompt === "string") { return prompt; @@ -242,11 +253,14 @@ export type AgentManagerEvent = | { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean } | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } | { - type: "timeline_replacement"; + /** Active rows anchored in `[retireFromSeq, seq)` left the conversation; they stay stored. */ + type: "timeline_retirement"; agentId: string; epoch: string; - /** The whole replacement history, for clients that cannot refetch it. */ - rows: AgentTimelineFetchResult["rows"]; + seq: number; + retireFromSeq: number; + /** Retirements of this history so far, including this one. */ + retirementCount: number; } | { type: "agent_stream"; @@ -254,6 +268,8 @@ export type AgentManagerEvent = event: AgentStreamEvent; seq?: number; epoch?: string; + /** Retirements of the history when this row committed; present with `seq`. */ + retirementCount?: number; timestamp?: string; }; @@ -934,7 +950,7 @@ export class AgentManager { await this.trackAgentRegistrationOperation( this.runLifecycleMutation(agentId, async () => { if (!this.needsPluginSessionReload(agentId)) return; - await this.reloadAgentSessionInternal(agentId); + await this.reloadSession(agentId); }), ); } @@ -1631,7 +1647,7 @@ export class AgentManager { }); for (const event of imported.providerSubagentEvents ?? []) { const update = await this.providerSubagents.apply(agent.id, event.provider, event.event); - this.dispatch({ type: "provider_subagent", event: update }); + if (update) this.dispatch({ type: "provider_subagent", event: update }); } return agent; } finally { @@ -1653,24 +1669,24 @@ export class AgentManager { options?: { rehydrateFromDisk?: boolean }, ): Promise { return this.trackAgentRegistrationOperation( - this.runLifecycleMutation(agentId, () => - this.reloadAgentSessionInternal(agentId, overrides, options), - ), + this.runLifecycleMutation(agentId, async () => { + return (await this.reloadSession(agentId, overrides, options)).agent; + }), ); } - private async reloadAgentSessionInternal( + private async reloadSession( agentId: string, overrides?: Partial, options?: { rehydrateFromDisk?: boolean }, - ): Promise { + ): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> { this.assertAcceptingAgentRegistrations(); let existing = this.requireSessionAgent(agentId); if (this.hasInFlightRun(agentId)) { await this.cancelAgentRunBefore(agentId, "reload"); existing = this.requireSessionAgent(agentId); } - const rehydrateFromDisk = options?.rehydrateFromDisk ?? false; + const { rehydrateFromDisk, settleRewind } = await this.planReloadHistory(agentId, options); const preservedHistoryPrimed = existing.historyPrimed; const preservedLastUsage = existing.lastUsage; const preservedLastError = existing.lastError; @@ -1731,18 +1747,11 @@ export class AgentManager { await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); this.assertAcceptingAgentRegistrations(); - if (rehydrateFromDisk) { - // Wipe the in-memory timeline so registerSession mints a new epoch and - // hydrateTimelineFromProvider re-streams the freshly read provider history. - await this.timeline.delete({ agentId }); - for (const event of await this.providerSubagents.deleteParent(agentId)) { - this.dispatch({ type: "provider_subagent", event }); - } - } + if (rehydrateFromDisk) await this.discardTimelineForRehydrate(agentId); // Preserve existing labels and timeline during reload. handedToRegistration = true; - return this.registerSession(session, storedConfig, agentId, { + const registered = this.registerSession(session, storedConfig, agentId, { labels: existing.labels, workspaceId: existing.workspaceId, owner: existing.owner, @@ -1756,6 +1765,9 @@ export class AgentManager { attention: preservedAttention, restoring: true, }); + return settleRewind + ? registered.then((snapshot) => this.settleRewindAfterReload(snapshot)) + : registered.then((agent) => ({ agent, rewind: "none" as const })); } catch (error) { if (closedExisting) { this.emitClosedAgent(closedExisting, { persist: false }); @@ -1780,6 +1792,30 @@ export class AgentManager { } } + /** + * An open rewind is judged from the resumed session's history, so that history must not replace + * the timeline first. + */ + private async planReloadHistory( + agentId: string, + options: { rehydrateFromDisk?: boolean } | undefined, + ): Promise<{ rehydrateFromDisk: boolean; settleRewind: boolean }> { + const openRewind = (await this.timeline.describe({ agentId }))?.rewind ?? null; + return { + rehydrateFromDisk: (options?.rehydrateFromDisk ?? false) && openRewind === null, + settleRewind: openRewind !== null, + }; + } + + // Wipes the in-memory timeline so registerSession mints a new epoch and + // hydrateTimelineFromProvider re-streams the freshly read provider history. + private async discardTimelineForRehydrate(agentId: string): Promise { + await this.timeline.delete({ agentId }); + for (const event of await this.providerSubagents.deleteParent(agentId)) { + this.dispatch({ type: "provider_subagent", event }); + } + } + private async closeReloadedSession(session: AgentSession, agentId: string): Promise { let operation = this.reloadedSessionCloses.get(session); if (!operation) { @@ -2002,7 +2038,7 @@ export class AgentManager { id: subagent.id, status: "canceled", }); - this.dispatch({ type: "provider_subagent", event }); + if (event) this.dispatch({ type: "provider_subagent", event }); } } @@ -2712,7 +2748,7 @@ export class AgentManager { async appendTimelineItem( agentId: string, item: AgentTimelineItem, - ): Promise<{ seq: number; epoch: string }> { + ): Promise<{ seq: number; epoch: string; retirementCount: number }> { const agent = this.requireAgent(agentId); item = limitAgentTimelineItemContent(item); this.touchUpdatedAt(agent); @@ -2722,9 +2758,12 @@ export class AgentManager { { item }, { publish: (commit) => this.publishTimelineEvent(agentId, event, commit) }, ); + if (result.kind === "dropped") { + throw new Error("Timeline item belongs to a rewound turn"); + } this.reportUnpublished(agentId, result); await this.persistSnapshot(agent); - return { seq: result.row.seq, epoch: result.epoch }; + return { seq: result.row.seq, epoch: result.epoch, retirementCount: result.retirementCount }; } async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise { @@ -2746,6 +2785,8 @@ export class AgentManager { }): Promise { const { agent, agentId, pendingRun, prompt, options } = params; try { + // A rewind still open from an earlier failure must settle before the conversation moves on. + await this.settleOpenRewind(agent, { judge: false }); const result = await agent.session.startTurn(prompt, options); if (pendingRun.settled) { throw new Error(`Agent ${agentId} run was canceled before its turn started`); @@ -3500,11 +3541,17 @@ export class AgentManager { // Without an active run, the rewind holds the run from its call, so no prompt starts // while it reads the history. const heldFromCall = this.hasInFlightRun(agentId) ? null : this.runs.createPendingRun(agentId); + const scope: TimelineScope = { agentId }; let providerMessageId: string; + // Opening the rewind checks its target is active, before a running turn is canceled for it. + let intent: TimelineRewindIntent | null = null; try { + if (mode !== "files") await this.settleOpenRewind(agent, { judge: false }); providerMessageId = await this.resolveRewindTarget(agentId, messageId); + if (mode !== "files") intent = await this.timeline.beginRewind(scope, messageId); if (!heldFromCall) await this.cancelAgentRunBefore(agentId, "rewind"); } catch (error) { + if (intent) await this.timeline.abandonRewind(scope, intent.id); if (heldFromCall) this.runs.settleForegroundRun(agentId, heldFromCall.token); throw error; } @@ -3515,26 +3562,19 @@ export class AgentManager { { agentId, provider: agent.provider, messageId, mode }, "agent.rewind.start", ); - await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode }); - if (mode !== "files") { - await this.hydrateTimelineFromProvider(agentId, { - force: true, - broadcast: true, - broadcastTimeline: false, - }); - const replacement = await this.timeline.fetch({ agentId }, { limit: 0 }); - this.dispatch({ - type: "timeline_replacement", - agentId, - epoch: replacement.epoch, - rows: replacement.rows, - }); + let outcome: "retired" | "unreported" = "retired"; + try { + if (intent) { + outcome = await this.rewindConversation(agent, intent, { providerMessageId, mode }); + } else { + await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode }); + await this.persistRewoundSession(agent); + } + } finally { + this.runs.settleForegroundRun(agentId, lock.token); } - // Rewind stages provider events under the run lock; publish its final state directly. - this.refreshSessionPersistence(agent); - await this.refreshSessionState(agent, { emit: false }); - await this.persistSnapshot(agent); - this.emitState(agent, { persist: false }); + // Reloading takes the agent's lifecycle lane, so it runs after the rewind releases the run. + if (outcome === "unreported") await this.settleUnreportedRewind(agent); this.logger.info( { agentId, provider: agent.provider, messageId, mode }, "agent.rewind.complete", @@ -3545,11 +3585,141 @@ export class AgentManager { "agent.rewind.failed", ); throw error; - } finally { - this.runs.settleForegroundRun(agentId, lock.token); } } + /** + * Provider outcome, then the persisted handle, then the retirement. A known failure abandons the + * rewind; an unknown one leaves it open. An unreported one stays open for the resumed session's + * history to settle. The provider call is never repeated for one rewind. + */ + private async rewindConversation( + agent: ActiveManagedAgent, + intent: TimelineRewindIntent, + target: { providerMessageId: string; mode: RewindMode }, + ): Promise<"retired" | "unreported"> { + const scope: TimelineScope = { agentId: agent.id }; + let result: ConversationRewindResult; + try { + result = (await invokeRewindCapability(agent.session, { + messageId: target.providerMessageId, + mode: target.mode, + })) ?? { kind: "at_target" }; + } catch (error) { + if (!(error instanceof RewindNotAppliedError)) + throw new RewindOutcomeUnknownError({ cause: error }); + await this.timeline.abandonRewind(scope, intent.id); + throw error; + } + if (result.kind === "unreported") { + await this.persistRewoundSession(agent); + return "unreported"; + } + await this.timeline.recordRewindCut(scope, intent.id, result); + await this.completeRewind(agent, intent.id); + return "retired"; + } + + /** + * The provider finished the rewind without saying what it removed. Reloading resumes its + * persisted session, whose replayed prompts settle the open rewind. A session without a + * persisted handle would resume as a new, empty conversation, which proves nothing. + */ + private async settleUnreportedRewind(agent: ActiveManagedAgent): Promise { + if (!agent.persistence) throw new RewindOutcomeUnknownError(); + const { rewind } = await this.trackAgentRegistrationOperation( + this.runLifecycleMutation(agent.id, () => this.reloadSession(agent.id)), + ); + if (rewind === "retired") return; + if (rewind === "abandoned") { + throw new RewindNotAppliedError( + "The provider finished the rewind, but its history still contains the message", + ); + } + throw new RewindOutcomeUnknownError(); + } + + /** Persists the session the provider now continues, then commits the retirement. */ + private async completeRewind(agent: ActiveManagedAgent, intentId: string): Promise { + await this.persistRewoundSession(agent); + const completion = await this.timeline.completeRewind({ agentId: agent.id }, intentId, { + publish: (commit) => this.publishRetirement(agent.id, commit), + }); + if (completion.kind === "unpublished") { + this.logger.error( + { err: completion.error, agentId: agent.id }, + "Failed to publish committed rewind", + ); + } + } + + private async persistRewoundSession(agent: ActiveManagedAgent): Promise { + // Rewind stages provider events under the run lock; publish its final state directly. + this.refreshSessionPersistence(agent); + await this.refreshSessionState(agent, { emit: false }); + await this.persistSnapshot(agent); + this.emitState(agent, { persist: false }); + } + + /** + * Settles a rewind left open by an earlier failure. A recorded provider outcome is committed + * without calling the provider again. An unknown one is judged from the prompts the provider's + * history replays when `judge` is set (a freshly resumed session); otherwise it stays open. + */ + private async settleOpenRewind( + agent: ActiveManagedAgent, + options: { judge: boolean }, + ): Promise { + const scope: TimelineScope = { agentId: agent.id }; + const intent = (await this.timeline.describe(scope))?.rewind; + if (!intent) return "none"; + if (intent.cut) { + await this.completeRewind(agent, intent.id); + return "retired"; + } + if (!options.judge) throw new RewindOutcomeUnknownError(); + const verdict = await this.timeline.judgeRewind( + scope, + intent.id, + await this.readProviderPromptIds(agent.session), + ); + this.logger.info({ agentId: agent.id, intentId: intent.id, verdict }, "agent.rewind.judged"); + if (verdict.kind === "unchanged") { + await this.timeline.abandonRewind(scope, intent.id); + return "abandoned"; + } + if (verdict.kind === "applied") { + await this.timeline.recordRewindCut(scope, intent.id, verdict.cut); + await this.completeRewind(agent, intent.id); + return "retired"; + } + return "open"; + } + + /** The reload itself succeeded; a rewind that still cannot settle stays open and is reported. */ + private async settleRewindAfterReload( + snapshot: ManagedAgent, + ): Promise<{ agent: ManagedAgent; rewind: RewindSettlement }> { + let rewind: RewindSettlement = "open"; + try { + rewind = await this.settleOpenRewind(this.requireSessionAgent(snapshot.id), { judge: true }); + } catch (error) { + this.logger.warn({ err: error, agentId: snapshot.id }, "agent.rewind.settle_failed"); + } + return { agent: this.getAgent(snapshot.id) ?? snapshot, rewind }; + } + + /** The prompt ids of the provider's current history, in order. Reads; records nothing. */ + private async readProviderPromptIds(session: AgentSession): Promise { + const ids: string[] = []; + for await (const event of session.streamHistory()) { + if (event.type !== "timeline" || event.item.type !== "user_message") continue; + if (event.item.messageId && ids.at(-1) !== event.item.messageId) + ids.push(event.item.messageId); + } + return ids; + } + /** The provider's id for the rewound prompt. Paseo-submitted prompts need its acknowledgement. */ private async resolveRewindTarget(agentId: string, messageId: string): Promise { const submittedRow = await this.timeline.findSubmittedPrompt({ agentId }, messageId); @@ -4173,7 +4343,7 @@ export class AgentManager { ): Promise { if (event.type === "provider_subagent") { const update = await this.providerSubagents.apply(agent.id, event.provider, event.event); - this.dispatch({ type: "provider_subagent", event: update }); + if (update) this.dispatch({ type: "provider_subagent", event: update }); return; } const turnId = getAgentStreamEventTurnId(event); @@ -4372,7 +4542,7 @@ export class AgentManager { } for (const event of providerSubagentEvents) { const update = await this.providerSubagents.apply(agent.id, event.provider, event.event); - if (broadcast) { + if (update && broadcast) { this.dispatch({ type: "provider_subagent", event: update }); } } @@ -4425,6 +4595,7 @@ export class AgentManager { const providerSubagentEvents: AgentManagerEvent[] = []; for (const event of historySubagentEvents) { const update = await this.providerSubagents.apply(agent.id, event.provider, event.event); + if (!update) continue; const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update }; if (deferredBroadcast) { providerSubagentEvents.push(managerEvent); @@ -5009,10 +5180,29 @@ export class AgentManager { this.dispatchStream(agentId, event, { seq: commit.row.seq, epoch: commit.epoch, + retirementCount: commit.retirementCount, timestamp: commit.row.timestamp, }); } + /** Children spawned by retired tool calls leave with them, before clients learn of the retirement. */ + private publishRetirement(agentId: string, commit: TimelineRetirementCommit): void { + const toolCallIds = new Set( + commit.retiredRows.flatMap((row) => (row.item.type === "tool_call" ? [row.item.callId] : [])), + ); + for (const event of this.providerSubagents.retireSpawnedBy(agentId, toolCallIds)) { + this.dispatch({ type: "provider_subagent", event }); + } + this.dispatch({ + type: "timeline_retirement", + agentId, + epoch: commit.epoch, + seq: commit.retirement.seq, + retireFromSeq: commit.retirement.retireFromSeq, + retirementCount: commit.retirementCount, + }); + } + /** A commit whose publication threw stays committed; the failure is logged, not retried. */ private reportUnpublished( agentId: string, @@ -5314,6 +5504,7 @@ export class AgentManager { metadata?: { seq?: number; epoch?: string; + retirementCount?: number; timestamp?: string; }, ): void { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6570392311..05ca59938e 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -590,6 +590,38 @@ export interface ImportedProviderSession { providerSubagentEvents?: Extract[]; } +/** + * What a conversation rewind removed from the provider session. `at_target`: the target prompt and + * everything after it. `after`: the provider kept history through the prompt `retainedMessageId` + * only (null: it kept nothing), which can cut earlier than the target. + */ +export type ConversationRewindCut = + | { kind: "at_target" } + | { kind: "after"; retainedMessageId: string | null }; + +/** + * How a conversation rewind the provider finished ended: a cut, or `unreported` when the provider + * finished without saying what it removed. Paseo then judges it from the prompts a resumed + * session's `streamHistory` replays. + */ +export type ConversationRewindResult = ConversationRewindCut | { kind: "unreported" }; + +/** The provider conversation is known to be unchanged; the rewind can be abandoned. */ +export class RewindNotAppliedError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "RewindNotAppliedError"; + } + + /** Marks a failure that happened before the provider conversation could change. */ + static from(error: unknown): RewindNotAppliedError { + if (error instanceof RewindNotAppliedError) return error; + return new RewindNotAppliedError(error instanceof Error ? error.message : String(error), { + cause: error, + }); + } +} + export interface AgentSessionConfig { provider: AgentProvider; cwd: string; @@ -710,9 +742,17 @@ export interface AgentSession { setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; setFeature?(featureId: string, value: unknown): Promise; - revertConversation?(input: { messageId: string }): Promise; + /** + * Rewinds the provider conversation to before the prompt `messageId`. Resolves with what the + * provider actually removed; `void` means exactly the target and everything after it. Rejects + * with `RewindNotAppliedError` only when the conversation is known to be unchanged; any other + * rejection leaves the outcome unknown, and Paseo judges it later from the prompts `streamHistory` + * replays once the session is resumed. + */ + revertConversation?(input: { messageId: string }): Promise; revertFiles?(input: { messageId: string }): Promise; - revertBoth?(input: { messageId: string }): Promise; + /** `revertConversation` plus file changes, with the same outcome contract. */ + revertBoth?(input: { messageId: string }): Promise; /** * Out-of-band prompt handler. When non-null, the manager runs the returned * handler instead of allocating a turn. The handler emits stream events 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 9e3f3c41a1..3a4d34d1db 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -1,4 +1,4 @@ -import type { ProjectedTimelineRow } from "./timeline-projection.js"; +import type { ProjectedTimelineRow, TimelineRetirement } from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; export interface AgentTimelineRow { @@ -12,6 +12,11 @@ export interface AgentTimelineRow { export interface AgentTimelineCursor { epoch: string; seq: number; + /** + * For a reader that cannot apply retirements: the retirement count its view reflects. The + * cursor is stale once another retirement has happened. + */ + retirementCount?: number; } export type AgentTimelineFetchDirection = "tail" | "before" | "after"; @@ -46,4 +51,8 @@ export interface AgentTimelineFetchResult { startSeq: number | null; endSeq: number | null; rows: ProjectedTimelineRow[]; + /** Retirements whose seq lies in the page's span. A client applies them before the rows. */ + retirements: TimelineRetirement[]; + /** Retirements of this history so far. */ + retirementCount: number; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 101a19a39f..68f96a69ee 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -16,7 +16,11 @@ import type { import { setImmediate as nextTurn } from "node:timers/promises"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js"; +import { + RewindNotAppliedError, + type AgentClient, + type AgentStreamEvent, +} from "./agent-sdk-types.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import { AgentManager } from "./agent-manager.js"; import { buildProviderRegistry } from "./provider-registry.js"; @@ -1075,6 +1079,80 @@ describe("PluginAgentClientRegistry", () => { }); }); +describe("plugin conversation rewind", () => { + async function openRevertableSession(reply: "request.completed" | "request.failed") { + const harness = createProviderHarness({ + capabilities: [...CAPABILITIES, "session.revert.conversation"], + openChildren: (sessionId, emit) => { + emit({ + type: "timeline.item", + sessionId, + item: { type: "user_message", id: "prompt-b", text: "B", revertToken: "before-b" }, + }); + }, + handleInput: async (input, emit) => { + if (input.type !== "session.revert") return false; + emit( + reply === "request.completed" + ? { type: "request.completed", requestId: input.requestId } + : { type: "request.failed", requestId: input.requestId, error: { message: "Busy" } }, + ); + return true; + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + return { harness, registry, session }; + } + + // The plugin protocol completes a revert without saying which messages it removed, so the + // resumed session's replay settles it. + test("a completed revert is unreported", async () => { + const { harness, registry, session } = await openRevertableSession("request.completed"); + try { + await expect(session.revertConversation!({ messageId: "prompt-b" })).resolves.toEqual({ + kind: "unreported", + }); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([ + expect.objectContaining({ token: "before-b", scope: "conversation" }), + ]); + } finally { + await session.close(); + registry.replace([]); + } + }); + + // A failed request may still have removed messages. + test("a failed revert leaves the outcome unknown", async () => { + const { registry, session } = await openRevertableSession("request.failed"); + try { + const rewind = session.revertConversation!({ messageId: "prompt-b" }); + await expect(rewind).rejects.toThrow("Busy"); + await expect(rewind).rejects.not.toBeInstanceOf(RewindNotAppliedError); + } finally { + await session.close(); + registry.replace([]); + } + }); + + test("a message without a revert token is not sent to the plugin", async () => { + const { harness, registry, session } = await openRevertableSession("request.completed"); + try { + await expect(session.revertConversation!({ messageId: "unknown" })).rejects.toBeInstanceOf( + RewindNotAppliedError, + ); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]); + } finally { + await session.close(); + registry.replace([]); + } + }); +}); + type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt"; async function requestFromClient(client: AgentClient, kind: RequestKind): Promise { diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index f09f6349b0..dbf39bbdb1 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -56,7 +56,9 @@ import type { ProviderRefreshContext, SteerActiveTurnOptions, SteerResult, + ConversationRewindResult, } from "./agent-sdk-types.js"; +import { RewindNotAppliedError } from "./agent-sdk-types.js"; import { resolveProviderLaunch, checkProviderLaunchAvailable, @@ -1357,16 +1359,21 @@ class PluginAgentSession implements AgentSession { }); } - async revertConversation(input: { messageId: string }): Promise { + // A plugin completes `session.revert` without saying which messages it removed, and a failed + // request may have removed some, so neither is a cut. Paseo settles a completed one from the + // history the plugin replays when the session is resumed. + async revertConversation(input: { messageId: string }): Promise { await this.revert(input.messageId, "conversation"); + return { kind: "unreported" }; } async revertFiles(input: { messageId: string }): Promise { await this.revert(input.messageId, "files"); } - async revertBoth(input: { messageId: string }): Promise { + async revertBoth(input: { messageId: string }): Promise { await this.revert(input.messageId, "both"); + return { kind: "unreported" }; } timelineHistory(): ImportedProviderSession["timeline"] { @@ -1638,7 +1645,9 @@ class PluginAgentSession implements AgentSession { private async revert(messageId: string, scope: "conversation" | "files" | "both") { const token = this.revertTokens.get(messageId); - if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); + if (token === undefined) { + throw new RewindNotAppliedError(`No provider revert token for message ${messageId}`); + } await this.bridge.revert({ type: "session.revert", requestId: randomUUID(), diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts index 81a1ead3b3..df47f2a37a 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -77,7 +77,7 @@ describe("ProviderSubagentStore", () => { }, }); - expect(update.type).toBe("timeline"); + expect(update?.type).toBe("timeline"); const [row] = (await subagents.fetchTimeline("parent-a", "child-1")).rows; expect(row?.item).toMatchObject({ type: "tool_call", @@ -85,6 +85,46 @@ describe("ProviderSubagentStore", () => { }); }); + test("retires the children of rewound tool calls and drops their late events", async () => { + const subagents = new ProviderSubagentStore(new AgentTimeline()); + const upsert = (id: string, fields: { toolCallId?: string; parentSubagentId?: string }) => + subagents.apply("parent-a", "claude", { + type: "upsert", + id, + status: "running", + timestamp: "2026-07-12T10:00:00.000Z", + ...fields, + }); + await upsert("kept", { toolCallId: "call-a" }); + await upsert("rewound", { toolCallId: "call-b" }); + await upsert("grandchild", { parentSubagentId: "rewound" }); + await subagents.apply("parent-a", "claude", { + type: "timeline", + id: "rewound", + item: { type: "assistant_message", text: "Working on B." }, + }); + + expect(subagents.retireSpawnedBy("parent-a", new Set(["call-b"]))).toEqual([ + { type: "remove", parentAgentId: "parent-a", subagentId: "rewound" }, + { type: "remove", parentAgentId: "parent-a", subagentId: "grandchild" }, + ]); + expect(subagents.list("parent-a").map((subagent) => subagent.id)).toEqual(["kept"]); + + // The retired child's history stays stored; a late event neither revives nor extends it. + expect( + await subagents.apply("parent-a", "claude", { + type: "timeline", + id: "rewound", + item: { type: "assistant_message", text: "Late output." }, + }), + ).toBeNull(); + expect(await upsert("rewound", { toolCallId: "call-b" })).toBeNull(); + expect(subagents.list("parent-a").map((subagent) => subagent.id)).toEqual(["kept"]); + expect( + (await subagents.fetchTimeline("parent-a", "rewound")).rows.map((row) => row.item), + ).toEqual([{ type: "assistant_message", text: "Working on B." }]); + }); + test("pages provider history on projected item boundaries", async () => { const subagents = new ProviderSubagentStore(new AgentTimeline()); for (let index = 0; index < 101; index += 1) { diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index 02e19bcacb..43393c3606 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -80,16 +80,20 @@ function stickyField(next: T | undefined, previous: T | null | undefined): T export class ProviderSubagentStore { private readonly descriptors = new Map(); + /** Children of rewound turns. Their history stays stored; late provider events are dropped. */ + private readonly retired = new Set(); constructor(private readonly timeline: AgentTimeline) {} + /** Resolves null when the event belongs to a retired child; nothing is recorded. */ async apply( parentAgentId: string, provider: AgentProvider, event: ProviderSubagentInputEvent, - ): Promise { + ): Promise { const key = storeKey(parentAgentId, event.id); const scope = childScope(parentAgentId, event.id); + if (this.retired.has(key)) return null; if (event.type === "remove") { this.descriptors.delete(key); await this.timeline.delete(scope); @@ -154,13 +158,52 @@ export class ProviderSubagentStore { return this.timeline.fetch(childScope(parentAgentId, subagentId), options); } + /** + * Retires the children spawned by retired tool calls of the parent, and their descendants. They + * leave the listing; their history stays stored. + */ + retireSpawnedBy( + parentAgentId: string, + toolCallIds: ReadonlySet, + ): Extract[] { + const children = this.list(parentAgentId); + const retiredIds = new Set( + children + .filter((subagent) => subagent.toolCallId !== null && toolCallIds.has(subagent.toolCallId)) + .map((subagent) => subagent.id), + ); + let grew = retiredIds.size > 0; + while (grew) { + grew = false; + for (const subagent of children) { + if (retiredIds.has(subagent.id) || subagent.parentSubagentId === null) continue; + if (!retiredIds.has(subagent.parentSubagentId)) continue; + retiredIds.add(subagent.id); + grew = true; + } + } + return [...retiredIds].map((subagentId) => { + const key = storeKey(parentAgentId, subagentId); + this.descriptors.delete(key); + this.retired.add(key); + return { type: "remove", parentAgentId, subagentId }; + }); + } + async deleteParent(parentAgentId: string): Promise { + const prefix = storeKey(parentAgentId, ""); + const retired = [...this.retired] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)); + for (const subagentId of retired) this.retired.delete(storeKey(parentAgentId, subagentId)); const removed = this.list(parentAgentId).map((subagent) => subagent.id); for (const subagentId of removed) { this.descriptors.delete(storeKey(parentAgentId, subagentId)); } await Promise.all( - removed.map((subagentId) => this.timeline.delete(childScope(parentAgentId, subagentId))), + [...removed, ...retired].map((subagentId) => + this.timeline.delete(childScope(parentAgentId, subagentId)), + ), ); return removed.map((subagentId) => ({ type: "remove", parentAgentId, subagentId })); } diff --git a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts index 1b1932e9cd..8a72b34297 100644 --- a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts @@ -4,7 +4,11 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./agent.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; -import type { AgentSession } from "../../agent-sdk-types.js"; +import { + RewindNotAppliedError, + type AgentSession, + type ConversationRewindCut, +} from "../../agent-sdk-types.js"; const SESSION_ID = "session-1"; @@ -204,14 +208,17 @@ describe("Claude rewind across a turn that produced no response", () => { const rewindSdk = new FakeClaudeSdk(); const session = await createSession(conversation, rewindSdk); + let cut: ConversationRewindCut | void; try { await runTurns(session, 3); - await session.revertConversation?.({ messageId: conversation.userMessageIds[2] }); + cut = await session.revertConversation?.({ messageId: conversation.userMessageIds[2] }); } finally { await session.close(); } expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); + // The unanswered turn goes with the target, so the fork keeps only the first prompt. + expect(cut).toEqual({ kind: "after", retainedMessageId: conversation.userMessageIds[0] }); }); test("starts a fresh session when no earlier turn produced a response", async () => { @@ -219,14 +226,16 @@ describe("Claude rewind across a turn that produced no response", () => { const rewindSdk = new FakeClaudeSdk(); const session = await createSession(conversation, rewindSdk); + let cut: ConversationRewindCut | void; try { await runTurns(session, 2); - await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + cut = await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); } finally { await session.close(); } expect(rewindSdk.recordedForks).toEqual([]); + expect(cut).toEqual({ kind: "after", retainedMessageId: null }); }); test("still forks at the previous turn when every turn produced a response", async () => { @@ -237,14 +246,52 @@ describe("Claude rewind across a turn that produced no response", () => { const rewindSdk = new FakeClaudeSdk(); const session = await createSession(conversation, rewindSdk); + let cut: ConversationRewindCut | void; try { await runTurns(session, 2); - await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + cut = await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); } finally { await session.close(); } expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); + expect(cut).toEqual({ kind: "at_target" }); + }); +}); + +describe("Claude rewind outcome", () => { + test("a failed fork leaves the session on its conversation and reports nothing applied", async () => { + const conversation = createConversation([ + { assistantMessageId: "assistant-1" }, + { assistantMessageId: "assistant-2" }, + ]); + const rewindSdk = new FakeClaudeSdk(); + rewindSdk.forkError = new Error("transcript is locked"); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 2); + const rewind = session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + await expect(rewind).rejects.toBeInstanceOf(RewindNotAppliedError); + await expect(rewind).rejects.toThrow("transcript is locked"); + expect((await session.getRuntimeInfo()).sessionId).toBe(SESSION_ID); + } finally { + await session.close(); + } + }); + + test("an unknown target is reported as not applied", async () => { + const conversation = createConversation([{ assistantMessageId: "assistant-1" }]); + const session = await createSession(conversation, new FakeClaudeSdk()); + + try { + await runTurns(session, 1); + await expect(session.revertConversation?.({ messageId: "unknown" })).rejects.toBeInstanceOf( + RewindNotAppliedError, + ); + } finally { + await session.close(); + } }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index c675b1ed62..94496f8e06 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -126,6 +126,7 @@ import { type AgentTimelineItem, type AgentUsage, type AgentRuntimeInfo, + type ConversationRewindCut, type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, @@ -136,6 +137,7 @@ import { type ProviderRefreshContext, type ResolveAgentDefaultModeInput, type ToolCallDetail, + RewindNotAppliedError, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -364,9 +366,10 @@ interface ClaudeRewindTurnAnchor { assistantMessageId: string | null; } +/** Where a conversation rewind forks, and which prompts the fork keeps. */ type ClaudeConversationRewindTarget = | { kind: "fresh-session" } - | { kind: "fork"; messageId: string }; + | { kind: "fork"; messageId: string; cut: ConversationRewindCut }; const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -3092,22 +3095,43 @@ class ClaudeAgentSession implements AgentSession { return Array.from(commandMap.values()).sort((a, b) => a.name.localeCompare(b.name)); } - async revertConversation(input: { messageId: string }): Promise { - await this.ensureHistoryReady(); - const target = this.resolveConversationRewindTarget(input.messageId); + async revertConversation(input: { messageId: string }): Promise { + let target: ClaudeConversationRewindTarget; + try { + await this.ensureHistoryReady(); + target = this.resolveConversationRewindTarget(input.messageId); + } catch (error) { + throw RewindNotAppliedError.from(error); + } if (target.kind === "fresh-session") { this.startFreshConversationSession(); - return; + return { kind: "after", retainedMessageId: null }; } + // A fork leaves the original session as it was; the rewind happened once this session + // continues the fork instead. const currentSessionId = this.claudeSessionId; - await revertClaudeConversation({ - sdk: this.rewindSdk, - sessionId: currentSessionId, - transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null, - messageId: target.messageId, - resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), - setSessionId: (sessionId) => this.rebindConversationSession(sessionId), - }); + let failure: unknown = null; + try { + await revertClaudeConversation({ + sdk: this.rewindSdk, + sessionId: currentSessionId, + transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null, + messageId: target.messageId, + resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), + setSessionId: (sessionId) => this.rebindConversationSession(sessionId), + }); + } catch (error) { + failure = error; + } + if (this.claudeSessionId === currentSessionId) { + throw RewindNotAppliedError.from( + failure ?? new Error("Claude did not switch to the rewound session"), + ); + } + if (failure) { + this.logger.warn({ err: failure }, "Claude rewound the conversation but failed afterwards"); + } + return target.cut; } async revertFiles(input: { messageId: string }): Promise { @@ -3119,9 +3143,13 @@ class ClaudeAgentSession implements AgentSession { }); } - async revertBoth(input: { messageId: string }): Promise { - await this.revertFiles(input); - await this.revertConversation(input); + async revertBoth(input: { messageId: string }): Promise { + try { + await this.revertFiles(input); + } catch (error) { + throw RewindNotAppliedError.from(error); + } + return this.revertConversation(input); } private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null { @@ -3444,9 +3472,16 @@ class ClaudeAgentSession implements AgentSession { // the immediately preceding anchor let one such turn fail every later rewind in the // conversation. for (let previous = index - 1; previous >= 0; previous -= 1) { - const assistantMessageId = this.rewindTurnAnchors[previous]?.assistantMessageId; - if (assistantMessageId) { - return { kind: "fork", messageId: assistantMessageId }; + const anchor = this.rewindTurnAnchors[previous]; + if (anchor?.assistantMessageId) { + return { + kind: "fork", + messageId: anchor.assistantMessageId, + cut: + previous === index - 1 + ? { kind: "at_target" } + : { kind: "after", retainedMessageId: anchor.userMessageId }, + }; } } return { kind: "fresh-session" }; diff --git a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts index d607b5c063..c41c5a3974 100644 --- a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts +++ b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts @@ -5,6 +5,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk { readonly recordedFileRewinds: Array<{ userMessageId: string }> = []; private nextSessionId = "forked-session-1"; + forkError: Error | null = null; setNextSessionId(sessionId: string): void { this.nextSessionId = sessionId; @@ -15,6 +16,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk { options: { upToMessageId: string; transcriptPath: string }, ): Promise<{ sessionId: string }> { this.recordedForks.push({ upToMessageId: options.upToMessageId }); + if (this.forkError) throw this.forkError; return { sessionId: this.nextSessionId }; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ea4e6378d9..b575847d06 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -38,6 +38,8 @@ import { type ProviderCatalog, type ProviderRefreshContext, type ResolveAgentDefaultModeInput, + type ConversationRewindCut, + RewindNotAppliedError, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import { runProviderRefreshActivity } from "../provider-refresh-deadline.js"; @@ -4958,35 +4960,59 @@ export class CodexAppServerAgentSession implements AgentSession { }; } - async revertConversation(input: { messageId: string }): Promise { - await this.connect(); - if (!this.client) { - throw new Error("Codex client is not initialized"); + async revertConversation(input: { messageId: string }): Promise { + let threadBefore: string | null; + let client: CodexAppServerClient; + try { + await this.connect(); + if (!this.client) { + throw new Error("Codex client is not initialized"); + } + if (this.currentThreadId) { + await this.ensureThreadLoaded(); + } else { + await this.ensureThread(); + } + await this.ensureHistoryIndexed(); + threadBefore = this.currentThreadId; + client = this.client; + } catch (error) { + throw RewindNotAppliedError.from(error); } - if (this.currentThreadId) { - await this.ensureThreadLoaded(); - } else { - await this.ensureThread(); - } - await this.ensureHistoryIndexed(); - await revertCodexConversation({ - client: this.client, - threadId: this.currentThreadId, - messageId: input.messageId, - cwd: this.config.cwd ?? null, - model: this.config.model ?? null, - serviceTier: this.serviceTier, - config: this.buildCodexInnerConfig(), - userMessageTurns: this.codexUserMessageTurns(), - threadRollbackAvailable: this.threadRollbackAvailable, - setThreadId: async (threadId) => { - this.currentThreadId = threadId; - this.cachedRuntimeInfo = null; - this.stagedHistory = await this.readThreadHistory(this.client); - this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline); - }, - }); + // The rewind forks; the thread it started from stays as it was. It happened once this session + // continues the fork. + let failure: unknown = null; + try { + await revertCodexConversation({ + client, + threadId: threadBefore, + messageId: input.messageId, + cwd: this.config.cwd ?? null, + model: this.config.model ?? null, + serviceTier: this.serviceTier, + config: this.buildCodexInnerConfig(), + userMessageTurns: this.codexUserMessageTurns(), + threadRollbackAvailable: this.threadRollbackAvailable, + setThreadId: async (threadId) => { + this.currentThreadId = threadId; + this.cachedRuntimeInfo = null; + this.stagedHistory = await this.readThreadHistory(client); + this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline); + }, + }); + } catch (error) { + failure = error; + } + if (this.currentThreadId === threadBefore) { + throw RewindNotAppliedError.from( + failure ?? new Error("Codex did not switch to the rewound thread"), + ); + } + if (failure) { + this.logger.warn({ err: failure }, "Codex rewound the conversation but failed afterwards"); + } + return { kind: "at_target" }; } async interrupt(): Promise { diff --git a/packages/server/src/server/agent/providers/codex-async-questions.test.ts b/packages/server/src/server/agent/providers/codex-async-questions.test.ts index 8915688bde..a794720614 100644 --- a/packages/server/src/server/agent/providers/codex-async-questions.test.ts +++ b/packages/server/src/server/agent/providers/codex-async-questions.test.ts @@ -7,7 +7,7 @@ import { waitForTimelineToolCall, } from "./codex/test-utils/fake-app-server.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { RewindNotAppliedError, type AgentStreamEvent } from "../agent-sdk-types.js"; import { AgentManager } from "../agent-manager.js"; const questionItem = { @@ -170,9 +170,10 @@ test("failed rewind preserves question state", async () => { const { session, events } = await setupRewind(true); try { const before = session.describePersistence(); - await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow( - "Rewind failed", - ); + const rewind = session.revertConversation({ messageId: "rewind-here" }); + await expect(rewind).rejects.toThrow("Rewind failed"); + // The fork was never adopted, so the conversation is known to be unchanged. + await expect(rewind).rejects.toBeInstanceOf(RewindNotAppliedError); expect(session.describePersistence()).toEqual(before); expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ "permission-earlier-pending", @@ -210,7 +211,15 @@ test("manager publishes and saves the provider state after rewind", async () => const { session } = await setupRewind(); const { manager, agent } = await manage(session); try { + // A resumed agent's conversation holds the provider history it can rewind. + await manager.hydrateTimelineFromProvider(agent.id, { force: true }); await manager.rewind(agent.id, "rewind-here", "conversation"); + expect(session.describePersistence().sessionId).toBe("forked-thread"); + expect( + (await manager.fetchTimeline(agent.id, { limit: 0 })).rows.flatMap((row) => + row.item.type === "user_message" ? [row.item.text] : [], + ), + ).toEqual(["earlier"]); const snapshot = manager.getAgent(agent.id)!; expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]); expect(snapshot.persistence).toEqual(session.describePersistence()); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts index fff7965b82..1c2f17810d 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts @@ -62,8 +62,11 @@ describe("MockLoadTestAgentSession rewind", () => { ); await expect(rewind.files({ messageId })).resolves.toBeUndefined(); - await expect(rewind.conversation({ messageId })).resolves.toBeUndefined(); - await expect(rewind.both({ messageId })).resolves.toBeUndefined(); + await expect(rewind.conversation({ messageId })).resolves.toEqual({ kind: "at_target" }); + // The rewind removed the message, so it is no longer a target. + await expect(rewind.both({ messageId })).rejects.toThrow( + `Mock rewind target ${messageId} was not found in session history`, + ); await session.close(); }); }); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 14322c6ccf..42abf5e3d7 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -30,7 +30,9 @@ import type { SteerResult, ToolCallDetail, ToolCallTimelineItem, + ConversationRewindCut, } from "../agent-sdk-types.js"; +import { RewindNotAppliedError } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; @@ -1131,10 +1133,11 @@ export class MockLoadTestAgentSession implements AgentSession { this.listeners.clear(); } - async revertConversation(input: { messageId: string }): Promise { + async revertConversation(input: { messageId: string }): Promise { this.failConfiguredRewind(); this.validateRewindTarget(input.messageId); - this.keepFirstUserMessageHistory(); + this.keepHistoryBefore(input.messageId); + return { kind: "at_target" }; } async revertFiles(input: { messageId: string }): Promise { @@ -1142,10 +1145,11 @@ export class MockLoadTestAgentSession implements AgentSession { this.validateRewindTarget(input.messageId); } - async revertBoth(input: { messageId: string }): Promise { + async revertBoth(input: { messageId: string }): Promise { this.failConfiguredRewind(); this.validateRewindTarget(input.messageId); - this.keepFirstUserMessageHistory(); + this.keepHistoryBefore(input.messageId); + return { kind: "at_target" }; } async setModel(modelId: string | null): Promise { @@ -1173,7 +1177,7 @@ export class MockLoadTestAgentSession implements AgentSession { private failConfiguredRewind(): void { if (this.rewindError) { - throw new Error(this.rewindError); + throw new RewindNotAppliedError(this.rewindError); } } @@ -1185,7 +1189,9 @@ export class MockLoadTestAgentSession implements AgentSession { event.item.messageId === messageId, ); if (!isKnownUserMessage) { - throw new Error(`Mock rewind target ${messageId} was not found in session history`); + throw new RewindNotAppliedError( + `Mock rewind target ${messageId} was not found in session history`, + ); } } @@ -1721,16 +1727,14 @@ export class MockLoadTestAgentSession implements AgentSession { this.history.push(event); } - private keepFirstUserMessageHistory(): void { - const nextHistory: AgentStreamEvent[] = []; - for (const event of this.history) { - if (event.type === "timeline" && event.item.type === "user_message") { - nextHistory.push(event); - break; - } - } - this.history.length = 0; - this.history.push(...nextHistory); + private keepHistoryBefore(messageId: string): void { + const targetIndex = this.history.findIndex( + (event) => + event.type === "timeline" && + event.item.type === "user_message" && + event.item.messageId === messageId, + ); + this.history.length = targetIndex; } private clearTurnTimer(turn: ActiveTurn): void { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c8b44c0bd8..333b966b58 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -39,6 +39,8 @@ import { type SteerActiveTurnOptions, type SteerResult, type ToolCallDetail, + type ConversationRewindCut, + RewindNotAppliedError, } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; @@ -1159,17 +1161,21 @@ export class OmpAgentSession implements AgentSession { } } - async revertConversation(input: { messageId: string }): Promise { + async revertConversation(input: { messageId: string }): Promise { if (this.activeTurnId) { - throw new Error("Cannot rewind the OMP conversation while a turn is active"); + throw new RewindNotAppliedError("Cannot rewind the OMP conversation while a turn is active"); } const target = input.messageId.trim(); if (!target) { - throw new Error("OMP rewind requires a user message id"); + throw new RewindNotAppliedError("OMP rewind requires a user message id"); } + // A failed branch call may still have moved the session, so its error is left unknown. await this.runtimeSession.branch(target); - await this.refreshState(); this.activeToolCalls.clear(); + await this.refreshState().catch((error: unknown) => { + this.logger.warn({ err: error }, "OMP rewound the conversation but failed to refresh state"); + }); + return { kind: "at_target" }; } async canStopRuntime(): Promise { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6fef290d91..e6ee17e17d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -57,6 +57,8 @@ import { type SteerResult, type ToolCallDetail, type ToolCallTimelineItem, + type ConversationRewindCut, + RewindNotAppliedError, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import { @@ -3742,14 +3744,18 @@ class OpenCodeAgentSession implements AgentSession { } } - async revertBoth(input: { messageId: string }): Promise { - await this.reconnectIfServerExited(); + async revertBoth(input: { messageId: string }): Promise { + await this.reconnectIfServerExited().catch((error: unknown) => { + throw RewindNotAppliedError.from(error); + }); + // A rejected revert may still have staged its removal, so its error is left unknown. await revertOpenCodeConversationAndFiles({ client: this.client, sessionId: this.sessionId, cwd: this.config.cwd, messageId: input.messageId, }); + return { kind: "at_target" }; } private abortSession(turnId: string | null, reason: string): Promise { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index f173ccb301..79add6d05f 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -16,9 +16,11 @@ import type { AgentSession, AgentSessionConfig, AgentStreamEvent, + ConversationRewindCut, SteerActiveTurnOptions, SteerResult, } from "../../../agent-sdk-types.js"; +import { RewindNotAppliedError } from "../../../agent-sdk-types.js"; import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js"; @@ -394,15 +396,21 @@ export class OpenCodeV2Session implements AgentSession { interrupt() { return this.turns.interrupt(); } - async revertBoth(input: { messageId: string }) { - await this.interrupt(); - await this.reconnectIfExited(); + async revertBoth(input: { messageId: string }): Promise { + try { + await this.interrupt(); + await this.reconnectIfExited(); + } catch (error) { + throw RewindNotAppliedError.from(error); + } + // A rejected stage or commit may still have changed the session, so its error is left unknown. await this.client.session.revert.stage({ sessionID: this.id, messageID: input.messageId, files: true, }); await this.client.session.revert.commit({ sessionID: this.id }); + return { kind: "at_target" }; } getPendingPermissions() { return [...this.permissions.list()]; diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 9474e98cc8..95323ba310 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -40,6 +40,8 @@ import { type ListImportableSessionsOptions, type ProviderCatalog, type ProviderRefreshContext, + type ConversationRewindCut, + RewindNotAppliedError, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -1573,16 +1575,25 @@ export class PiRpcAgentSession implements AgentSession { } } - async revertConversation(input: { messageId: string }): Promise { - if (this.activeTurnId) { - throw new Error("Cannot rewind the Pi conversation while a turn is active"); - } - await this.refreshState().catch(() => undefined); - await this.requestEntryCapture("rewind"); - const targetEntry = this.treeUserEntriesById.get(input.messageId); - if (!targetEntry) { - throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`); + async revertConversation(input: { messageId: string }): Promise { + let targetEntry: PiCapturedEntry; + try { + if (this.activeTurnId) { + throw new Error("Cannot rewind the Pi conversation while a turn is active"); + } + await this.refreshState().catch(() => undefined); + await this.requestEntryCapture("rewind"); + const entry = this.treeUserEntriesById.get(input.messageId); + if (!entry) { + throw new Error( + `Pi rewind target ${input.messageId} was not found in captured tree entries`, + ); + } + targetEntry = entry; + } catch (error) { + throw RewindNotAppliedError.from(error); } + // A failed navigation may still have moved the session, so its error is left unknown. await revertPiConversation({ messageId: input.messageId, navigator: { @@ -1591,6 +1602,7 @@ export class PiRpcAgentSession implements AgentSession { }); this.currentLeafOverrideId = targetEntry.parentId; this.activeToolCalls.clear(); + return { kind: "at_target" }; } private async runPiTreeExtensionCommand(targetId: string): Promise { diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 1c596ed4cd..f2e0ec79f3 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -1,21 +1,37 @@ -import { describe, expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { AgentManager } from "../agent-manager.js"; -import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; +import { AgentManager, type AgentManagerEvent } from "../agent-manager.js"; +import { + RewindNotAppliedError, + type AgentClient, + type AgentPersistenceHandle, + type AgentSession, + type AgentSessionConfig, + type FetchCatalogOptions, +} from "../agent-sdk-types.js"; +import { AgentStorage } from "../agent-storage.js"; +import { AgentTimeline } from "../timeline/index.js"; +import { DeferredTimelineBackend } from "../timeline/testing.js"; import { FakeRewindSession, REWIND_TEST_CAPABILITIES } from "./test-rewind-session.js"; class FakeRewindClient implements AgentClient { readonly provider = "claude"; readonly capabilities = REWIND_TEST_CAPABILITIES; + readonly resumedSessionIds: string[] = []; + constructor(readonly session: FakeRewindSession) {} async createSession(_config: AgentSessionConfig): Promise { return this.session; } - async resumeSession(): Promise { + async resumeSession(handle: AgentPersistenceHandle): Promise { + this.resumedSessionIds.push(handle.sessionId); return this.session; } @@ -28,100 +44,487 @@ class FakeRewindClient implements AgentClient { } } -class RewindHistoryGate { - private gate: Promise | null = null; - private releaseGate: (() => void) | null = null; +/** Agent storage on a real directory whose next save can be made to fail. */ +class FlakyAgentStorage extends AgentStorage { + failNextSave = false; - hold(): void { - this.gate = new Promise((resolve) => { - this.releaseGate = resolve; - }); - } - - release(): void { - this.releaseGate?.(); - this.releaseGate = null; - this.gate = null; - } - - async wait(): Promise { - await this.gate; + override async applySnapshot(...args: Parameters) { + if (this.failNextSave) { + this.failNextSave = false; + throw new Error("No space left on device"); + } + return super.applySnapshot(...args); } } -async function createRewindHarness(options: { historyGate?: RewindHistoryGate } = {}) { - const session = new FakeRewindSession(options.historyGate?.wait.bind(options.historyGate)); +const storageDirs: string[] = []; +afterEach(async () => { + await Promise.all(storageDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createFlakyStorage(): Promise { + const dir = await mkdtemp(join(tmpdir(), "paseo-rewind-storage-")); + storageDirs.push(dir); + return new FlakyAgentStorage(dir, createTestLogger()); +} + +async function createRewindHarness( + session = new FakeRewindSession(), + options: { registry?: AgentStorage } = {}, +) { + const backend = new DeferredTimelineBackend(); + const client = new FakeRewindClient(session); const manager = new AgentManager({ - clients: { claude: new FakeRewindClient(session) }, + clients: { claude: client }, logger: createTestLogger(), idFactory: () => "00000000-0000-4000-8000-000000000901", + timeline: new AgentTimeline(backend), + registry: options.registry, }); - const agent = await manager.createAgent( - { - provider: "claude", - cwd: process.cwd(), - }, - undefined, - { workspaceId: undefined }, - ); - return { manager, session, agentId: agent.id }; + const agent = await manager.createAgent({ provider: "claude", cwd: process.cwd() }, undefined, { + workspaceId: undefined, + }); + return { manager, session, client, backend, agentId: agent.id }; } -describe("AgentManager rewind", () => { - test("rewinds the conversation and rehydrates the timeline", async () => { - const { manager, session, agentId } = await createRewindHarness(); - - await manager.rewind(agentId, "message-1", "conversation"); - - expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "message-1" }]); - expect(session.historyReadCount).toBe(1); - expect( - (await manager.fetchTimeline(agentId, { limit: 0 })).rows.map((row) => row.item), - ).toEqual([{ type: "user_message", text: "before", messageId: "message-1" }]); +/** A conversation the provider answers, with no history before Paseo's first prompt. */ +async function createConversationHarness(options: { registry?: AgentStorage } = {}) { + const session = new FakeRewindSession(); + session.holdTurns = false; + session.history = []; + const harness = await createRewindHarness(session, options); + const events: AgentManagerEvent[] = []; + harness.manager.subscribe((event) => events.push(event), { + agentId: harness.agentId, + replayState: false, }); + async function prompt(text: string): Promise { + for await (const _event of harness.manager.streamAgent(harness.agentId, text, { + clientMessageId: text, + })) { + // Drain the turn. + } + await harness.manager.flush(); + } + async function activeTexts(): Promise { + const page = await harness.manager.fetchTimeline(harness.agentId, { limit: 0 }); + return page.rows.map((row) => + row.item.type === "user_message" || row.item.type === "assistant_message" + ? row.item.text + : row.item.type, + ); + } + function retirements() { + return events.filter((event) => event.type === "timeline_retirement"); + } + return { ...harness, events, prompt, activeTexts, retirements }; +} - test("replaces the canonical epoch without replaying reconstructed parent rows", async () => { - const { manager, session, agentId } = await createRewindHarness(); - const history = Array.from({ length: 250 }, (_, index) => ({ - type: "assistant_message" as const, - text: `rewound ${index}`, - })); - session.history = history; - const epochBefore = (await manager.fetchTimeline(agentId, { limit: 0 })).epoch; - const events: string[] = []; - const unsubscribe = manager.subscribe((event) => events.push(event.type), { - replayState: false, - }); +describe("AgentManager conversation rewind", () => { + test("A, B, rewind B, C keeps A, stores B outside the conversation, and shows C once", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session, backend } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + const before = await manager.fetchTimeline(agentId, { limit: 0 }); - await manager.rewind(agentId, "message-1", "conversation"); - unsubscribe(); + await manager.rewind(agentId, "B", "conversation"); + await harness.prompt("C"); - const replacement = await manager.fetchTimeline(agentId, { limit: 0 }); - expect(replacement.epoch).not.toBe(epochBefore); - expect(replacement.rows).toEqual([ - expect.objectContaining({ - seq: 250, - seqStart: 1, - seqEnd: 250, - sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }], - item: { - type: "assistant_message", - text: history.map((item) => item.text).join(""), - }, - }), + expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "provider-2" }]); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); + expect(backend.retiredRows({ agentId }).map((row) => row.item)).toEqual([ + expect.objectContaining({ type: "user_message", text: "B" }), + expect.objectContaining({ type: "assistant_message", text: "reply to B" }), ]); - expect(events.filter((type) => type === "agent_stream")).toEqual([]); - }); - - test("rewinds files without rehydrating the conversation timeline", async () => { - const { manager, session, agentId } = await createRewindHarness(); - - await manager.rewind(agentId, "message-1", "files"); - - expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]); + const after = await manager.fetchTimeline(agentId, { limit: 0 }); + expect(after.epoch).toBe(before.epoch); + expect(after.retirementCount).toBe(1); + expect(harness.retirements()).toEqual([ + { + type: "timeline_retirement", + agentId, + epoch: before.epoch, + seq: before.window.nextSeq, + retireFromSeq: before.rows[2]!.seqStart, + retirementCount: 1, + }, + ]); + // The provider history is not read back; the retirement is the only change. expect(session.historyReadCount).toBe(0); }); + test("a reader at the end of B catches up through the retirement to C", async () => { + const harness = await createConversationHarness(); + const { manager, agentId } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + const loaded = await manager.fetchTimeline(agentId, { limit: 0 }); + + await manager.rewind(agentId, "B", "conversation"); + await harness.prompt("C"); + + const page = await manager.fetchTimeline(agentId, { + direction: "after", + cursor: { epoch: loaded.epoch, seq: loaded.endSeq! }, + limit: 50, + }); + expect(page.reset).toBe(false); + expect(page.retirements).toEqual([ + { seq: loaded.window.nextSeq, retireFromSeq: loaded.rows[2]!.seqStart }, + ]); + expect(page.rows.map((row) => row.item)).toEqual([ + expect.objectContaining({ type: "user_message", text: "C" }), + expect.objectContaining({ type: "assistant_message", text: "reply to C" }), + ]); + }); + + test("repeating the rewind of B after C fails without calling the provider or touching C", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + await manager.rewind(agentId, "B", "conversation"); + await harness.prompt("C"); + + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow( + "The message to rewind to is no longer in the conversation", + ); + + expect(session.recordedRewinds).toHaveLength(1); + expect(harness.retirements()).toHaveLength(1); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); + }); + + test("repeating a rewind does not cancel the turn that is running", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + await manager.rewind(agentId, "B", "conversation"); + session.holdTurns = true; + const run = manager.streamAgent(agentId, "C", { clientMessageId: "C" }); + await run.next(); + + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow( + "The message to rewind to is no longer in the conversation", + ); + + expect(session.aborted).toBe(false); + expect(manager.getAgent(agentId)?.lifecycle).toBe("running"); + }); + + test("the provider's retained boundary decides what retires", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + await harness.prompt("C"); + // The provider can only fork after A, so B goes with C. + session.nextRewind = { + kind: "applied", + cut: { kind: "after", retainedMessageId: "provider-1" }, + }; + + await manager.rewind(agentId, "C", "conversation"); + + expect(await harness.activeTexts()).toEqual(["A", "reply to A"]); + }); + + test("a failure the provider reports as not applied keeps the conversation", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "not_applied", message: "No checkpoint for this message" }; + + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow( + "No checkpoint for this message", + ); + await harness.prompt("C"); + + expect(harness.retirements()).toEqual([]); + expect(await harness.activeTexts()).toEqual([ + "A", + "reply to A", + "B", + "reply to B", + "C", + "reply to C", + ]); + }); + + test("an unknown outcome refuses prompts and further rewinds without calling the provider again", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unknown", applied: true }; + + const unknown = "Paseo could not confirm whether the conversation was rewound."; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(unknown); + await expect(harness.prompt("C")).rejects.toThrow(unknown); + await expect(manager.rewind(agentId, "A", "conversation")).rejects.toThrow(unknown); + + expect(session.recordedRewinds).toHaveLength(1); + expect(harness.retirements()).toEqual([]); + // The refused prompt stays in the conversation with the reason, like any refused prompt. + expect(await harness.activeTexts()).toEqual([ + "A", + "reply to A", + "B", + "reply to B", + "C", + `[System Error] ${unknown} Reload the agent to check, or fork the chat from an earlier message.`, + ]); + }); + + test("reloading settles an unknown outcome the provider history proves applied", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unknown", applied: true }; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(); + + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await harness.prompt("C"); + + expect(session.recordedRewinds).toHaveLength(1); + expect(harness.retirements()).toHaveLength(1); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); + }); + + test("reloading settles an unknown outcome the provider history proves unchanged", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unknown", applied: false }; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(); + + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await harness.prompt("C"); + + expect(harness.retirements()).toEqual([]); + expect(await harness.activeTexts()).toEqual([ + "A", + "reply to A", + "B", + "reply to B", + "C", + "reply to C", + ]); + }); + + test("reloading keeps the rewind unresolved when the provider history proves nothing", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unknown", applied: true }; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(); + // A provider that replays no history leaves nothing to compare. + session.history = []; + + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + + await expect(harness.prompt("C")).rejects.toThrow( + "Paseo could not confirm whether the conversation was rewound.", + ); + // The reload did not wipe the conversation. + expect((await harness.activeTexts()).slice(0, 5)).toEqual([ + "A", + "reply to A", + "B", + "reply to B", + "C", + ]); + expect(harness.retirements()).toEqual([]); + }); + + test("a prompt refused while the rewind is unresolved does not change what the reload judges", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unknown", applied: true }; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(); + // Refused before reaching the provider, so the provider history has no C. + await expect(harness.prompt("C")).rejects.toThrow(); + + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await harness.prompt("D"); + + expect(harness.retirements()).toHaveLength(1); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "D", "reply to D"]); + }); + + test("a prompt refused while the rewind is unresolved stays when the reload proves no change", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unknown", applied: false }; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(); + await expect(harness.prompt("C")).rejects.toThrow(); + + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await harness.prompt("D"); + + expect(harness.retirements()).toEqual([]); + expect(await harness.activeTexts()).toEqual([ + "A", + "reply to A", + "B", + "reply to B", + "C", + expect.stringContaining("[System Error] Paseo could not confirm"), + "D", + "reply to D", + ]); + }); + + test("a rewind whose save failed is committed by a reload that resumes the rewound session", async () => { + const registry = await createFlakyStorage(); + const harness = await createConversationHarness({ registry }); + const { manager, agentId, session, client } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + const before = await manager.fetchTimeline(agentId, { limit: 0 }); + // The provider continues the rewound conversation in a new session, then the save fails. + session.sessionIdAfterRewind = "rewound-session"; + registry.failNextSave = true; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow( + "No space left on device", + ); + expect(harness.retirements()).toEqual([]); + + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await harness.prompt("C"); + + expect(client.resumedSessionIds).toEqual(["rewound-session"]); + expect((await registry.get(agentId))?.persistence?.sessionId).toBe("rewound-session"); + // The reload committed the recorded cut on the same timeline; it did not replay history. + expect(session.recordedRewinds).toHaveLength(1); + expect(session.historyReadCount).toBe(0); + expect(harness.retirements()).toEqual([ + expect.objectContaining({ epoch: before.epoch, retireFromSeq: before.rows[2]!.seqStart }), + ]); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); + }); + + test("a rewind whose save failed is committed by the next prompt without a reload", async () => { + const registry = await createFlakyStorage(); + const harness = await createConversationHarness({ registry }); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.sessionIdAfterRewind = "rewound-session"; + registry.failNextSave = true; + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toThrow(); + + await harness.prompt("C"); + + expect(session.recordedRewinds).toHaveLength(1); + expect((await registry.get(agentId))?.persistence?.sessionId).toBe("rewound-session"); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); + }); + + test("a rewind the provider finishes without reporting its cut settles from the resumed history", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session, client } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + const before = await manager.fetchTimeline(agentId, { limit: 0 }); + session.nextRewind = { kind: "unreported", applied: true }; + + await manager.rewind(agentId, "B", "conversation"); + await harness.prompt("C"); + + expect(session.recordedRewinds).toHaveLength(1); + expect(client.resumedSessionIds).toEqual(["fake-rewind-session"]); + expect(harness.retirements()).toEqual([ + expect.objectContaining({ epoch: before.epoch, retireFromSeq: before.rows[2]!.seqStart }), + ]); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "C", "reply to C"]); + }); + + test("a rewind the provider reports finished but did not apply keeps the conversation", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + session.nextRewind = { kind: "unreported", applied: false }; + + await expect(manager.rewind(agentId, "B", "conversation")).rejects.toBeInstanceOf( + RewindNotAppliedError, + ); + await harness.prompt("C"); + + expect(harness.retirements()).toEqual([]); + expect(await harness.activeTexts()).toEqual([ + "A", + "reply to A", + "B", + "reply to B", + "C", + "reply to C", + ]); + }); + + test("a rewind the provider finishes without reporting stays unresolved when its history proves nothing", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + session.nextRewind = { kind: "unreported", applied: true }; + + // Rewinding the only prompt leaves an empty history, which may also mean no replay. + const unknown = "Paseo could not confirm whether the conversation was rewound."; + await expect(manager.rewind(agentId, "A", "conversation")).rejects.toThrow(unknown); + await expect(harness.prompt("B")).rejects.toThrow(unknown); + + expect(harness.retirements()).toEqual([]); + }); + + test("retires the provider subagent a rewound turn spawned, and keeps the others", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + session.subagentPrompts.add("A").add("B"); + await harness.prompt("A"); + await harness.prompt("B"); + await manager.flush(); + expect(manager.listProviderSubagents(agentId).map((child) => child.id)).toEqual([ + "child-of-A", + "child-of-B", + ]); + + await manager.rewind(agentId, "B", "conversation"); + + expect(manager.listProviderSubagents(agentId).map((child) => child.id)).toEqual(["child-of-A"]); + expect( + harness.events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "remove" ? [event.event] : [], + ), + ).toEqual([{ type: "remove", parentAgentId: agentId, subagentId: "child-of-B" }]); + }); + + test("rewinding files leaves the conversation as it is", async () => { + const harness = await createConversationHarness(); + const { manager, agentId, session } = harness; + await harness.prompt("A"); + await harness.prompt("B"); + + await manager.rewind(agentId, "B", "files"); + + expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "provider-2" }]); + expect(harness.retirements()).toEqual([]); + expect(await harness.activeTexts()).toEqual(["A", "reply to A", "B", "reply to B"]); + expect(session.historyReadCount).toBe(0); + }); +}); + +describe("AgentManager rewind run ownership", () => { test("aborts an in-flight turn before rewinding", async () => { const { manager, session, agentId } = await createRewindHarness(); const run = manager.streamAgent(agentId, "keep working"); @@ -140,40 +543,42 @@ describe("AgentManager rewind", () => { } } - const session = new RejectingInterruptSession(); - const manager = new AgentManager({ - clients: { claude: new FakeRewindClient(session) }, - logger: createTestLogger(), - idFactory: () => "00000000-0000-4000-8000-000000000902", - }); - const agent = await manager.createAgent({ provider: "claude", cwd: process.cwd() }, undefined, { - workspaceId: undefined, - }); - const run = manager.streamAgent(agent.id, "keep working"); + const { manager, session, agentId } = await createRewindHarness( + new RejectingInterruptSession(), + ); + const run = manager.streamAgent(agentId, "keep working"); await run.next(); - await expect(manager.rewind(agent.id, "message-1", "files")).rejects.toThrow( - `Cannot rewind agent ${agent.id} because its active run cancellation was not acknowledged`, + await expect(manager.rewind(agentId, "message-1", "files")).rejects.toThrow( + `Cannot rewind agent ${agentId} because its active run cancellation was not acknowledged`, ); expect(session.recordedRewinds).toEqual([]); - expect(manager.getAgent(agent.id)).toMatchObject({ + expect(manager.getAgent(agentId)).toMatchObject({ lifecycle: "running", activeForegroundTurnId: "turn-1", }); }); - test("blocks new prompts until the rehydrate epoch broadcasts", async () => { - const historyGate = new RewindHistoryGate(); - historyGate.hold(); - const { manager, agentId } = await createRewindHarness({ historyGate }); + test("blocks new prompts until the rewind settles", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + class GatedRewindSession extends FakeRewindSession { + override async revertFiles(input: { messageId: string }): Promise { + await gate; + await super.revertFiles(input); + } + } + const { manager, agentId } = await createRewindHarness(new GatedRewindSession()); - const rewind = manager.rewind(agentId, "message-1", "both"); + const rewind = manager.rewind(agentId, "message-1", "files"); expect(() => manager.streamAgent(agentId, "too early")).toThrow( - "Agent 00000000-0000-4000-8000-000000000901 already has an active run", + `Agent ${agentId} already has an active run`, ); - historyGate.release(); + release(); await rewind; }); }); diff --git a/packages/server/src/server/agent/rewind/rewind.ts b/packages/server/src/server/agent/rewind/rewind.ts index 4c9899e87d..b7a1378812 100644 --- a/packages/server/src/server/agent/rewind/rewind.ts +++ b/packages/server/src/server/agent/rewind/rewind.ts @@ -1,8 +1,13 @@ -import type { AgentSession } from "../agent-sdk-types.js"; +import { + RewindNotAppliedError, + type AgentSession, + type ConversationRewindResult, +} from "../agent-sdk-types.js"; export type RewindMode = "conversation" | "files" | "both"; -export class RewindCapabilityError extends Error { +/** Thrown before the provider is called, so nothing changed. */ +export class RewindCapabilityError extends RewindNotAppliedError { constructor(mode: RewindMode) { super(`Provider does not support rewinding ${mode}`); this.name = "RewindCapabilityError"; @@ -12,14 +17,13 @@ export class RewindCapabilityError extends Error { export async function invokeRewindCapability( session: AgentSession, input: { messageId: string; mode: RewindMode }, -): Promise { +): Promise { switch (input.mode) { case "conversation": if (!session.capabilities.supportsRewindConversation || !session.revertConversation) { throw new RewindCapabilityError(input.mode); } - await session.revertConversation({ messageId: input.messageId }); - return; + return session.revertConversation({ messageId: input.messageId }); case "files": if (!session.capabilities.supportsRewindFiles || !session.revertFiles) { throw new RewindCapabilityError(input.mode); @@ -30,7 +34,20 @@ export async function invokeRewindCapability( if (!session.capabilities.supportsRewindBoth || !session.revertBoth) { throw new RewindCapabilityError(input.mode); } - await session.revertBoth({ messageId: input.messageId }); - return; + return session.revertBoth({ messageId: input.messageId }); + } +} + +/** + * The provider may or may not have rewound the conversation. The rewind stays open and prompts are + * refused until reloading the agent lets its history settle the outcome. + */ +export class RewindOutcomeUnknownError extends Error { + constructor(options?: { cause?: unknown }) { + super( + "Paseo could not confirm whether the conversation was rewound. Reload the agent to check, or fork the chat from an earlier message.", + options, + ); + this.name = "RewindOutcomeUnknownError"; } } diff --git a/packages/server/src/server/agent/rewind/test-rewind-session.ts b/packages/server/src/server/agent/rewind/test-rewind-session.ts index e717ca051d..fb06fbecf8 100644 --- a/packages/server/src/server/agent/rewind/test-rewind-session.ts +++ b/packages/server/src/server/agent/rewind/test-rewind-session.ts @@ -1,13 +1,18 @@ -import type { - AgentCapabilityFlags, - AgentPermissionRequest, - AgentPermissionResponse, - AgentPersistenceHandle, - AgentProvider, - AgentRunResult, - AgentSession, - AgentStreamEvent, - AgentTimelineItem, +import { + RewindNotAppliedError, + type AgentCapabilityFlags, + type AgentPermissionRequest, + type AgentPermissionResponse, + type AgentPersistenceHandle, + type AgentPromptInput, + type AgentProvider, + type AgentRunOptions, + type AgentRunResult, + type AgentSession, + type AgentStreamEvent, + type AgentTimelineItem, + type ConversationRewindCut, + type ConversationRewindResult, } from "../agent-sdk-types.js"; export const REWIND_TEST_CAPABILITIES: AgentCapabilityFlags = { @@ -27,14 +32,33 @@ export interface RecordedRewind { messageId: string; } +/** What the next conversation rewind does to the provider history, and what it reports. */ +export type FakeRewindOutcome = + | { kind: "applied"; cut?: ConversationRewindCut } + | { kind: "not_applied"; message: string } + /** The call fails; `applied` says whether the history changed anyway. */ + | { kind: "unknown"; applied: boolean; cut?: ConversationRewindCut } + /** The call finishes without saying what it removed, as a plugin provider's does. */ + | { kind: "unreported"; applied: boolean }; + export class FakeRewindSession implements AgentSession { readonly provider: AgentProvider = "claude"; readonly id = "fake-rewind-session"; + /** The session a resume continues. A rewind that changes it is a provider forking the thread. */ + persistedSessionId = "fake-rewind-session"; + /** Set to make an applied rewind continue in a new provider session, as Claude's fork does. */ + sessionIdAfterRewind: string | null = null; readonly capabilities = REWIND_TEST_CAPABILITIES; readonly recordedRewinds: RecordedRewind[] = []; aborted = false; historyReadCount = 0; history: AgentTimelineItem[] = [{ type: "user_message", text: "before", messageId: "message-1" }]; + /** Leaves started turns running until interrupted, instead of answering them. */ + holdTurns = true; + nextRewind: FakeRewindOutcome = { kind: "applied" }; + /** Prompts whose turn spawns a provider subagent through a tool call. */ + readonly subagentPrompts = new Set(); + private promptCount = 0; private subscribers = new Set<(event: AgentStreamEvent) => void>(); private activeTurnId: string | null = null; @@ -45,13 +69,60 @@ export class FakeRewindSession implements AgentSession { return { sessionId: this.id, finalText: "", timeline: [] }; } - async startTurn(): Promise<{ turnId: string }> { + async startTurn( + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise<{ turnId: string }> { this.aborted = false; - this.activeTurnId = "turn-1"; + if (this.holdTurns) { + this.activeTurnId = "turn-1"; + queueMicrotask(() => { + this.emit({ type: "turn_started", provider: this.provider, turnId: "turn-1" }); + }); + return { turnId: "turn-1" }; + } + this.promptCount += 1; + const turnId = `turn-${this.promptCount}`; + const text = typeof prompt === "string" ? prompt : "prompt"; + const items: AgentTimelineItem[] = [ + { + type: "user_message", + text, + messageId: `provider-${this.promptCount}`, + ...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), + }, + { type: "assistant_message", text: `reply to ${text}` }, + ]; + const spawnsSubagent = this.subagentPrompts.has(text); + const callId = `call-${this.promptCount}`; + if (spawnsSubagent) { + items.push({ + type: "tool_call", + callId, + name: "Task", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }); + } + this.activeTurnId = turnId; queueMicrotask(() => { - this.emit({ type: "turn_started", provider: this.provider, turnId: "turn-1" }); + this.emit({ type: "turn_started", provider: this.provider, turnId }); + for (const item of items) { + this.history.push(item); + this.emit({ type: "timeline", provider: this.provider, item, turnId }); + } + if (spawnsSubagent) { + this.emit({ + type: "provider_subagent", + provider: this.provider, + event: { type: "upsert", id: `child-of-${text}`, toolCallId: callId, status: "running" }, + }); + } + this.activeTurnId = null; + this.emit({ type: "turn_completed", provider: this.provider, turnId }); }); - return { turnId: "turn-1" }; + return { turnId }; } subscribe(callback: (event: AgentStreamEvent) => void): () => void { @@ -91,7 +162,7 @@ export class FakeRewindSession implements AgentSession { ): Promise {} describePersistence(): AgentPersistenceHandle { - return { provider: this.provider, sessionId: this.id }; + return { provider: this.provider, sessionId: this.persistedSessionId }; } async interrupt(): Promise { @@ -105,16 +176,47 @@ export class FakeRewindSession implements AgentSession { async close(): Promise {} - async revertConversation(input: { messageId: string }): Promise { + async revertConversation(input: { messageId: string }): Promise { this.recordedRewinds.push({ mode: "conversation", messageId: input.messageId }); + return this.applyRewind(input.messageId); } async revertFiles(input: { messageId: string }): Promise { this.recordedRewinds.push({ mode: "files", messageId: input.messageId }); } - async revertBoth(input: { messageId: string }): Promise { + async revertBoth(input: { messageId: string }): Promise { this.recordedRewinds.push({ mode: "both", messageId: input.messageId }); + return this.applyRewind(input.messageId); + } + + private applyRewind(messageId: string): ConversationRewindResult { + const outcome = this.nextRewind; + this.nextRewind = { kind: "applied" }; + if (outcome.kind === "not_applied") throw new RewindNotAppliedError(outcome.message); + const cut = (outcome.kind !== "unreported" && outcome.cut) || { kind: "at_target" }; + if (outcome.kind === "applied" || outcome.applied) { + this.cutHistory(messageId, cut); + if (this.sessionIdAfterRewind) this.persistedSessionId = this.sessionIdAfterRewind; + } + if (outcome.kind === "unknown") throw new Error("connection lost during rewind"); + return outcome.kind === "unreported" ? { kind: "unreported" } : cut; + } + + private cutHistory(messageId: string, cut: ConversationRewindCut): void { + const promptIndex = (id: string) => + this.history.findIndex((item) => item.type === "user_message" && item.messageId === id); + let keep: number; + if (cut.kind === "at_target") keep = promptIndex(messageId); + else if (cut.retainedMessageId === null) keep = 0; + else { + const retained = promptIndex(cut.retainedMessageId); + const next = this.history.findIndex( + (item, index) => index > retained && item.type === "user_message", + ); + keep = next < 0 ? this.history.length : next; + } + this.history.length = keep; } private emit(event: AgentStreamEvent): void { diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 7333882adc..3ea2df4c63 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -43,6 +43,17 @@ export interface ProjectedTimelinePageSelection { endSeq: number | null; hasOlder: boolean; hasNewer: boolean; + /** Retirements whose seq lies in the page's span, coalesced; see `TimelineProjection.retire`. */ + retirements: TimelineRetirement[]; +} + +/** + * A rewind's record: the rows anchored in `[retireFromSeq, seq)` left the active conversation. + * `seq` is the retirement's own position in the change sequence. + */ +export interface TimelineRetirement { + seq: number; + retireFromSeq: number; } function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] { @@ -312,7 +323,12 @@ export class TimelineProjection { // span must include its first anchor to cover interleaved lifecycle updates. private readonly spans: { start: number; endSeq: number }[] = []; + // Every source seq in order. `rowIndex` -1 marks seqs covered without an active row: the rows a + // rewind retired, and each retirement's own seq. Forward pages advance through them. private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = []; + private readonly retirements: TimelineRetirement[] = []; + private readonly retiredIdentities = new Set(); + private readonly retiredTurnIds = new Set(); constructor(rows: readonly AgentTimelineRow[] = []) { for (const row of rows) this.appendProjected(row); @@ -392,11 +408,126 @@ export class TimelineProjection { return this.rows.map((row) => ({ ...row })); } + /** + * Whether a new source row belongs to retired history: late output of a turn with no active + * rows left, or a late update to a retired item. An update to an active item is never retired. + */ + isRetired(row: Pick): boolean { + if (row.turnId !== undefined && this.retiredTurnIds.has(row.turnId)) return true; + const identity = timelineItemIdentity(row.item); + if (identity === null || this.identities.has(identity)) return false; + return this.retiredIdentities.has(identity); + } + + /** + * Retires the active rows anchored at or after `retireFromSeq`. Rows are in anchor order, so they + * form the display suffix. Their source seqs and the retirement's own seq stay covered. + */ + retire(retirement: TimelineRetirement): ProjectedTimelineRow[] { + const cut = this.firstRowAnchoredAtOrAfter(retirement.retireFromSeq); + const retired = this.rows.splice(cut); + const activeTurnIds = new Set(this.rows.map((row) => row.turnId)); + for (const row of retired) { + const identity = timelineItemIdentity(row.item); + if (identity !== null) this.retiredIdentities.add(identity); + if (row.turnId !== undefined && !activeTurnIds.has(row.turnId)) { + this.retiredTurnIds.add(row.turnId); + } + } + for (const [identity, index] of this.identities) { + if (index >= cut) this.identities.delete(identity); + } + while (this.spans.length > 0 && this.spans[this.spans.length - 1].start >= cut) + this.spans.pop(); + const lastSpan = this.spans.at(-1); + if (lastSpan) { + lastSpan.endSeq = 0; + for (let index = lastSpan.start; index < cut; index++) { + lastSpan.endSeq = Math.max(lastSpan.endSeq, this.rows[index].seqEnd); + } + } + for (const range of this.sourceRanges) { + if (range.rowIndex >= cut) range.rowIndex = -1; + } + this.sourceRanges.push({ startSeq: retirement.seq, endSeq: retirement.seq, rowIndex: -1 }); + this.retirements.push({ ...retirement }); + return retired; + } + + private firstRowAnchoredAtOrAfter(seq: number): number { + let low = 0; + let high = this.rows.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.rows[middle].seqStart < seq) low = middle + 1; + else high = middle; + } + return low; + } + + /** + * Retirements with seq in `[startSeq, endSeq]`, merging neighbours that no active row separates. + * Merging keeps the meaning (drop rows anchored in `[retireFromSeq, seq)`) and bounds the count + * by the active rows in the span, so a page never carries an unbounded list. + */ + private retirementsInSpan(startSeq: number, endSeq: number): TimelineRetirement[] { + let low = 0; + let high = this.retirements.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.retirements[middle].seq < startSeq) low = middle + 1; + else high = middle; + } + const inSpan: TimelineRetirement[] = []; + for (let index = low; index < this.retirements.length; index++) { + const retirement = this.retirements[index]; + if (retirement.seq > endSeq) break; + inSpan.push(retirement); + } + return this.coalesceRetirements(inSpan); + } + + private coalesceRetirements(retirements: readonly TimelineRetirement[]): TimelineRetirement[] { + const coalesced: TimelineRetirement[] = []; + for (const retirement of retirements) { + const previous = coalesced.at(-1); + if (previous && !this.hasActiveAnchorIn(previous.seq, retirement.retireFromSeq)) { + coalesced[coalesced.length - 1] = { + seq: retirement.seq, + retireFromSeq: Math.min(previous.retireFromSeq, retirement.retireFromSeq), + }; + continue; + } + coalesced.push({ ...retirement }); + } + return coalesced; + } + + private hasActiveAnchorIn(startSeq: number, endSeq: number): boolean { + const index = this.firstRowAnchoredAtOrAfter(startSeq); + return index < this.rows.length && this.rows[index].seqStart < endSeq; + } + get size(): number { return this.rows.length; } selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + if (this.rows.length === 0 && this.sourceRanges.length > 0) { + // Every row is retired. Forward pages still advance; a tail covers the whole window so a + // client can apply the retirements to what it holds. + if (input.direction === "after") return this.selectAfter(input); + if (input.direction === "tail") { + return { + entries: [], + startSeq: input.bounds.minSeq, + endSeq: input.bounds.maxSeq, + hasOlder: false, + hasNewer: false, + retirements: this.retirementsInSpan(input.bounds.minSeq, input.bounds.maxSeq), + }; + } + } if (this.rows.length === 0) { const cursor = input.cursorSeq ?? @@ -413,6 +544,7 @@ export class TimelineProjection { input.direction === "after" ? cursor < input.bounds.maxSeq : input.direction === "before" && cursor <= input.bounds.maxSeq, + retirements: [], }; } switch (input.direction) { @@ -436,6 +568,7 @@ export class TimelineProjection { endSeq: input.bounds.maxSeq, hasOlder: start > 0, hasNewer: false, + retirements: this.retirementsInSpan(this.rows[start].seqStart, input.bounds.maxSeq), }; } @@ -463,29 +596,59 @@ export class TimelineProjection { else high = middle; } const indexes = new Set(); + const covered: TimelineSeqRange[] = []; const limit = Math.max(0, Math.floor(input.limit ?? 0)); + let retirementIndex = this.firstRetirementAtOrAfter(startSeq); + const retirements: TimelineRetirement[] = []; for (let i = low; i < this.sourceRanges.length; i++) { const range = this.sourceRanges[i]; if (range.startSeq > input.bounds.maxSeq) break; - indexes.add(range.rowIndex); - if (limit > 0 && indexes.size === limit) break; + if (range.rowIndex >= 0) { + indexes.add(range.rowIndex); + } else { + covered.push({ startSeq: Math.max(range.startSeq, startSeq), endSeq: range.endSeq }); + const retirement = this.retirements[retirementIndex]; + if (retirement && retirement.seq === range.startSeq) { + retirements.push(retirement); + retirementIndex++; + } + } + // Each retirement counts toward the limit, so a page stays bounded however many it crosses. + if (limit > 0 && indexes.size + retirements.length >= limit) break; } const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]); const selected = selectProjectedEntriesAfter({ entries, + covered, startSeq, maxSeq: input.bounds.maxSeq, limit, }); + const endSeq = selected.endSeq; return { entries: selected.entries, - startSeq: selected.endSeq === null ? null : startSeq, - endSeq: selected.endSeq, + startSeq: endSeq === null ? null : startSeq, + endSeq, hasOlder: startSeq > input.bounds.minSeq, - hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq, + hasNewer: endSeq !== null && endSeq < input.bounds.maxSeq, + retirements: + endSeq === null + ? [] + : this.coalesceRetirements(retirements.filter((retirement) => retirement.seq <= endSeq)), }; } + private firstRetirementAtOrAfter(seq: number): number { + let low = 0; + let high = this.retirements.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.retirements[middle].seq < seq) low = middle + 1; + else high = middle; + } + return low; + } + private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1); if (endSeq >= input.bounds.minSeq) { @@ -508,20 +671,29 @@ export class TimelineProjection { let selectedEnd = endSeq; if (input.wholeTurns) selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1; + const pageStart = this.rows[start]?.seqStart ?? null; return { entries: this.rows.slice(start, low), - startSeq: this.rows[start]?.seqStart ?? null, + startSeq: pageStart, endSeq: selectedEnd, hasOlder: start > 0, hasNewer: selectedEnd < input.bounds.maxSeq, + retirements: pageStart === null ? [] : this.retirementsInSpan(pageStart, selectedEnd), }; } - return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true }; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: true, + retirements: [], + }; } getLastItem(seq: number): AgentTimelineItem | null { const last = this.sourceRanges.at(-1); - return last?.endSeq === seq ? this.rows[last.rowIndex].item : null; + return last?.endSeq === seq && last.rowIndex >= 0 ? this.rows[last.rowIndex].item : null; } /** @@ -677,6 +849,8 @@ interface ProjectedEntryCandidate { function selectProjectedEntriesAfter(input: { entries: readonly TimelineProjectionEntry[]; + /** Seqs covered without an active row; they advance the cursor but add no entry. */ + covered?: readonly TimelineSeqRange[]; startSeq: number; maxSeq: number; limit: number; @@ -693,11 +867,13 @@ function selectProjectedEntriesAfter(input: { const selectedEntries = selected .sort((left, right) => left.index - right.index) .map((candidate) => candidate.entry); - if (selectedEntries.length === 0) return { entries: [], endSeq: null }; + const covered = input.covered ?? []; + if (selectedEntries.length === 0 && covered.length === 0) return { entries: [], endSeq: null }; - const selectedRanges = selectedEntries - .flatMap((entry) => entry.sourceSeqRanges) - .sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq); + const selectedRanges = [ + ...selectedEntries.flatMap((entry) => entry.sourceSeqRanges), + ...covered, + ].sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq); // Wide projected entries can include future, discontiguous source ranges. The // page cursor advances only through source rows covered without a gap. let endSeq = input.startSeq - 1; diff --git a/packages/server/src/server/agent/timeline/agent-timeline.test.ts b/packages/server/src/server/agent/timeline/agent-timeline.test.ts index 4feabaf584..6ce6a121a2 100644 --- a/packages/server/src/server/agent/timeline/agent-timeline.test.ts +++ b/packages/server/src/server/agent/timeline/agent-timeline.test.ts @@ -2,12 +2,22 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import type { AgentTimelineItem } from "../agent-sdk-types.js"; +import type { AgentTimelineItem, ConversationRewindCut } from "../agent-sdk-types.js"; import { limitAgentTimelineItemContent } from "../agent-timeline-content.js"; import type { AgentTimelineFetchResult, AgentTimelineRow } from "../agent-timeline-store-types.js"; import { DeferredTimelineBackend } from "./testing.js"; import { selectReferenceProjectedTimelinePage } from "../test-utils/timeline-page-reference.js"; -import { AgentTimeline, type TimelineCommit, type TimelineScope } from "./index.js"; +import { + AgentTimeline, + TimelineRewindBoundaryError, + TimelineRewindPendingError, + TimelineRewindTargetError, + type TimelineCommit, + type TimelineEntry, + type TimelineRetirementCommit, + type TimelineRewindCompletion, + type TimelineScope, +} from "./index.js"; import { UnknownTimelineError } from "./internal/backend.js"; const root = (agentId: string): TimelineScope => ({ agentId }); @@ -189,7 +199,12 @@ describe.each([ ]); await timeline.delete(child); expect(await timeline.describe(child)).toBeNull(); - expect(await timeline.describe(root("a"))).toEqual({ epoch: "parent", size: 1 }); + expect(await timeline.describe(root("a"))).toEqual({ + epoch: "parent", + size: 1, + retirementCount: 0, + rewind: null, + }); await expect(timeline.readRows(child)).rejects.toBeInstanceOf(UnknownTimelineError); }); @@ -657,3 +672,365 @@ it("continues a scroll across appends and lifecycle collapse without losing sour expect(last.endSeq).toBe(7); expect(last.hasNewer).toBe(false); }); + +describe("conversation rewind", () => { + const scope = root("agent"); + const createBackend = () => new DeferredTimelineBackend(); + + function prompt(messageId: string, turnId: string): TimelineEntry { + return { item: { type: "user_message", text: messageId, messageId }, turnId }; + } + + function reply(text: string, turnId: string): TimelineEntry { + return { item: { type: "assistant_message", text, messageId: `reply-${text}` }, turnId }; + } + + function tool(callId: string, turnId: string, status: "running" | "completed"): TimelineEntry { + return { + item: { + type: "tool_call", + callId, + name: "shell", + status, + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + turnId, + }; + } + + async function conversation(): Promise<{ + backend: DeferredTimelineBackend; + timeline: AgentTimeline; + }> { + const backend = createBackend(); + const timeline = new AgentTimeline(backend); + await timeline.create(scope, { epoch: "e" }); + await timeline.append(scope, [ + prompt("a", "t1"), + reply("A", "t1"), + prompt("b", "t2"), + tool("call-b", "t2", "running"), + reply("B", "t2"), + ]); + return { backend, timeline }; + } + + async function rewind( + timeline: AgentTimeline, + messageId: string, + cut: ConversationRewindCut = { kind: "at_target" }, + ): Promise { + const intent = await timeline.beginRewind(scope, messageId); + await timeline.recordRewindCut(scope, intent.id, cut); + return timeline.completeRewind(scope, intent.id); + } + + function texts(rows: readonly { item: AgentTimelineItem }[]): string[] { + return rows.map((row) => ("text" in row.item ? row.item.text : row.item.type)); + } + + it("retires the rewound turn from active history and keeps it stored", async () => { + const { backend, timeline } = await conversation(); + const published: TimelineRetirementCommit[] = []; + const intent = await timeline.beginRewind(scope, "b"); + await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" }); + const completion = await timeline.completeRewind(scope, intent.id, { + publish: (commit) => published.push(commit), + }); + await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]); + + expect(completion).toMatchObject({ + kind: "retired", + retirement: { seq: 6, retireFromSeq: 3 }, + epoch: "e", + retirementCount: 1, + }); + expect(published.map((commit) => texts(commit.retiredRows))).toEqual([["b", "tool_call", "B"]]); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "c", "C"]); + expect(texts(backend.retiredRows(scope))).toEqual(["b", "tool_call", "B"]); + expect(await timeline.describe(scope)).toEqual({ + epoch: "e", + size: 4, + retirementCount: 1, + rewind: null, + }); + const tail = await timeline.fetch(scope); + expect(texts(tail.rows)).toEqual(["a", "A", "c", "C"]); + expect(tail).toMatchObject({ + epoch: "e", + startSeq: 1, + endSeq: 8, + retirements: [{ seq: 6, retireFromSeq: 3 }], + }); + }); + + it("advances a caught-up cursor through the retirement to the next turn", async () => { + const { timeline } = await conversation(); + await rewind(timeline, "b"); + await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]); + + const catchUp = await timeline.fetch(scope, { + direction: "after", + cursor: { epoch: "e", seq: 5 }, + }); + expect(texts(catchUp.rows)).toEqual(["c", "C"]); + expect(catchUp).toMatchObject({ + reset: false, + startSeq: 6, + endSeq: 8, + hasNewer: false, + retirements: [{ seq: 6, retireFromSeq: 3 }], + }); + + const retirementOnly = await timeline.fetch(scope, { + direction: "after", + cursor: { epoch: "e", seq: 5 }, + limit: 1, + }); + expect(retirementOnly).toMatchObject({ + rows: [], + startSeq: 6, + endSeq: 6, + hasNewer: true, + retirements: [{ seq: 6, retireFromSeq: 3 }], + }); + const next = await timeline.fetch(scope, { + direction: "after", + cursor: { epoch: "e", seq: 6 }, + }); + expect(texts(next.rows)).toEqual(["c", "C"]); + expect(next.retirements).toEqual([]); + }); + + it("pages a tail whose last turn was rewound with nothing after it", async () => { + const { timeline } = await conversation(); + await rewind(timeline, "b"); + + const tail = await timeline.fetch(scope); + expect(texts(tail.rows)).toEqual(["a", "A"]); + expect(tail).toMatchObject({ endSeq: 6, retirements: [{ seq: 6, retireFromSeq: 3 }] }); + const caughtUp = await timeline.fetch(scope, { + direction: "after", + cursor: { epoch: "e", seq: 2 }, + }); + expect(caughtUp).toMatchObject({ + rows: [], + endSeq: 6, + hasNewer: false, + retirements: [{ seq: 6, retireFromSeq: 3 }], + }); + }); + + it("cannot retire a later turn by completing the same rewind again", async () => { + const { timeline } = await conversation(); + const intent = await timeline.beginRewind(scope, "b"); + await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" }); + await timeline.completeRewind(scope, intent.id); + await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]); + + expect(await timeline.completeRewind(scope, intent.id)).toEqual({ kind: "absent" }); + expect(await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" })).toBeNull(); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "c", "C"]); + expect((await timeline.describe(scope))?.retirementCount).toBe(1); + }); + + it("drops late output of a retired turn and still accepts updates to active items", async () => { + const timeline = new AgentTimeline(createBackend()); + await timeline.create(scope, { epoch: "e" }); + await timeline.append(scope, [ + prompt("a", "t1"), + tool("call-a", "t1", "running"), + prompt("b", "t2"), + tool("call-b", "t2", "running"), + ]); + await rewind(timeline, "b"); + + const late = await timeline.ingest(scope, tool("call-b", "t2", "completed")); + const lateText = await timeline.ingest(scope, reply("B again", "t2")); + const reusedCallId = await timeline.ingest(scope, tool("call-a", "t2", "completed")); + const active = await timeline.ingest(scope, tool("call-a", "t1", "completed")); + + expect([late, lateText, reusedCallId].map((result) => result.kind)).toEqual([ + "dropped", + "dropped", + "dropped", + ]); + expect(active.kind).toBe("recorded"); + const rows = await timeline.readRows(scope); + expect(rows.map((row) => row.item)).toEqual([ + { type: "user_message", text: "a", messageId: "a" }, + expect.objectContaining({ type: "tool_call", callId: "call-a", status: "completed" }), + ]); + }); + + it("allows one open rewind at a time and leaves history alone when abandoned", async () => { + const { timeline } = await conversation(); + const intent = await timeline.beginRewind(scope, "b"); + + await expect(timeline.beginRewind(scope, "a")).rejects.toBeInstanceOf( + TimelineRewindPendingError, + ); + await expect(timeline.completeRewind(scope, intent.id)).rejects.toThrow( + "Cannot complete a rewind before its provider outcome", + ); + await timeline.abandonRewind(scope, intent.id); + expect((await timeline.describe(scope))?.rewind).toBeNull(); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]); + await expect(timeline.beginRewind(scope, "missing")).rejects.toBeInstanceOf( + TimelineRewindTargetError, + ); + expect((await timeline.beginRewind(scope, "a")).targetSeq).toBe(1); + }); + + it("retires from the boundary the provider actually kept", async () => { + const { timeline } = await conversation(); + await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]); + + const completion = await rewind(timeline, "c", { kind: "after", retainedMessageId: "a" }); + + expect(completion).toMatchObject({ kind: "retired", retirement: { retireFromSeq: 3 } }); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A"]); + }); + + it("matches the retained boundary by the provider's message id", async () => { + const backend = createBackend(); + const timeline = new AgentTimeline(backend); + await timeline.create(scope, { epoch: "e" }); + await timeline.ingest(scope, { + item: { type: "user_message", text: "a", messageId: "a", clientMessageId: "client-a" }, + }); + await timeline.attachProviderMessageId(scope, "client-a", "provider-a"); + await timeline.append(scope, [reply("A", "t1"), prompt("b", "t2"), prompt("c", "t3")]); + + await rewind(timeline, "c", { kind: "after", retainedMessageId: "provider-a" }); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A"]); + + await rewind(timeline, "a", { kind: "after", retainedMessageId: null }); + expect(await timeline.readRows(scope)).toEqual([]); + }); + + it("keeps the rewind open when the provider's boundary is after the target", async () => { + const { timeline } = await conversation(); + const intent = await timeline.beginRewind(scope, "a"); + await timeline.recordRewindCut(scope, intent.id, { kind: "after", retainedMessageId: "a" }); + + await expect(timeline.completeRewind(scope, intent.id)).rejects.toBeInstanceOf( + TimelineRewindBoundaryError, + ); + expect((await timeline.describe(scope))?.rewind).toMatchObject({ id: intent.id }); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]); + }); + + it("keeps active history and the open rewind when the retirement fails to commit", async () => { + const { backend, timeline } = await conversation(); + const intent = await timeline.beginRewind(scope, "b"); + await timeline.recordRewindCut(scope, intent.id, { kind: "at_target" }); + backend.failNextRetire = true; + + await expect(timeline.completeRewind(scope, intent.id)).rejects.toThrow("disk full"); + expect(texts(await timeline.readRows(scope))).toEqual(["a", "A", "b", "tool_call", "B"]); + expect(await timeline.describe(scope)).toMatchObject({ retirementCount: 0 }); + + expect(await timeline.completeRewind(scope, intent.id)).toMatchObject({ + kind: "retired", + retirement: { seq: 6, retireFromSeq: 3 }, + }); + }); + + it("judges an unsettled rewind from the provider's own history", async () => { + const { timeline } = await conversation(); + await timeline.append(scope, [prompt("c", "t3"), reply("C", "t3")]); + const intent = await timeline.beginRewind(scope, "b"); + + expect(await timeline.judgeRewind(scope, intent.id, ["a", "b", "c"])).toEqual({ + kind: "unchanged", + }); + expect(await timeline.judgeRewind(scope, intent.id, ["a"])).toEqual({ + kind: "applied", + cut: { kind: "after", retainedMessageId: "a" }, + }); + expect(await timeline.judgeRewind(scope, intent.id, ["a", "b"])).toMatchObject({ + kind: "unknown", + reason: "The provider history ends after the rewind target", + }); + expect(await timeline.judgeRewind(scope, intent.id, ["a", "x"])).toMatchObject({ + kind: "unknown", + reason: "The provider history differs from Paseo's", + }); + expect(await timeline.judgeRewind(scope, intent.id, [])).toMatchObject({ + kind: "unknown", + reason: "The provider history has no prompts", + }); + expect(await timeline.judgeRewind(scope, "other", ["a"])).toEqual({ kind: "absent" }); + expect(texts(await timeline.readRows(scope))).toEqual([ + "a", + "A", + "b", + "tool_call", + "B", + "c", + "C", + ]); + expect((await timeline.describe(scope))?.rewind).toMatchObject({ id: intent.id }); + }); + + it("judges against provider ids and skips prompts the provider never acknowledged", async () => { + const timeline = new AgentTimeline(createBackend()); + await timeline.create(scope, { epoch: "e" }); + for (const id of ["a", "b", "c"]) { + await timeline.ingest(scope, { + item: { type: "user_message", text: id, messageId: id, clientMessageId: id }, + }); + if (id !== "b") await timeline.attachProviderMessageId(scope, id, `provider-${id}`); + } + const intent = await timeline.beginRewind(scope, "c"); + + expect(await timeline.judgeRewind(scope, intent.id, ["provider-a", "provider-c"])).toEqual({ + kind: "unchanged", + }); + const verdict = await timeline.judgeRewind(scope, intent.id, ["provider-a"]); + expect(verdict).toEqual({ + kind: "applied", + cut: { kind: "after", retainedMessageId: "provider-a" }, + }); + if (verdict.kind !== "applied") throw new Error("expected an applied verdict"); + await timeline.recordRewindCut(scope, intent.id, verdict.cut); + await timeline.completeRewind(scope, intent.id); + expect(texts(await timeline.readRows(scope))).toEqual(["a"]); + }); + + it("bounds pages across a thousand rewinds", async () => { + const backend = createBackend(); + const timeline = new AgentTimeline(backend); + await timeline.create(scope, { epoch: "e" }); + await timeline.append(scope, [prompt("kept", "t0"), reply("kept", "t0")]); + for (let index = 0; index < 1000; index++) { + await timeline.append(scope, [prompt(`p${index}`, `t${index + 1}`)]); + await rewind(timeline, `p${index}`); + } + + const tail = await timeline.fetch(scope, { limit: 50 }); + expect(texts(tail.rows)).toEqual(["kept", "kept"]); + expect(tail.retirements).toEqual([{ seq: 2002, retireFromSeq: 3 }]); + + let cursor = 2; + let pages = 0; + for (;;) { + const page = await timeline.fetch(scope, { + direction: "after", + cursor: { epoch: "e", seq: cursor }, + limit: 50, + }); + pages += 1; + expect(page.rows).toEqual([]); + expect(page.retirements.length).toBe(1); + if (page.endSeq === null) throw new Error("after-page lost its cursor"); + cursor = page.endSeq; + if (!page.hasNewer) break; + } + expect(cursor).toBe(2002); + // Each page covers 50 prompts and their 50 retirements. + expect(pages).toBe(20); + }); +}); diff --git a/packages/server/src/server/agent/timeline/index.ts b/packages/server/src/server/agent/timeline/index.ts index 958098de67..294797bd34 100644 --- a/packages/server/src/server/agent/timeline/index.ts +++ b/packages/server/src/server/agent/timeline/index.ts @@ -1,10 +1,18 @@ export { AgentTimeline, + TimelineRewindBoundaryError, + TimelineRewindPendingError, + TimelineRewindTargetError, type TimelineAppendResult, type TimelineCommit, type TimelineEntry, type TimelineHead, type TimelineIngestResult, + type TimelineRetirement, + type TimelineRetirementCommit, + type TimelineRewindCompletion, + type TimelineRewindIntent, + type TimelineRewindVerdict, type TimelineScope, type TimelineSeed, } from "./internal/agent-timeline.js"; diff --git a/packages/server/src/server/agent/timeline/internal/agent-timeline.ts b/packages/server/src/server/agent/timeline/internal/agent-timeline.ts index 18936c7039..43f54b26cc 100644 --- a/packages/server/src/server/agent/timeline/internal/agent-timeline.ts +++ b/packages/server/src/server/agent/timeline/internal/agent-timeline.ts @@ -1,37 +1,101 @@ -import type { AgentTimelineItem } from "../../agent-sdk-types.js"; +import { randomUUID } from "node:crypto"; +import type { AgentTimelineItem, ConversationRewindCut } from "../../agent-sdk-types.js"; import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; import type { AgentTimelineFetchOptions, AgentTimelineFetchResult, AgentTimelineRow, } from "../../agent-timeline-store-types.js"; -import type { ProjectedTimelineRow } from "../../timeline-projection.js"; +import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js"; import type { AgentTimelineBackend, TimelineEntry, TimelineHead, + TimelinePrompt, + TimelineRewindIntent, TimelineScope, TimelineSeed, } from "./backend.js"; import { UnknownTimelineError } from "./backend.js"; import { MemoryTimelineBackend } from "./memory-backend.js"; -export type { TimelineEntry, TimelineHead, TimelineScope, TimelineSeed } from "./backend.js"; +export type { + TimelineEntry, + TimelineHead, + TimelineRewindIntent, + TimelineScope, + TimelineSeed, +} from "./backend.js"; +export type { TimelineRetirement } from "../../timeline-projection.js"; export interface TimelineCommit { row: AgentTimelineRow; epoch: string; + /** Rewinds applied to this history so far. */ + retirementCount: number; +} + +export interface TimelineRetirementCommit { + retirement: TimelineRetirement; + /** The rows that left the active conversation. They stay stored. */ + retiredRows: ProjectedTimelineRow[]; + epoch: string; + retirementCount: number; +} + +export type TimelineRewindCompletion = + | ({ kind: "retired" } & TimelineRetirementCommit) + /** Committed, but `publish` threw; see `TimelineIngestResult`. */ + | ({ kind: "unpublished"; error: unknown } & TimelineRetirementCommit) + /** No rewind with this id is open: it completed or was abandoned. Nothing changed. */ + | { kind: "absent" }; + +/** + * What the provider's own history says about an open rewind, judged against the active prompts. + * `unknown` carries the reason the evidence does not decide it. + */ +export type TimelineRewindVerdict = + | { kind: "unchanged" } + | { kind: "applied"; cut: ConversationRewindCut } + | { kind: "unknown"; reason: string } + /** No rewind with this id is open. */ + | { kind: "absent" }; + +/** Another rewind is open on the history; it must settle first. */ +export class TimelineRewindPendingError extends Error { + constructor(readonly intent: TimelineRewindIntent) { + super("Another rewind of this conversation has not settled"); + this.name = "TimelineRewindPendingError"; + } +} + +/** The rewind target is not an active prompt: it never existed or a rewind already retired it. */ +export class TimelineRewindTargetError extends Error { + constructor(readonly messageId: string) { + super("The message to rewind to is no longer in the conversation"); + this.name = "TimelineRewindTargetError"; + } +} + +/** The provider's cut does not resolve to a prompt at or before the target. */ +export class TimelineRewindBoundaryError extends Error { + constructor(readonly intent: TimelineRewindIntent) { + super("The provider's rewind boundary does not match this conversation"); + this.name = "TimelineRewindBoundaryError"; + } } export type TimelineIngestResult = - | { kind: "recorded"; row: AgentTimelineRow; epoch: string } + | ({ kind: "recorded" } & TimelineCommit) /** * The row is committed but `publish` threw. The commit stands and nothing retries the * publication; ingesting the same submitted prompt again returns `duplicate`. */ - | { kind: "unpublished"; row: AgentTimelineRow; epoch: string; error: unknown } + | ({ kind: "unpublished"; error: unknown } & TimelineCommit) /** A submitted prompt with this `clientMessageId` is already recorded; nothing was published. */ - | { kind: "duplicate"; row: AgentTimelineRow; epoch: string }; + | ({ kind: "duplicate" } & TimelineCommit) + /** Late output for a retired item or turn. Nothing was recorded or published. */ + | { kind: "dropped" }; export type TimelineAppendResult = | { kind: "recorded"; commits: TimelineCommit[] } @@ -98,8 +162,17 @@ export class AgentTimeline { const clientMessageId = submittedPromptId(entry.item); if (clientMessageId) { const existing = await this.backend.findSubmittedPrompt(scope, clientMessageId); - if (existing) return { kind: "duplicate", row: existing, epoch: await this.epoch(scope) }; + if (existing) { + const head = await this.requireHead(scope); + return { + kind: "duplicate", + row: existing, + epoch: head.epoch, + retirementCount: head.retirementCount, + }; + } } + if (await this.backend.isRetired(scope, entry)) return { kind: "dropped" }; const [commit] = await this.commit(scope, [entry]); const failure = publishCommitted(options?.publish, commit); return failure @@ -164,6 +237,98 @@ export class AgentTimeline { return this.run(scope, () => this.backend.lastAssistantMessage(scope)); } + /** + * Opens a rewind to before the active prompt `messageId`. While it is open, `describe` reports it, + * and a second rewind is refused until this one completes or is abandoned. + */ + beginRewind(scope: TimelineScope, messageId: string): Promise { + return this.run(scope, async () => { + const head = await this.requireHead(scope); + if (head.rewind) throw new TimelineRewindPendingError(head.rewind); + const target = (await this.backend.prompts(scope)).find( + (prompt) => prompt.messageId === messageId, + ); + if (!target) throw new TimelineRewindTargetError(messageId); + const intent: TimelineRewindIntent = { + id: randomUUID(), + messageId, + targetSeq: target.seqStart, + }; + await this.backend.setRewind(scope, intent); + return intent; + }); + } + + /** Records that the provider applied the rewind, before the retirement is committed. */ + recordRewindCut( + scope: TimelineScope, + intentId: string, + cut: ConversationRewindCut, + ): Promise { + return this.run(scope, async () => { + const head = await this.requireHead(scope); + if (head.rewind?.id !== intentId) return null; + const intent = { ...head.rewind, cut }; + await this.backend.setRewind(scope, intent); + return intent; + }); + } + + /** Closes a rewind the provider is known not to have applied. History is unchanged. */ + abandonRewind(scope: TimelineScope, intentId: string): Promise { + return this.run(scope, async () => { + const head = await this.requireHead(scope); + if (head.rewind?.id === intentId) await this.backend.setRewind(scope, null); + }); + } + + /** + * Judges an open rewind from the prompt ids the provider's current history holds, in order. + * Changes nothing; the caller settles the rewind from the verdict. + */ + judgeRewind( + scope: TimelineScope, + intentId: string, + providerPromptIds: readonly string[], + ): Promise { + return this.run(scope, async () => { + const intent = (await this.requireHead(scope)).rewind; + if (intent?.id !== intentId) return { kind: "absent" }; + return judgeRewindEvidence(intent, await this.backend.prompts(scope), providerPromptIds); + }); + } + + /** + * Retires the turns the recorded cut removed, and closes the rewind, in one commit. Completing a + * rewind that is no longer open changes nothing, so a repeat cannot retire later turns. + */ + completeRewind( + scope: TimelineScope, + intentId: string, + options?: { publish?: (commit: TimelineRetirementCommit) => void }, + ): Promise { + return this.run(scope, async () => { + const head = await this.requireHead(scope); + const intent = head.rewind; + if (intent?.id !== intentId) return { kind: "absent" }; + if (!intent.cut) throw new Error("Cannot complete a rewind before its provider outcome"); + const retireFromSeq = resolveRetireFromSeq(intent, await this.backend.prompts(scope)); + if (retireFromSeq === null) throw new TimelineRewindBoundaryError(intent); + const result = await this.backend.retire(scope, { retireFromSeq }); + this.track(scope, result.head); + const commit: TimelineRetirementCommit = { + retirement: result.retirement, + retiredRows: result.retiredRows, + epoch: result.head.epoch, + retirementCount: result.head.retirementCount, + }; + const failure = publishCommitted(options?.publish, commit); + return failure + ? { kind: "unpublished", error: failure.error, ...commit } + : { kind: "retired", ...commit }; + }); + } + /** * Resolves once every operation called before it has finished, including its publication. * Callers use it so an event published outside the timeline cannot overtake queued writes. @@ -187,13 +352,17 @@ export class AgentTimeline { })); const { rows, head } = await this.backend.append(scope, bounded); this.track(scope, head); - return rows.map((row) => ({ row, epoch: head.epoch })); + return rows.map((row) => ({ + row, + epoch: head.epoch, + retirementCount: head.retirementCount, + })); } - private async epoch(scope: TimelineScope): Promise { + private async requireHead(scope: TimelineScope): Promise { const head = await this.backend.head(scope); if (!head) throw new UnknownTimelineError(scope); - return head.epoch; + return head; } private track(scope: TimelineScope, head: TimelineHead): TimelineHead { @@ -233,6 +402,62 @@ function publishCommitted( } } +/** + * The anchor where retirement starts: the target for `at_target`; for `after`, the first active + * prompt after the retained one. Null when the cut does not resolve at or before the target. + * Prompts without a provider id (never acknowledged) are retired with the turns around them. + */ +function resolveRetireFromSeq( + intent: TimelineRewindIntent, + prompts: readonly TimelinePrompt[], +): number | null { + const cut = intent.cut; + if (!cut) return null; + let retireFromSeq: number | undefined; + if (cut.kind === "at_target") { + retireFromSeq = intent.targetSeq; + } else if (cut.retainedMessageId === null) { + retireFromSeq = prompts[0]?.seqStart; + } else { + const retainedId = cut.retainedMessageId; + const retained = prompts.findIndex( + (prompt) => (prompt.providerMessageId ?? prompt.messageId) === retainedId, + ); + retireFromSeq = retained < 0 ? undefined : prompts[retained + 1]?.seqStart; + } + return retireFromSeq !== undefined && retireFromSeq <= intent.targetSeq ? retireFromSeq : null; +} + +/** + * The provider's prompts must be, in order, a prefix of the active prompts it has acknowledged. + * All of them: the rewind did not happen. A prefix ending before the target: it happened, keeping + * that prefix. Anything else, including an empty history that may mean "no replay", is unknown. + */ +function judgeRewindEvidence( + intent: TimelineRewindIntent, + prompts: readonly TimelinePrompt[], + providerPromptIds: readonly string[], +): TimelineRewindVerdict { + const known = prompts.filter((prompt) => !prompt.submitted || prompt.providerMessageId); + const keys = known.map((prompt) => prompt.providerMessageId ?? prompt.messageId); + if (providerPromptIds.length === 0) { + return { kind: "unknown", reason: "The provider history has no prompts" }; + } + const isPrefix = providerPromptIds.every((id, index) => keys[index] === id); + if (!isPrefix) { + return { kind: "unknown", reason: "The provider history differs from Paseo's" }; + } + if (providerPromptIds.length === keys.length) return { kind: "unchanged" }; + const target = known.findIndex((prompt) => prompt.messageId === intent.messageId); + if (target < 0 || providerPromptIds.length > target) { + return { kind: "unknown", reason: "The provider history ends after the rewind target" }; + } + return { + kind: "applied", + cut: { kind: "after", retainedMessageId: providerPromptIds[providerPromptIds.length - 1] }, + }; +} + function sizeKey(scope: TimelineScope): string { return scope.subagentId === undefined ? scope.agentId : `${scope.agentId}\0${scope.subagentId}`; } diff --git a/packages/server/src/server/agent/timeline/internal/backend.ts b/packages/server/src/server/agent/timeline/internal/backend.ts index 5e350297ea..8b60701a9d 100644 --- a/packages/server/src/server/agent/timeline/internal/backend.ts +++ b/packages/server/src/server/agent/timeline/internal/backend.ts @@ -4,7 +4,8 @@ import type { AgentTimelineFetchResult, AgentTimelineRow, } from "../../agent-timeline-store-types.js"; -import type { ProjectedTimelineRow } from "../../timeline-projection.js"; +import type { ConversationRewindCut } from "../../agent-sdk-types.js"; +import type { ProjectedTimelineRow, TimelineRetirement } from "../../timeline-projection.js"; /** Root history uses `agentId` alone; provider-child history adds the child's id. */ export interface TimelineScope { @@ -32,6 +33,34 @@ export interface TimelineHead { epoch: string; /** Projected (displayed) row count. */ size: number; + /** Rewinds applied since the history was created. */ + retirementCount: number; + /** The rewind in progress, or one whose provider outcome is not settled yet. */ + rewind: TimelineRewindIntent | null; +} + +/** + * A conversation rewind between its start and its retirement commit. It refuses new prompts and + * other rewinds. Without `cut` the provider outcome is unknown; with it the provider applied the + * rewind and only the local commit is outstanding. + */ +export interface TimelineRewindIntent { + id: string; + /** The rewound prompt's `messageId`. */ + messageId: string; + /** The rewound prompt's anchor. */ + targetSeq: number; + cut?: ConversationRewindCut; +} + +/** An active user prompt: what a rewind addresses. */ +export interface TimelinePrompt { + seqStart: number; + messageId: string | undefined; + /** The provider's id for the prompt, when it differs from `messageId`. */ + providerMessageId: string | undefined; + /** Submitted by Paseo; the provider knows it only once `providerMessageId` is attached. */ + submitted: boolean; } export interface TimelineBackendAppendResult { @@ -39,6 +68,13 @@ export interface TimelineBackendAppendResult { head: TimelineHead; } +export interface TimelineBackendRetireResult { + retirement: TimelineRetirement; + /** The rows that left the active conversation. They stay stored. */ + retiredRows: ProjectedTimelineRow[]; + head: TimelineHead; +} + type MaybePromise = T | Promise; /** @@ -56,6 +92,19 @@ export interface AgentTimelineBackend { scope: TimelineScope, entries: readonly TimelineEntry[], ): MaybePromise; + /** Whether a new entry would land on retired history (see `TimelineProjection.isRetired`). */ + isRetired(scope: TimelineScope, entry: TimelineEntry): MaybePromise; + /** Active user prompts in display order. */ + prompts(scope: TimelineScope): MaybePromise; + setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): MaybePromise; + /** + * Allocates the next sequence number for a retirement of the active rows anchored at or after + * `retireFromSeq`, and clears the rewind intent, in one commit. + */ + retire( + scope: TimelineScope, + input: { retireFromSeq: number }, + ): MaybePromise; attachProviderMessageId( scope: TimelineScope, clientMessageId: string, diff --git a/packages/server/src/server/agent/timeline/internal/memory-backend.ts b/packages/server/src/server/agent/timeline/internal/memory-backend.ts index 9efc43d842..7c04a5c86d 100644 --- a/packages/server/src/server/agent/timeline/internal/memory-backend.ts +++ b/packages/server/src/server/agent/timeline/internal/memory-backend.ts @@ -10,8 +10,11 @@ import { TimelineProjection, type ProjectedTimelineRow } from "../../timeline-pr import type { AgentTimelineBackend, TimelineBackendAppendResult, + TimelineBackendRetireResult, TimelineEntry, TimelineHead, + TimelinePrompt, + TimelineRewindIntent, TimelineScope, TimelineSeed, } from "./backend.js"; @@ -22,6 +25,10 @@ interface MemoryTimelineState { projection: TimelineProjection; minSeq: number; nextSeq: number; + /** Rows rewinds took out of the active conversation, in retirement order. */ + retired: ProjectedTimelineRow[]; + retirementCount: number; + rewind: TimelineRewindIntent | null; } const DEFAULT_TIMELINE_FETCH_LIMIT = 200; @@ -61,6 +68,9 @@ export class MemoryTimelineBackend implements AgentTimelineBackend { projection, minSeq: projection.getRows()[0]?.seqStart ?? 0, nextSeq, + retired: [], + retirementCount: 0, + rewind: null, }; this.states.set(scopeKey(scope), state); return this.describe(state); @@ -87,6 +97,47 @@ export class MemoryTimelineBackend implements AgentTimelineBackend { return { rows: rows.map(cloneRow), head: this.describe(state) }; } + isRetired(scope: TimelineScope, entry: TimelineEntry): boolean { + return this.requireState(scope).projection.isRetired(entry); + } + + prompts(scope: TimelineScope): TimelinePrompt[] { + const prompts: TimelinePrompt[] = []; + for (const row of this.requireState(scope).projection.getRows()) { + if (row.item.type !== "user_message") continue; + prompts.push({ + seqStart: row.seqStart, + messageId: row.item.messageId, + providerMessageId: row.providerMessageId, + submitted: row.item.clientMessageId !== undefined, + }); + } + return prompts; + } + + setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): TimelineHead { + const state = this.requireState(scope); + state.rewind = rewind ? { ...rewind } : null; + return this.describe(state); + } + + retire(scope: TimelineScope, input: { retireFromSeq: number }): TimelineBackendRetireResult { + const state = this.requireState(scope); + const retirement = { seq: state.nextSeq, retireFromSeq: input.retireFromSeq }; + const retiredRows = state.projection.retire(retirement); + state.retired.push(...retiredRows); + state.nextSeq = retirement.seq + 1; + if (state.minSeq === 0) state.minSeq = retirement.seq; + state.retirementCount += 1; + state.rewind = null; + return { retirement, retiredRows: retiredRows.map(cloneRow), head: this.describe(state) }; + } + + /** Retired rows, oldest retirement first. Not part of the backend contract; tests read it. */ + retiredRows(scope: TimelineScope): ProjectedTimelineRow[] { + return this.requireState(scope).retired.map(cloneRow); + } + attachProviderMessageId( scope: TimelineScope, clientMessageId: string, @@ -115,7 +166,10 @@ export class MemoryTimelineBackend implements AgentTimelineBackend { const cursor = options.cursor; const hasRows = state.minSeq !== 0; const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq }; - const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch; + const staleCursor = + cursor !== undefined && + (cursor.epoch !== state.epoch || + (cursor.retirementCount !== undefined && cursor.retirementCount !== state.retirementCount)); const gap = !staleCursor && direction === "after" && @@ -147,6 +201,8 @@ export class MemoryTimelineBackend implements AgentTimelineBackend { item: limitAgentTimelineItemContent(entry.item), }), ), + retirements: page.retirements, + retirementCount: state.retirementCount, }; } @@ -164,7 +220,12 @@ export class MemoryTimelineBackend implements AgentTimelineBackend { } private describe(state: MemoryTimelineState): TimelineHead { - return { epoch: state.epoch, size: state.projection.size }; + return { + epoch: state.epoch, + size: state.projection.size, + retirementCount: state.retirementCount, + rewind: state.rewind ? { ...state.rewind } : null, + }; } private requireState(scope: TimelineScope): MemoryTimelineState { diff --git a/packages/server/src/server/agent/timeline/testing.ts b/packages/server/src/server/agent/timeline/testing.ts index f666bcc10e..1933d9b35d 100644 --- a/packages/server/src/server/agent/timeline/testing.ts +++ b/packages/server/src/server/agent/timeline/testing.ts @@ -9,8 +9,11 @@ import type { ProjectedTimelineRow } from "../timeline-projection.js"; import type { AgentTimelineBackend, TimelineBackendAppendResult, + TimelineBackendRetireResult, TimelineEntry, TimelineHead, + TimelinePrompt, + TimelineRewindIntent, TimelineScope, TimelineSeed, } from "./internal/backend.js"; @@ -27,6 +30,7 @@ export class DeferredTimelineBackend implements AgentTimelineBackend { readonly appends: TimelineEntry[][] = []; readRowsCalls = 0; failNextAppend = false; + failNextRetire = false; private async defer(call: () => T): Promise { await nextTurn(); @@ -56,6 +60,31 @@ export class DeferredTimelineBackend implements AgentTimelineBackend { return result; }); } + isRetired(scope: TimelineScope, entry: TimelineEntry): Promise { + return this.defer(() => this.memory.isRetired(scope, entry)); + } + prompts(scope: TimelineScope): Promise { + return this.defer(() => this.memory.prompts(scope)); + } + setRewind(scope: TimelineScope, rewind: TimelineRewindIntent | null): Promise { + return this.defer(() => this.memory.setRewind(scope, rewind)); + } + retire( + scope: TimelineScope, + input: { retireFromSeq: number }, + ): Promise { + return this.defer(() => { + if (this.failNextRetire) { + this.failNextRetire = false; + throw new Error("disk full"); + } + return this.memory.retire(scope, input); + }); + } + /** Rows rewinds retired; they stay stored. */ + retiredRows(scope: TimelineScope): ProjectedTimelineRow[] { + return this.memory.retiredRows(scope); + } attachProviderMessageId( scope: TimelineScope, clientMessageId: string, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index f7a5b17857..8e20279f13 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -257,6 +257,7 @@ const OUTBOUND_PERMISSION = { "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", "agent.timeline.replacement": ["workspace.read", "hub.execute"], + "agent.timeline.rewind": ["workspace.read", "hub.execute"], "browser.host.register.response": ["workspace.write"], "subscription.release.response": null, "session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"], diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts index f47b30ce85..97929987fb 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -181,6 +181,7 @@ async function connect(input: { clientId: string; selective: boolean; timelineReplacementInvalidation?: boolean; + timelineRewindOperations?: boolean; timelineNotifications?: boolean; pluginTimelineItems?: boolean; workspaceSetupBlocked?: boolean; @@ -202,6 +203,7 @@ async function connect(input: { ? {} : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), [CLIENT_CAPS.timelineReplacementInvalidation]: input.timelineReplacementInvalidation ?? false, + [CLIENT_CAPS.timelineRewindOperations]: input.timelineRewindOperations ?? false, }, reconnect: { enabled: false }, }); @@ -376,7 +378,7 @@ test("plugin timeline items are sent only to clients that advertise support", as } }); -test("rewind routes replacement completion by source capability and subscription", async () => { +test("a rewind reaches each client in the shape it can apply", async () => { await daemon.close(); daemon = await createTestPaseoDaemon({ isDev: true, @@ -386,6 +388,13 @@ test("rewind routes replacement completion by source capability and subscription clientId: "rewind-initiating", selective: true, timelineReplacementInvalidation: true, + timelineRewindOperations: true, + }); + const capable = await connect({ + clientId: "rewind-capable", + selective: true, + timelineReplacementInvalidation: true, + timelineRewindOperations: true, }); const passive = await connect({ clientId: "rewind-passive", @@ -407,48 +416,133 @@ test("rewind routes replacement completion by source capability and subscription await Promise.all([ initiating.setLegacyTimelineMembership([agent.id]), + capable.setLegacyTimelineMembership([agent.id]), passive.setLegacyTimelineMembership([agent.id]), unrelated.setLegacyTimelineMembership([]), ]); - await initiating.client.sendMessage(agent.id, "Rewind this synthetic prompt"); + await initiating.client.sendMessage(agent.id, "Keep this prompt"); await initiating.client.cancelAgent(agent.id); - const timeline = await initiating.client.fetchAgentTimeline(agent.id, { + await initiating.client.sendMessage(agent.id, "Rewind this prompt"); + await initiating.client.cancelAgent(agent.id); + const loaded = await initiating.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0, - projection: "canonical", }); - const target = timeline.entries.find( - (entry) => - entry.item.type === "user_message" && entry.item.text === "Rewind this synthetic prompt", + const target = loaded.entries.find( + (entry) => entry.item.type === "user_message" && entry.item.text === "Rewind this prompt", ); if (!target || target.item.type !== "user_message" || !target.item.messageId) { - throw new Error("Expected rewindable canonical user message"); + throw new Error("Expected rewindable user message"); } + const epoch = loaded.epoch; + const legacyEpoch = `${epoch}.r1`; - for (const connected of [initiating, passive, unrelated, legacy]) connected.clear(); + for (const connected of [initiating, capable, passive, unrelated, legacy]) connected.clear(); await initiating.client.rewindAgent(agent.id, target.item.messageId, "conversation"); - await Promise.all([ - initiating.barrier("rewind-initiator"), - passive.barrier("rewind-passive"), - unrelated.barrier("rewind-unrelated"), - legacy.barrier("rewind-legacy"), - ]); + await Promise.all( + [initiating, capable, passive, unrelated, legacy].map((connected, index) => + connected.barrier(`rewind-${index}`), + ), + ); + const rewind = { + type: "agent.timeline.rewind", + payload: { + agentId: agent.id, + epoch, + seq: loaded.window.nextSeq, + retireFromSeq: target.seqStart, + }, + }; + const timelineChanges = (connected: ConnectedClient) => + connected.messages.filter( + (message) => + message.type === "agent.timeline.rewind" || message.type === "agent.timeline.replacement", + ); expect( initiating.messages.filter((message) => message.type === "agent.rewind.response"), ).toHaveLength(1); - expect( - initiating.messages.filter((message) => message.type === "agent.timeline.replacement"), - ).toHaveLength(0); - expect( - passive.messages.filter((message) => message.type === "agent.timeline.replacement"), - ).toHaveLength(1); + // A client that applies rewinds keeps its history and epoch, including the one that asked. + expect(timelineChanges(initiating)).toEqual([ + expect.objectContaining({ ...rewind, payload: expect.objectContaining(rewind.payload) }), + ]); + expect(timelineChanges(capable)).toEqual([ + expect.objectContaining({ ...rewind, payload: expect.objectContaining(rewind.payload) }), + ]); + // An older client only drops history on an epoch change, so it sees a new one. + expect(timelineChanges(passive)).toEqual([ + expect.objectContaining({ + type: "agent.timeline.replacement", + payload: expect.objectContaining({ agentId: agent.id, epoch: legacyEpoch }), + }), + ]); expect(passive.hasTimeline(agent.id)).toBe(false); - expect(legacy.messages.filter(isAgentStream(agent.id)).length).toBeGreaterThan(0); - expect( - unrelated.messages.filter((message) => message.type === "agent.timeline.replacement"), - ).toHaveLength(0); + expect(timelineChanges(unrelated)).toEqual([]); expect(unrelated.hasTimeline(agent.id)).toBe(false); + // A client older than replacement messages gets the remaining rows under the new epoch. + const legacyRows = legacy.messages.filter(isAgentStream(agent.id)); + expect( + legacyRows.map((message) => message.type === "agent_stream" && message.payload.epoch), + ).toEqual(legacyRows.map(() => legacyEpoch)); + expect( + legacyRows.flatMap((message) => + message.type === "agent_stream" && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "user_message" + ? [message.payload.event.item.text] + : [], + ), + ).toEqual(["Keep this prompt"]); + + // A capable reader catches up from where it was; an older one is reset to the new epoch. + const capableCatchUp = await capable.client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { epoch, seq: loaded.endCursor!.seq }, + limit: 50, + }); + expect(capableCatchUp).toMatchObject({ + epoch, + reset: false, + retirements: [{ seq: loaded.window.nextSeq, retireFromSeq: target.seqStart }], + entries: [], + }); + const olderCatchUp = await passive.client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { epoch, seq: loaded.endCursor!.seq }, + limit: 50, + }); + expect(olderCatchUp).toMatchObject({ epoch: legacyEpoch, reset: true, staleCursor: true }); + expect(olderCatchUp.retirements).toBeUndefined(); + expect(olderCatchUp.endCursor?.epoch).toBe(legacyEpoch); + const texts = (page: typeof olderCatchUp) => + page.entries.flatMap((entry) => (entry.item.type === "user_message" ? [entry.item.text] : [])); + expect(texts(olderCatchUp)).toEqual(["Keep this prompt"]); + // Its cursor under the new epoch keeps working. + const olderNext = await passive.client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: olderCatchUp.endCursor!, + limit: 50, + }); + expect(olderNext).toMatchObject({ epoch: legacyEpoch, reset: false, entries: [] }); + + // Live rows after the rewind carry each client's epoch. + for (const connected of [capable, passive]) connected.clear(); + await initiating.client.sendMessage(agent.id, "After the rewind"); + await initiating.client.cancelAgent(agent.id); + await Promise.all([capable.barrier("after-capable"), passive.barrier("after-passive")]); + const liveEpochs = (connected: ConnectedClient) => [ + ...new Set( + connected.messages.flatMap((message) => + message.type === "agent_stream" && + message.payload.agentId === agent.id && + message.payload.event.type === "timeline" + ? [message.payload.epoch] + : [], + ), + ), + ]; + expect(liveEpochs(capable)).toEqual([epoch]); + expect(liveEpochs(passive)).toEqual([legacyEpoch]); }, 30_000); test("subscription acknowledgements stay on the requesting socket of a retained session", async () => { @@ -753,13 +847,15 @@ test("plugin items are gated in provider child streams, child fetches, and rewin ]); expect(oldChild.window).toEqual(newChild.window); + await capable.client.sendMessage(agent.id, "Kept prompt"); + await capable.client.cancelAgent(agent.id); await capable.client.sendMessage(agent.id, "Rewind target"); await capable.client.cancelAgent(agent.id); const timeline = await capable.client.fetchAgentTimeline(agent.id, { direction: "tail", projection: "canonical", }); - const targetMessageId = rewindMessageId(timeline); + const targetMessageId = rewindMessageId(timeline, "Rewind target"); capable.clear(); legacy.clear(); await capable.client.rewindAgent(agent.id, targetMessageId, "conversation"); @@ -773,7 +869,12 @@ test("plugin items are gated in provider child streams, child fetches, and rewin } expect(replayItems(capable)).toContainEqual(plugin); expect(replayItems(legacy).some((item) => item.type === "plugin")).toBe(false); - expect(replayItems(legacy)).toContainEqual(expect.objectContaining({ type: "user_message" })); + expect(replayItems(legacy)).toContainEqual( + expect.objectContaining({ type: "user_message", text: "Kept prompt" }), + ); + expect(replayItems(legacy)).not.toContainEqual( + expect.objectContaining({ type: "user_message", text: "Rewind target" }), + ); }); async function createAttentionWorkspace(client: DaemonClient): Promise { @@ -786,8 +887,11 @@ async function createAttentionWorkspace(client: DaemonClient): Promise { function rewindMessageId( timeline: Awaited>, + text: string, ): string { - const target = timeline.entries.find((entry) => entry.item.type === "user_message"); + const target = timeline.entries.find( + (entry) => entry.item.type === "user_message" && entry.item.text === text, + ); if (target?.item.type !== "user_message" || !target.item.messageId) throw new Error("Expected rewind target"); return target.item.messageId; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index d1408f9880..45ab17b703 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1510,7 +1510,9 @@ function createStoredAgentRecord( describe("plugin timeline append RPC", () => { test("stamps the plugin identity and returns the timeline position", async () => { const messages: SessionOutboundMessage[] = []; - const appendTimelineItem = vi.fn().mockResolvedValue({ seq: 7, epoch: "epoch-1" }); + const appendTimelineItem = vi + .fn() + .mockResolvedValue({ seq: 7, epoch: "epoch-1", retirementCount: 0 }); const session = createSessionForTest({ clientId: "plugin:review", messages, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 72c7268612..c55fe9f469 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -19,6 +19,7 @@ import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; +import { legacyWireEpoch, readLegacyWireCursor } from "./session/legacy-rewind-epoch/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import { basename, join, resolve, sep } from "path"; @@ -1381,10 +1382,17 @@ export class Session { }, }); } - const message: SessionOutboundMessage = { + const payload = this.buildAgentStreamPayload(event, serializedEvent); + const messageFor = (source?: object): SessionOutboundMessage => ({ type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }; + payload: + payload.epoch === undefined + ? payload + : { + ...payload, + epoch: this.wireEpoch(payload.epoch, event.retirementCount ?? 0, source), + }, + }); for (const subscription of this.timelineSubscriptions.values()) { const source = subscription.owner.source; if (!subscription.agentIds.has(event.agentId)) continue; @@ -1399,7 +1407,7 @@ export class Session { !this.supportsTimelineItem(serializedEvent.item, source) ) continue; - subscription.owner.emit(message); + subscription.owner.emit(messageFor(source)); } // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit timeline delivery after 2027-03-09. for (const [source, { capabilities }] of this.clientSources) { @@ -1414,14 +1422,33 @@ export class Session { (subscription) => subscription.owner.source === source && subscription.agentIds.has(event.agentId), ); - if (!alreadyDelivered) this.onMessageToSource?.(source, message); + if (!alreadyDelivered) this.onMessageToSource?.(source, messageFor(source)); } if ( this.clientSources.size === 0 && !this.supports(CLIENT_CAPS.selectiveAgentTimeline) && this.timelineSubscriptions.size === 0 ) - this.emit(message); + this.emit(messageFor()); + } + + // COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 with + // ./session/legacy-rewind-epoch. A client that cannot apply rewinds sees each one as a new epoch. + private appliesRewinds(source?: object): boolean { + return source + ? this.supportsForSource(CLIENT_CAPS.timelineRewindOperations, source) + : this.supports(CLIENT_CAPS.timelineRewindOperations); + } + + private wireEpoch(epoch: string, retirementCount: number, source?: object): string { + return this.appliesRewinds(source) ? epoch : legacyWireEpoch(epoch, retirementCount); + } + + private readWireCursor( + cursor: { epoch: string; seq: number }, + source?: object, + ): AgentTimelineCursor { + return this.appliesRewinds(source) ? cursor : readLegacyWireCursor(cursor); } supports(capability: ClientCapability): boolean { @@ -1988,8 +2015,15 @@ export class Session { this.unsubscribeAgentEvents = this.agentManager.subscribe( (event) => { - if (event.type === "timeline_replacement") { - this.deliverTimelineReplacement(event, this.rewindInitiators.get(event.agentId)); + if (event.type === "timeline_retirement") { + this.deliverTimelineRetirement(event, this.rewindInitiators.get(event.agentId)).catch( + (error) => { + this.sessionLogger.error( + { err: error, agentId: event.agentId }, + "Failed to deliver timeline retirement", + ); + }, + ); return; } @@ -4910,17 +4944,35 @@ export class Session { } } - private deliverTimelineReplacement( - timeline: Extract, + private async deliverTimelineRetirement( + retirement: Extract, initiatingSource?: object, - ): void { - const { agentId, epoch } = timeline; + ): Promise { + const { agentId, epoch, seq, retireFromSeq, retirementCount } = retirement; const agent = this.agentManager.getAgent(agentId); if (!agent) return; + const rewind: SessionOutboundMessage = { + type: "agent.timeline.rewind", + payload: { agentId, epoch, seq, retireFromSeq }, + }; + const legacyEpoch = legacyWireEpoch(epoch, retirementCount); + const replacement: SessionOutboundMessage = { + type: "agent.timeline.replacement", + payload: { agentId, epoch: legacyEpoch }, + }; + const readActiveRows = async () => + (await this.agentManager.fetchTimeline(agentId, { limit: 0 })).rows; if (this.clientSources.size === 0 || !this.onMessageToSource) { - if (!this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) { - this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch); + if (this.appliesRewinds()) this.emit(rewind); + else if (this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) this.emit(replacement); + else { + this.emitReconstructedTimelineRows( + agentId, + agent.provider, + await readActiveRows(), + legacyEpoch, + ); } return; } @@ -4928,26 +4980,32 @@ export class Session { for (const subscription of this.timelineSubscriptions.values()) { const source = subscription.owner.source; if (!subscription.agentIds.has(agentId)) continue; - const modern = this.delivery.isModern(source); + if (this.appliesRewinds(source)) { + subscription.owner.emit(rewind); + continue; + } + // COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10. An older + // client that started the rewind refetches its history when the rewind response arrives. if ( - modern || + this.delivery.isModern(source) || (source !== initiatingSource && this.supportsForSource(CLIENT_CAPS.timelineReplacementInvalidation, source)) ) { - subscription.owner.emit({ - type: "agent.timeline.replacement", - payload: { agentId, epoch }, - }); + subscription.owner.emit(replacement); } } // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove reconstructed replay after 2027-02-21. - for (const [source, { capabilities }] of this.clientSources) { - if ( - !this.delivery.isModern(source) && - !capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation) - ) { - this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); - } + const replaySources = [...this.clientSources] + .filter( + ([source, { capabilities }]) => + !this.delivery.isModern(source) && + !capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation), + ) + .map(([source]) => source); + if (replaySources.length === 0) return; + const rows = await readActiveRows(); + for (const source of replaySources) { + this.emitReconstructedTimelineRows(agentId, agent.provider, rows, legacyEpoch, source); } } @@ -7830,12 +7888,7 @@ export class Session { ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const projection = "projected" as const; - const cursor: AgentTimelineCursor | undefined = msg.cursor - ? { - epoch: msg.cursor.epoch, - seq: msg.cursor.seq, - } - : undefined; + const cursor = msg.cursor ? this.readWireCursor(msg.cursor, source) : undefined; try { const snapshot = await ensureAgentLoaded(msg.agentId, { @@ -7859,14 +7912,15 @@ export class Session { hasOlder: fetchedControlTimeline.hasOlder, hasNewer: fetchedControlTimeline.hasNewer, }; + const epoch = this.wireEpoch( + fetchedControlTimeline.epoch, + fetchedControlTimeline.retirementCount, + source, + ); const startCursor = - selectedTimeline.startSeq !== null - ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq } - : null; + selectedTimeline.startSeq !== null ? { epoch, seq: selectedTimeline.startSeq } : null; const endCursor = - selectedTimeline.endSeq !== null - ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq } - : null; + selectedTimeline.endSeq !== null ? { epoch, seq: selectedTimeline.endSeq } : null; const entries = selectedTimeline.entries.filter((entry) => this.supportsTimelineItem(entry.item, source), ); @@ -7880,7 +7934,7 @@ export class Session { agent: agentPayload, direction, projection, - epoch: selectedTimeline.timeline.epoch, + epoch, reset: fetchedControlTimeline.reset, staleCursor: fetchedControlTimeline.staleCursor, gap: fetchedControlTimeline.gap, @@ -7890,6 +7944,9 @@ export class Session { hasOlder: selectedTimeline.hasOlder, hasNewer: selectedTimeline.hasNewer, ...(msg.mergeWindow === true ? { mergeWindow: true } : {}), + ...(this.appliesRewinds(source) + ? { retirements: fetchedControlTimeline.retirements } + : {}), entries: entries.map((entry) => { const payloadEntry = { provider: snapshot.provider, @@ -7954,13 +8011,13 @@ export class Session { const pluginId = parsePluginClientId(this.clientId); if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items"); assertPluginTimelineDataSize(msg.item.data); - const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, { - ...msg.item, - pluginId, - }); + const { seq, epoch, retirementCount } = await this.agentManager.appendTimelineItem( + msg.agentId, + { ...msg.item, pluginId }, + ); this.emit({ type: "agent.timeline.append.response", - payload: { requestId: msg.requestId, seq, epoch }, + payload: { requestId: msg.requestId, seq, epoch: this.wireEpoch(epoch, retirementCount) }, }); } @@ -7975,17 +8032,19 @@ export class Session { logger: this.sessionLogger, }); const rows = await this.agentManager.getTimelineRows(msg.agentId); - const { epoch } = await this.agentManager.fetchTimeline(msg.agentId, { + const before = await this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1, }); const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor }); - if ( - (await this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 })) - .epoch !== epoch - ) { + const after = await this.agentManager.fetchTimeline(msg.agentId, { + direction: "tail", + limit: 1, + }); + if (after.epoch !== before.epoch || after.retirementCount !== before.retirementCount) { throw new Error("History changed; search again"); } + const epoch = this.wireEpoch(before.epoch, before.retirementCount, source); this.emitForSource( { type: "agent.timeline.search.response", @@ -8032,7 +8091,10 @@ export class Session { direction: "tail", limit: 1, }); - const index = buildTimelinePromptIndex(timeline.epoch, rows); + const index = buildTimelinePromptIndex( + this.wireEpoch(timeline.epoch, timeline.retirementCount, source), + rows, + ); this.emitForSource( { type: "agent.timeline.list_prompts.response", @@ -8214,7 +8276,10 @@ export class Session { const forkContext = buildAgentForkContextAttachment({ rows: timeline.rows, cursorBoundary: msg.boundaryCursor - ? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor } + ? { + timelineEpoch: this.wireEpoch(timeline.epoch, timeline.retirementCount), + cursor: msg.boundaryCursor, + } : null, boundaryMessageId: msg.boundaryMessageId, agentTitle: agentPayload.title, diff --git a/packages/server/src/server/session/legacy-rewind-epoch/index.ts b/packages/server/src/server/session/legacy-rewind-epoch/index.ts new file mode 100644 index 0000000000..2c82c6ac87 --- /dev/null +++ b/packages/server/src/server/session/legacy-rewind-epoch/index.ts @@ -0,0 +1,26 @@ +// COMPAT(timelineRewindOperations): added in v0.14.1, remove after 2027-05-10 once the client floor +// applies rewinds. Delete this module and its call sites in session.ts. +// +// A client without the capability cannot drop retired rows from what it already holds. The only +// signal it honors is an epoch change, so it sees each retirement as a new history: the stored +// epoch, suffixed with the retirement count once there is one. Nothing is stored or rotated. + +import type { AgentTimelineCursor } from "../../agent/agent-timeline-store-types.js"; + +const RETIREMENT_SUFFIX = /^(.*)\.r([1-9][0-9]*)$/; + +/** The epoch a client without rewind operations sees for a history. */ +export function legacyWireEpoch(epoch: string, retirementCount: number): string { + return retirementCount === 0 ? epoch : `${epoch}.r${retirementCount}`; +} + +/** + * Reads a cursor such a client sends back. It stays valid only while no retirement has happened + * since, so the timeline treats a later retirement as a stale cursor and resets the client. + */ +export function readLegacyWireCursor(cursor: { epoch: string; seq: number }): AgentTimelineCursor { + const match = RETIREMENT_SUFFIX.exec(cursor.epoch); + return match + ? { epoch: match[1], seq: cursor.seq, retirementCount: Number(match[2]) } + : { epoch: cursor.epoch, seq: cursor.seq, retirementCount: 0 }; +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7bfc4d5a0f..78fcdd6804 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2080,6 +2080,8 @@ export class VoiceAssistantWebSocketServer { workspaceTerminals: true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, + // COMPAT(timelineRewindOperations): added in v0.14.1, remove gate after 2027-05-10. + timelineRewindOperations: true, // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. agentTimelinePromptIndex: true, // COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07. diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index 9762323dce..477177d9e1 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -45,6 +45,8 @@ await directory.subscription.release(); ## Follow timeline events ```ts +let entries = (await agent.timeline.refetch()).entries; + const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; if (event.type === "subscription_restored") { @@ -61,6 +63,13 @@ const unsubscribe = agent.timeline.subscribe((update) => { void agent.timeline.refetch().then((page) => console.log(page.entries)); return; } + if (event.type === "rewind") { + // Entries that started in [retireFromSeq, seq) left the conversation. Later ones stay. + entries = entries.filter( + (entry) => entry.seqStart < event.retireFromSeq || entry.seqStart >= event.seq, + ); + return; + } if (event.type === "timeline" && event.item.type === "assistant_message") { process.stdout.write(event.item.text); } @@ -75,7 +84,7 @@ Await `unsubscribe.ready` before starting work whose events you need to observe. After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "subscription_restored" } }` before subsequent live updates. This is a local SDK notification after membership acknowledgement (local attachment on broadcast-only hosts). The subscription gets a fresh ID. No history is fetched automatically, and missed events are not replayed. -Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A failed explicit history read rejects that read and leaves the live subscription active. +Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A live `rewind` keeps the epoch and removes the entries whose `seqStart` is at least `retireFromSeq` and below `seq`; entries from `seq` on belong to the rewound conversation. A failed explicit history read rejects that read and leaves the live subscription active. Subscription establishment failures deliver `{ agentId, event: { type: "error", error } }` and release the observation. Establish a new subscription when ready to retry. Releasing a subscription stops its callbacks and prevents restoration after reconnect. @@ -97,7 +106,7 @@ for (const entry of page.entries) { } ``` -Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to page without inventing offsets. +Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to page without inventing offsets. A page can carry `retirements`, the rewinds whose `seq` falls inside it. When you append the page to entries you already hold, first drop held entries whose `seqStart` is at least that retirement's `retireFromSeq` and below its `seq`. Entries you hold from `seq` on stay. ## Follow workspace updates diff --git a/public-docs/sdk/events.zh-CN.md b/public-docs/sdk/events.zh-CN.md index 190d4c9a48..3888d3d784 100644 --- a/public-docs/sdk/events.zh-CN.md +++ b/public-docs/sdk/events.zh-CN.md @@ -43,6 +43,8 @@ await directory.subscription.release(); ## 跟踪时间线事件 {#follow-timeline-events} ```ts +let entries = (await agent.timeline.refetch()).entries; + const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; if (event.type === "subscription_restored") { @@ -59,6 +61,13 @@ const unsubscribe = agent.timeline.subscribe((update) => { void agent.timeline.refetch().then((page) => console.log(page.entries)); return; } + if (event.type === "rewind") { + // Entries that started in [retireFromSeq, seq) left the conversation. Later ones stay. + entries = entries.filter( + (entry) => entry.seqStart < event.retireFromSeq || entry.seqStart >= event.seq, + ); + return; + } if (event.type === "timeline" && event.item.type === "assistant_message") { process.stdout.write(event.item.text); } @@ -73,7 +82,7 @@ const unsubscribe = agent.timeline.subscribe((update) => { 重连后,同一个句柄会在后续实时更新之前收到 `{ agentId, subscriptionId, event: { type: "subscription_restored" } }`。这是 SDK 在成员关系确认后(在只支持广播的主机上是本地挂载后)发出的本地通知。订阅会获得一个新的 ID。不会自动获取历史,错过的事件也不会重放。 -请为你的使用方选择恢复方式:继续接收实时事件、请求最近一页,或者用你保存的位置调用 `timeline.refetch({ direction: "after", cursor: { epoch, seq } })`。读取进行期间,实时投递会继续;请按 epoch 和序号缓冲这些事件,或与返回的页面进行对账。需要时,根据 `hasNewer` 和 `endCursor` 继续读取后面的页面。实时的 `replacement` 会使之前的 epoch 失效。显式的历史读取失败时,只会让那次读取 reject,实时订阅仍然有效。 +请为你的使用方选择恢复方式:继续接收实时事件、请求最近一页,或者用你保存的位置调用 `timeline.refetch({ direction: "after", cursor: { epoch, seq } })`。读取进行期间,实时投递会继续;请按 epoch 和序号缓冲这些事件,或与返回的页面进行对账。需要时,根据 `hasNewer` 和 `endCursor` 继续读取后面的页面。实时的 `replacement` 会使之前的 epoch 失效。实时的 `rewind` 保持 epoch 不变,并移除 `seqStart` 不小于 `retireFromSeq` 且小于 `seq` 的条目;从 `seq` 起的条目属于回退后的对话。显式的历史读取失败时,只会让那次读取 reject,实时订阅仍然有效。 订阅建立失败时会投递 `{ agentId, event: { type: "error", error } }` 并释放该观察。准备好重试时,请建立新的订阅。释放订阅会停止它的回调,并阻止重连后的恢复。 @@ -95,7 +104,7 @@ for (const entry of page.entries) { } ``` -使用结果中的 `startCursor`、`endCursor`、`hasOlder` 和 `hasNewer` 翻页,不要自己计算偏移量。 +使用结果中的 `startCursor`、`endCursor`、`hasOlder` 和 `hasNewer` 翻页,不要自己计算偏移量。页面可能带有 `retirements`,即 `seq` 落在该页范围内的回退。把页面追加到已持有的条目之前,先针对每个回退丢弃 `seqStart` 不小于其 `retireFromSeq` 且小于其 `seq` 的已持有条目。从 `seq` 起已持有的条目保留。 ## 跟踪工作区更新 {#follow-workspace-updates} diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 2867261c32..17716d685a 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -116,7 +116,7 @@ See [background workspaces](./workspaces.md) for caller inheritance and inclusiv `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. -`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). +`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch; a live rewind removes a range of entries and keeps the epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). ## `client.projects` diff --git a/public-docs/sdk/reference.zh-CN.md b/public-docs/sdk/reference.zh-CN.md index fcda38bc89..cc8a39360f 100644 --- a/public-docs/sdk/reference.zh-CN.md +++ b/public-docs/sdk/reference.zh-CN.md @@ -113,7 +113,7 @@ nav: API 参考 `agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection` 和 `requestId`。 -`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。 +`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效;实时的回退会移除一段条目并保持 epoch 不变。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。 ## `client.projects` {#clientprojects}