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
This commit is contained in:
Mohamed Boudra
2026-07-15 16:30:42 +02:00
committed by GitHub
parent f06792ae89
commit 04e893417e
23 changed files with 549 additions and 108 deletions

View File

@@ -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.

View File

@@ -54,6 +54,28 @@ async function expectChatHistoryPill(page: Page): Promise<void> {
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,

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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> | 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}
/>
</TurnFooterRow>
@@ -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 (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
forkBoundaryMessageId={boundaryMessageId}
onFork={onForkAssistantTurn}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
/>
</View>
);

View File

@@ -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<AgentStreamViewHandle, AgentStreamVi
!readOnly &&
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
);
const supportsAgentForkContextCursor = useSessionStore(
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
@@ -466,7 +473,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
async ({ target, boundaryMessageId }) => {
async ({ target, boundary }) => {
try {
if (!supportsAgentForkContext) {
toast?.error(t("message.actions.forkUnavailable"));
@@ -478,10 +485,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const draftSetup = buildForkDraftSetup(context);
const prepareForkDraft = async () => {
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<AgentStreamViewHandle, AgentStreamVi
content,
layoutItem,
strategy: streamRenderStrategy,
supportsTimelineCursor: supportsAgentForkContextCursor,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
[
handleForkAssistantTurn,
readOnly,
renderStreamItemContent,
streamRenderStrategy,
supportsAgentForkContextCursor,
],
);
const pendingPermissionItems = useMemo(
@@ -882,6 +893,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
supportsTimelineCursor={supportsAgentForkContextCursor}
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
/>
) : null,
@@ -892,6 +904,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
streamRenderStrategy,
supportsAgentForkContextCursor,
],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {

View File

@@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment {
serverId: string;
agentId: string;
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
};
}

View File

@@ -564,11 +564,7 @@ interface AssistantTurnFooterProps {
getContent: () => string;
completedAt?: Date;
durationMs?: number;
forkBoundaryMessageId?: string;
onFork?: (input: {
target: AssistantForkTarget;
boundaryMessageId?: string;
}) => Promise<void> | void;
onFork?: (target: AssistantForkTarget) => Promise<void> | 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 (
<View style={assistantTurnFooterStylesheet.container}>

View File

@@ -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",

View File

@@ -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<StreamItem[]>(
(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,

View File

@@ -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<string>;
timelineCursor?: TimelinePosition;
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -385,6 +392,7 @@ function appendAssistantMessage(
source: StreamUpdateSource,
messageId?: string,
reservedItemIds?: ReadonlySet<string>,
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<string>,
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<StreamItem[]>((state, { event, timestamp }) => {
return reduceStreamUpdate(state, event, timestamp, options);
const hydrated = events.reduce<StreamItem[]>((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;

View File

@@ -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 } : {}),
});

View File

@@ -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",

View File

@@ -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(),
}),
});

View File

@@ -315,6 +315,8 @@ second line'`,
contextKind: "chat_history",
title: "Chat history",
});
expect(result.attachment.text).toMatch(/^<chat-history-summary>\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({

View File

@@ -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 `<chat-history-summary>\n${header.join("\n")}\n\n${input.body}\n</chat-history-summary>`;
}
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,
};
}

View File

@@ -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) {

View File

@@ -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: "<chat-history-summary>\nPrevious work\n</chat-history-summary>",
};
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({

View File

@@ -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": {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,

View File

@@ -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.