From 04e893417e2e95c77a25a413edb675557813d727 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 16:30:42 +0200 Subject: [PATCH] Allow failed agent turns to be forked (#2063) * fix(chat): allow failed turns to be forked Failed turns can end in Paseo-generated timeline items without provider message IDs. Anchor forks to canonical timeline positions while retaining legacy boundaries for compatibility. Place the fork summary before the new prompt and delimit it as structured chat history. * fix(chat): tighten fork boundary handling --- docs/timeline-sync.md | 8 +++ packages/app/e2e/assistant-fork-menu.spec.ts | 22 ++++++ .../src/agent-stream/turn-boundary.test.ts | 67 ++++++++++++++++++- .../app/src/agent-stream/turn-boundary.ts | 24 ++++++- packages/app/src/agent-stream/turn-footer.tsx | 27 ++++++-- packages/app/src/agent-stream/view.tsx | 25 +++++-- packages/app/src/attachments/types.ts | 1 + packages/app/src/components/message.tsx | 13 ++-- .../timeline/session-stream-reducers.test.ts | 46 ++++++++++++- .../src/timeline/session-stream-reducers.ts | 33 +++++++-- packages/app/src/types/stream.ts | 39 +++++++++-- packages/client/src/daemon-client.ts | 2 + .../protocol/src/messages.attachments.test.ts | 43 ++++++++++++ packages/protocol/src/messages.ts | 4 ++ .../src/server/agent/activity-curator.test.ts | 37 ++++++++++ .../src/server/agent/activity-curator.ts | 49 +++++++++++--- .../src/server/agent/create-agent/create.ts | 32 +-------- .../server/agent/prompt-attachments.test.ts | 36 +++++++++- .../src/server/agent/prompt-attachments.ts | 34 ++++++++++ .../providers/mock-load-test-agent.test.ts | 32 +++++++++ .../agent/providers/mock-load-test-agent.ts | 36 +++++++++- packages/server/src/server/session.ts | 45 +++---------- .../server/src/server/websocket-server.ts | 2 + 23 files changed, 549 insertions(+), 108 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 86414257a..020ee0ed4 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -37,6 +37,14 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. +## Durable item anchors + +Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows. + +Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. + +The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context. + ## Resume behavior When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run. diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts index 47a26f531..965c87b94 100644 --- a/packages/app/e2e/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -54,6 +54,28 @@ async function expectChatHistoryPill(page: Page): Promise { test.describe("Assistant fork menu", () => { test.describe.configure({ timeout: 180_000 }); + test("forks a failed assistant turn that has no provider message id", async ({ + page, + seedForkWorkspace, + }) => { + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-failed-turn-", + title: "Assistant fork failed turn", + model: "ten-second-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await submitMessage(page, "Emit a synthetic turn failure."); + await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({ + timeout: 30_000, + }); + + await openAssistantForkMenu(page); + await page.getByTestId("assistant-fork-menu-new-tab").click(); + await expectChatHistoryPill(page); + }); + test("focuses a forked assistant turn in a new workspace draft tab", async ({ page, seedForkWorkspace, diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index c18fad104..33e56901d 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { + resolveAssistantTurnBoundaryMessageId, + resolveAssistantTurnForkBoundary, +} from "./turn-boundary"; function timestamp(seed: number): Date { return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); @@ -62,3 +65,65 @@ describe("resolveAssistantTurnBoundaryMessageId", () => { ).toBeUndefined(); }); }); + +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 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [userMessage("user-1", 1), failedTurn], + startIndex: 1, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + }); + }); + + 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 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + boundaryMessageId: "msg-assistant-1", + }); + }); + + 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 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toEqual({ boundaryMessageId: "msg-assistant-1" }); + }); + + it("does not offer an unavailable boundary", () => { + expect( + resolveAssistantTurnForkBoundary({ + items: [assistantMessage("assistant-1", 2)], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts index 75fa63a3e..637c83b72 100644 --- a/packages/app/src/agent-stream/turn-boundary.ts +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -1,4 +1,8 @@ -import type { StreamItem } from "@/types/stream"; +import type { StreamItem, TimelinePosition } from "@/types/stream"; + +export type AssistantTurnForkBoundary = + | { boundaryCursor: TimelinePosition; boundaryMessageId?: string } + | { boundaryCursor?: undefined; boundaryMessageId: string }; export function resolveAssistantTurnBoundaryMessageId(input: { items: readonly StreamItem[]; @@ -11,3 +15,21 @@ export function resolveAssistantTurnBoundaryMessageId(input: { // Forking without the selected assistant's durable message id would send the wrong slice. return item.messageId || undefined; } + +export function resolveAssistantTurnForkBoundary(input: { + items: readonly StreamItem[]; + startIndex: number; + supportsTimelineCursor: boolean; +}): AssistantTurnForkBoundary | undefined { + const item = input.items[input.startIndex]; + if (item?.kind !== "assistant_message") { + return undefined; + } + if (input.supportsTimelineCursor && item.timelineCursor) { + return { + boundaryCursor: item.timelineCursor, + ...(item.messageId ? { boundaryMessageId: item.messageId } : {}), + }; + } + return item.messageId ? { boundaryMessageId: item.messageId } : undefined; +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 2f7bf3c8b..5b7e01e8d 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -9,7 +9,7 @@ import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, } from "./strategy"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary"; import { AssistantTurnFooter, LiveElapsed, @@ -31,7 +31,7 @@ const workingIndicatorColorMapping = (theme: Theme) => ({ export type TurnContentStrategy = StreamStrategy; export type AssistantTurnForkHandler = (input: { target: AssistantForkTarget; - boundaryMessageId?: string; + boundary: AssistantTurnForkBoundary; }) => Promise | void; export const TurnFooter = memo(function TurnFooter({ @@ -39,12 +39,14 @@ export const TurnFooter = memo(function TurnFooter({ inFlightTurnStartedAt, host, strategy, + supportsTimelineCursor, onForkAssistantTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; host: TurnFooterHost | null; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { if (isRunning) { @@ -63,6 +65,7 @@ export const TurnFooter = memo(function TurnFooter({ items={host.items} timing={host.timing} startIndex={host.startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> ); @@ -73,12 +76,14 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { return ( @@ -88,6 +93,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items={items} timing={timing} startIndex={startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> @@ -130,12 +136,14 @@ function CompletedTurnFooter({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { const getContent = useCallback( @@ -147,18 +155,27 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); - const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({ + const boundary = resolveAssistantTurnForkBoundary({ items, startIndex, + supportsTimelineCursor, }); + const handleFork = useCallback( + (target: AssistantForkTarget) => { + if (!boundary) { + return; + } + return onForkAssistantTurn?.({ target, boundary }); + }, + [boundary, onForkAssistantTurn], + ); return ( ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 29caafc42..a1354787f 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -142,6 +142,7 @@ function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { @@ -155,6 +156,7 @@ function renderStreamItemWithTurnFooter(input: { items={footerHost.items} timing={footerHost.timing} startIndex={footerHost.startIndex} + supportsTimelineCursor={input.supportsTimelineCursor} onForkAssistantTurn={input.onForkAssistantTurn} /> ) : null; @@ -282,6 +284,7 @@ function buildChatHistoryAttachment(input: { serverId: input.serverId, agentId: input.agentId, boundaryMessageId: input.payload.boundaryMessageId, + boundaryCursor: input.payload.boundaryCursor, itemCount: input.payload.itemCount, }, }; @@ -369,6 +372,10 @@ const AgentStreamViewComponent = forwardRef + state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, + ); const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -466,7 +473,7 @@ const AgentStreamViewComponent = forwardRef { + async ({ target, boundary }) => { try { if (!supportsAgentForkContext) { toast?.error(t("message.actions.forkUnavailable")); @@ -478,10 +485,7 @@ const AgentStreamViewComponent = forwardRef { const draftId = generateDraftId(); - const payload = await client.buildAgentForkContext( - agentId, - boundaryMessageId ? { boundaryMessageId } : {}, - ); + const payload = await client.buildAgentForkContext(agentId, boundary); const attachment = buildChatHistoryAttachment({ draftId, serverId: resolvedServerId, @@ -854,10 +858,17 @@ const AgentStreamViewComponent = forwardRef ) : null, @@ -892,6 +904,7 @@ const AgentStreamViewComponent = forwardRef(() => { diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 82e447f92..2199ce29f 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment { serverId: string; agentId: string; boundaryMessageId?: string | null; + boundaryCursor?: { epoch: string; seq: number } | null; itemCount?: number; }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 249ba4e56..08792bfd5 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -564,11 +564,7 @@ interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; durationMs?: number; - forkBoundaryMessageId?: string; - onFork?: (input: { - target: AssistantForkTarget; - boundaryMessageId?: string; - }) => Promise | void; + onFork?: (target: AssistantForkTarget) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ @@ -613,7 +609,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, - forkBoundaryMessageId, onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); @@ -656,11 +651,11 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ }, [canSwap]); const handleFork = useCallback( (target: AssistantForkTarget) => { - return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId }); + return onFork?.(target); }, - [forkBoundaryMessageId, onFork], + [onFork], ); - const canFork = Boolean(onFork && forkBoundaryMessageId); + const canFork = Boolean(onFork); return ( diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index a3e6acf5e..e0026371c 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -187,6 +187,25 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("preserves the canonical end cursor on a projected assistant message", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + payload: { + ...baseTimelineInput.payload, + epoch: "timeline-1", + entries: [makeTimelineEntry(40, "[System Error] failed", "assistant_message", 42)], + }, + }); + + expect(result.tail).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("returns error path when payload.error is set", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -892,7 +911,12 @@ describe("processTimelineResponse", () => { }); it("merges assistant chunks across the older-page prepend boundary", () => { - const currentTail = [makeAssistantItem("newer chunk", "assistant-newer")]; + const currentTail = [ + { + ...makeAssistantItem("newer chunk", "assistant-newer"), + timelineCursor: { epoch: "epoch-1", seq: 3 }, + }, + ]; const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -914,6 +938,9 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); + expect(result.tail[0]).toEqual( + expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), + ); expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, @@ -1222,6 +1249,23 @@ describe("processTimelineResponse", () => { // --------------------------------------------------------------------------- describe("processAgentStreamEvent", () => { + it("preserves the live timeline cursor on an assistant error", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeAssistantTimelineEvent("[System Error] failed"), + epoch: "timeline-1", + seq: 42, + }); + + expect(result.head).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("passes through non-timeline events without cursor changes", () => { const turnEvent: AgentStreamEventPayload = { type: "turn_completed", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 22b0062f2..aaa0e21c0 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -510,6 +510,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea ...olderLast, text: `${olderLast.text}${currentFirst.text}`, timestamp: currentFirst.timestamp, + ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), }, ...currentTail.slice(1), ]; @@ -519,8 +520,9 @@ function replaceLiveAssistantWithProjectedText(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; + timelineCursor: { epoch: string; seq: number }; }): StreamItem[] | null { - const { head, event, timestamp } = params; + const { head, event, timestamp, timelineCursor } = params; if (event.type !== "timeline" || event.item.type !== "assistant_message") { return null; } @@ -537,6 +539,7 @@ function replaceLiveAssistantWithProjectedText(params: { ...current, text: event.item.text, timestamp, + timelineCursor, }; return next; } @@ -575,19 +578,22 @@ function applyTimelineIncrementalPath(args: { if (acceptedUnits.length > 0) { if (payload.direction === "before") { const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp }) => ({ + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ event, timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })), { source: "canonical" }, ); nextTail = mergePrependedCanonicalTail(olderTail, currentTail); } else if (currentHead.length > 0) { - for (const { event, timestamp } of acceptedUnits) { + for (const { event, timestamp, seqEnd } of acceptedUnits) { + const timelineCursor = { epoch: payload.epoch, seq: seqEnd }; const replacedHead = replaceLiveAssistantWithProjectedText({ head: nextHead, event, timestamp, + timelineCursor, }); if (replacedHead) { nextHead = replacedHead; @@ -599,15 +605,17 @@ function applyTimelineIncrementalPath(args: { event, timestamp, source: "canonical", + timelineCursor, }); nextTail = applied.tail; nextHead = applied.head; } } else { nextTail = acceptedUnits.reduce( - (state, { event, timestamp }) => + (state, { event, timestamp, seqEnd }) => reduceStreamUpdate(state, event, timestamp, { source: "canonical", + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, }), currentTail, ); @@ -681,8 +689,16 @@ export function processTimelineResponse( const toHydratedEvents = ( units: TimelineUnit[], - ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => - units.map(({ event, timestamp }) => ({ event, timestamp })); + ): Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }> => + units.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) @@ -934,6 +950,10 @@ export function processAgentStreamEvent( input; const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); + const timelineCursor = + event.type === "timeline" && seq !== undefined && epoch !== undefined + ? { epoch, seq } + : undefined; // ------------------------------------------------------------------ // Apply stream event to tail/head @@ -945,6 +965,7 @@ export function processAgentStreamEvent( event, timestamp, source: "live", + timelineCursor, }) : { tail: currentTail, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index e059c0e98..13728a70e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -107,12 +107,18 @@ export interface AssistantMessageItem { kind: "assistant_message"; id: string; messageId?: string; + timelineCursor?: TimelinePosition; text: string; timestamp: Date; blockGroupId?: string; blockIndex?: number; } +export interface TimelinePosition { + epoch: string; + seq: number; +} + export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { @@ -202,6 +208,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; + timelineCursor?: TimelinePosition; } function isRecord(value: unknown): value is Record { @@ -385,6 +392,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -401,6 +409,7 @@ function appendAssistantMessage( ...last, text: `${last.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -1), updated]; } @@ -418,6 +427,7 @@ function appendAssistantMessage( ...secondLast, text: `${secondLast.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -2), updated, last]; } @@ -432,6 +442,7 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, }; @@ -817,6 +828,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -831,6 +843,7 @@ function reduceTimelineEvent( source, item.messageId, reservedItemIds, + timelineCursor, ), ); case "reasoning": @@ -876,7 +889,14 @@ export function reduceStreamUpdate( const source = options?.source ?? "live"; switch (event.type) { case "timeline": - return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds); + return reduceTimelineEvent( + state, + event, + timestamp, + source, + options?.reservedItemIds, + options?.timelineCursor, + ); case "thread_started": case "turn_started": case "turn_completed": @@ -898,11 +918,12 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; + timelineCursor?: TimelinePosition; }>, options?: { source?: StreamUpdateSource }, ): StreamItem[] { - const hydrated = events.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp, options); + const hydrated = events.reduce((state, { event, timestamp, timelineCursor }) => { + return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor }); }, []); return finalizeActiveThoughts(hydrated); @@ -1186,6 +1207,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; + timelineCursor?: TimelinePosition; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; const source = params.source ?? "live"; @@ -1256,7 +1278,11 @@ export function applyStreamEvent(params: { ), ) : undefined; - const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds }); + const reduced = reduceStreamUpdate(nextHead, event, timestamp, { + source, + reservedItemIds, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextHead) { nextHead = reduced; changedHead = true; @@ -1275,7 +1301,10 @@ export function applyStreamEvent(params: { } // For non-streamable kinds or non-timeline events, apply to tail - const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source }); + const reduced = reduceStreamUpdate(nextTail, event, timestamp, { + source, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextTail) { nextTail = reduced; changedTail = true; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9f5f3d7c3..fdb905b13 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -538,6 +538,7 @@ function normalizeListCommandsOptions( return { agentId: input, ...legacyOptions }; } export interface AgentForkContextOptions { + boundaryCursor?: FetchAgentTimelineCursor; boundaryMessageId?: string; requestId?: string; } @@ -2555,6 +2556,7 @@ export class DaemonClient { type: "agent.fork_context.request", agentId, requestId: resolvedRequestId, + ...(options.boundaryCursor ? { boundaryCursor: options.boundaryCursor } : {}), ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}), }); diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 57f52d240..2a037d913 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -1,12 +1,55 @@ import { describe, expect, it } from "vitest"; import { + AgentForkContextRequestMessageSchema, + AgentForkContextResponseMessageSchema, CreateAgentRequestMessageSchema, CreatePaseoWorktreeRequestSchema, SendAgentMessageRequestSchema, } from "./messages.js"; describe("shared messages attachments", () => { + it("preserves an optional timeline cursor on fork-context messages", () => { + const boundaryCursor = { epoch: "timeline-1", seq: 42 }; + const request = AgentForkContextRequestMessageSchema.parse({ + type: "agent.fork_context.request", + agentId: "agent-1", + requestId: "fork-1", + boundaryCursor, + }); + const response = AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: null, + boundaryCursor, + error: null, + }, + }); + + expect(request.boundaryCursor).toEqual(boundaryCursor); + expect(response.payload.boundaryCursor).toEqual(boundaryCursor); + }); + + it("accepts a legacy fork-context response without a timeline cursor", () => { + expect( + AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: "assistant-1", + error: null, + }, + }).payload.boundaryCursor, + ).toBeUndefined(); + }); + it("keeps valid review attachments", () => { const parsed = SendAgentMessageRequestSchema.parse({ type: "send_agent_message_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1718e1117..965b95aec 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1321,6 +1321,7 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({ export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), + boundaryCursor: AgentTimelineCursorSchema.optional(), boundaryMessageId: z.string().optional(), requestId: z.string(), }); @@ -2429,6 +2430,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -3129,6 +3132,7 @@ export const AgentForkContextResponseMessageSchema = z.object({ attachment: TextAttachmentSchema.nullable(), itemCount: z.number().int().nonnegative(), boundaryMessageId: z.string().nullable(), + boundaryCursor: AgentTimelineCursorSchema.nullable().optional(), error: z.string().nullable(), }), }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 75ae75fe7..2f76fe635 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -315,6 +315,8 @@ second line'`, contextKind: "chat_history", title: "Chat history", }); + expect(result.attachment.text).toMatch(/^\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); expect(result.attachment.text).toContain("Source agent: Source Agent"); expect(result.attachment.text).toContain("Source directory: /repo"); expect(result.attachment.text).toContain("[User] Ship the thing"); @@ -394,6 +396,41 @@ second line'`, expect(result.attachment.text).not.toContain("after boundary"); }); + it("selects a synthetic assistant error by its timeline cursor", () => { + const result = buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-1", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [ + row(1, { type: "user_message", text: "Try the task", messageId: "user-1" }), + row(2, { type: "assistant_message", text: "[System Error] provider failed" }), + row(3, { + type: "assistant_message", + text: "This belongs to a later turn.", + messageId: "assistant-2", + }), + ], + }); + + expect(result.boundaryCursor).toEqual({ epoch: "timeline-1", seq: 2 }); + expect(result.boundaryMessageId).toBeNull(); + expect(result.attachment.text).toContain("[System Error] provider failed"); + expect(result.attachment.text).not.toContain("This belongs to a later turn."); + }); + + it("rejects a cursor from a previous timeline epoch", () => { + expect(() => + buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-2", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [row(2, { type: "assistant_message", text: "Stale result." })], + }), + ).toThrow("Selected timeline position is no longer available."); + }); + it("rejects missing assistant boundaries instead of silently using the wrong context", () => { expect(() => buildAgentForkContextAttachment({ diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 359fb4280..da3318c1a 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -216,30 +216,55 @@ export function curateAgentActivity( : "No activity to display."; } +interface ForkCursorBoundary { + timelineEpoch: string; + cursor: { epoch: string; seq: number }; +} + function selectForkContextRows(input: { rows: readonly AgentTimelineRow[]; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; -}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } { +}): { + items: AgentTimelineItem[]; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { + const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; - if (!boundaryMessageId) { + if (!boundaryCursor && !boundaryMessageId) { const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor: null, boundaryMessageId: null, }; } - const boundaryIndex = input.rows.findLastIndex( - (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, - ); + if ( + input.cursorBoundary && + input.cursorBoundary.cursor.epoch !== input.cursorBoundary.timelineEpoch + ) { + throw new Error("Selected timeline position is no longer available."); + } + const boundaryIndex = boundaryCursor + ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) + : input.rows.findLastIndex( + (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, + ); if (boundaryIndex < 0) { - throw new Error("Selected assistant message is no longer available."); + throw new Error( + boundaryCursor + ? "Selected timeline position is no longer available." + : "Selected assistant message is no longer available.", + ); } const selectedRows = input.rows.slice(0, boundaryIndex + 1); const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor, boundaryMessageId, }; } @@ -263,17 +288,24 @@ function buildForkContextText(input: { if (cwd) { header.push(`Source directory: ${cwd}`); } - return `${header.join("\n")}\n\n${input.body}`; + return `\n${header.join("\n")}\n\n${input.body}\n`; } export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; agentTitle?: string | null; cwd?: string | null; -}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } { +}): { + attachment: TextAgentAttachment; + itemCount: number; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { const selected = selectForkContextRows({ rows: input.rows, + cursorBoundary: input.cursorBoundary, boundaryMessageId: input.boundaryMessageId, }); const entries = curateProjectedActivityEntries(selected.items, { @@ -299,6 +331,7 @@ export function buildAgentForkContextAttachment(input: { }), }, itemCount: selected.items.length, + boundaryCursor: selected.boundaryCursor, boundaryMessageId: selected.boundaryMessageId, }; } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 60c4d2df6..7954ec1d4 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -13,16 +13,12 @@ import type { } from "../../worktree-session.js"; import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; -import type { - AgentPromptContentBlock, - AgentPromptInput, - AgentRunOptions, - AgentSessionConfig, -} from "../agent-sdk-types.js"; +import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; +import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; import { @@ -486,30 +482,6 @@ async function sendInitialPrompt( } } -function buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], -): AgentPromptInput { - const normalized = text.trim(); - const hasImages = (images?.length ?? 0) > 0; - const hasAttachments = (attachments?.length ?? 0) > 0; - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; -} - function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent { const parentAgent = agentManager.getAgent(parentAgentId); if (!parentAgent) { diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index d187415c0..42820c9ea 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it } from "vitest"; -import { buildAgentBranchNameSeed, renderPromptAttachmentAsText } from "./prompt-attachments.js"; +import { + buildAgentBranchNameSeed, + buildAgentPrompt, + renderPromptAttachmentAsText, +} from "./prompt-attachments.js"; describe("prompt attachments", () => { + it("places fork history before the new user prompt", () => { + const chatHistory = { + type: "text" as const, + mimeType: "text/plain", + contextKind: "chat_history" as const, + title: "Chat history", + text: "\nPrevious work\n", + }; + const issue = { + type: "github_issue" as const, + mimeType: "application/github-issue", + number: 55, + title: "Issue", + url: "https://github.com/getpaseo/paseo/issues/55", + }; + + expect( + buildAgentPrompt( + " Take a different approach ", + [{ data: "image-data", mimeType: "image/png" }], + [issue, chatHistory], + ), + ).toEqual([ + chatHistory, + { type: "text", text: "Take a different approach" }, + { type: "image", data: "image-data", mimeType: "image/png" }, + issue, + ]); + }); + it("renders github_pr attachments as readable text", () => { expect( renderPromptAttachmentAsText({ diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index 129018f2f..c1dba8276 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -1,7 +1,41 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { AgentPromptContentBlock, AgentPromptInput } from "./agent-sdk-types.js"; const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const; +export function buildAgentPrompt( + text: string, + images?: Array<{ data: string; mimeType: string }>, + attachments?: AgentAttachment[], +): AgentPromptInput { + const normalized = text.trim(); + const hasImages = (images?.length ?? 0) > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!hasImages && !hasAttachments) { + return normalized; + } + + const chatHistoryAttachments: AgentAttachment[] = []; + const otherAttachments: AgentAttachment[] = []; + for (const attachment of attachments ?? []) { + if (attachment.type === "text" && attachment.contextKind === "chat_history") { + chatHistoryAttachments.push(attachment); + } else { + otherAttachments.push(attachment); + } + } + + const blocks: AgentPromptContentBlock[] = [...chatHistoryAttachments]; + if (normalized.length > 0) { + blocks.push({ type: "text", text: normalized }); + } + for (const image of images ?? []) { + blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); + } + blocks.push(...otherAttachments); + return blocks; +} + export function renderPromptAttachmentAsText(attachment: AgentAttachment): string { switch (attachment.type) { case "github_pr": { diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 51763d336..98d904076 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -179,6 +179,38 @@ describe("MockLoadTestAgentClient", () => { expect(events).toHaveLength(eventCountAfterInterrupt); }); + test("emits a terminal failure without an assistant provider message", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit a synthetic turn failure."); + await vi.advanceTimersByTimeAsync(0); + unsubscribe(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "user_message" }), + }), + ); + expect( + events.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toHaveLength(0); + expect(events.at(-1)).toMatchObject({ + type: "turn_failed", + error: "Requested mock provider failure", + }); + }); + test("emits the free-write question scenario selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); 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 1609fe4a2..d371ef1ac 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 @@ -152,6 +152,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } +function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean { + return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt)); +} + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -652,7 +656,9 @@ export class MockLoadTestAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(prompt); const questionPrompt = parseMockQuestionPrompt(prompt); const structuredBranchName = parseStructuredBranchNamePrompt(prompt); - if (structuredBranchName) { + if (shouldEmitTurnFailure(prompt)) { + this.scheduleFailedTurn(turn); + } else if (structuredBranchName) { this.scheduleStructuredJsonTurn(turn, structuredBranchName); } else if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); @@ -816,6 +822,34 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleFailedTurn(turn: ActiveTurn): void { + turn.timer = setTimeout(() => { + if (this.activeTurn !== turn) { + return; + } + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + this.activeTurn = null; + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId: turn.turnId, + error: "Requested mock provider failure", + }); + turn.resolve({ + sessionId: this.id, + finalText: "", + timeline: [], + canceled: false, + }); + }, 0); + turn.timer.unref?.(); + } + private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { turn.timer = setTimeout(() => { this.emitStressTurn(turn, stress); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 69df4cd15..48e44ab66 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -100,13 +100,12 @@ import { type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; +import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, type AgentPersistenceHandle, type AgentPermissionResponse, - type AgentPromptContentBlock, - type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, } from "./agent/agent-sdk-types.js"; @@ -1027,33 +1026,6 @@ export class Session { // No unsolicited agent list hydration. Callers must use fetch_agents_request. } - /** - * Normalize a user prompt (with optional image metadata) for AgentManager - */ - private buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], - ): AgentPromptInput { - const normalized = text?.trim() ?? ""; - const hasImages = Boolean(images && images.length > 0); - const hasAttachments = Boolean(attachments && attachments.length > 0); - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; - } - /** * Interrupt the agent's active run so the next prompt starts a fresh turn. * Returns once the manager confirms the stream has been cancelled. @@ -2439,7 +2411,7 @@ export class Session { ); const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; - const prompt = this.buildAgentPrompt(promptText, images, attachments); + const prompt = buildAgentPrompt(promptText, images, attachments); try { await sendPromptToAgent({ @@ -5522,12 +5494,15 @@ export class Session { logger: this.sessionLogger, }); const agentPayload = await this.buildAgentPayload(snapshot); - const rows = this.agentManager.fetchTimeline(msg.agentId, { + const timeline = this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0, - }).rows; + }); const forkContext = buildAgentForkContextAttachment({ - rows, + rows: timeline.rows, + cursorBoundary: msg.boundaryCursor + ? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor } + : null, boundaryMessageId: msg.boundaryMessageId, agentTitle: agentPayload.title, cwd: snapshot.cwd, @@ -5540,6 +5515,7 @@ export class Session { agentId: msg.agentId, attachment: forkContext.attachment, itemCount: forkContext.itemCount, + boundaryCursor: forkContext.boundaryCursor, boundaryMessageId: forkContext.boundaryMessageId, error: null, }, @@ -5556,6 +5532,7 @@ export class Session { agentId: msg.agentId, attachment: null, itemCount: 0, + boundaryCursor: msg.boundaryCursor ?? null, boundaryMessageId: msg.boundaryMessageId ?? null, error: error instanceof Error ? error.message : String(error), }, @@ -5583,7 +5560,7 @@ export class Session { try { const agentId = resolved.agentId; - const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments); + const prompt = buildAgentPrompt(msg.text, msg.images, msg.attachments); this.sessionLogger.trace( { agentId, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 05ce7e886..ff371979a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1260,6 +1260,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.