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