mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>(() => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
boundaryCursor?: { epoch: string; seq: number } | null;
|
||||
itemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user