Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 744878bf62 refactor(agents): name finish notification options 2026-10-13 13:26:03 +02:00
paseo-bot[bot] e1ce819d11 fix(agents): retain complete failure outcomes through delivery
Capture the failure reason when the terminal notification is queued, preserve provider diagnostics at their producer, and apply the existing notification text budget. Failed responses also keep their completion timing when their footer is anchored to error activity.
2026-10-13 13:20:08 +02:00
paseo-bot[bot] 6d7aaf5461 test(app): distinguish timeline errors from submission failures
A rejected prompt displays its reason in both the submission status and the timeline. Scope the timeline assertions to the notification row.
2026-10-13 13:05:09 +02:00
paseo-bot[bot] 9e596e3377 fix(agents): preserve failure reasons and failed-turn forking
Typed failure activity no longer appears in assistant replies. Attach the response footer to the error cursor and read lastError for failed schedules and child notifications so those consumers retain their behavior.
2026-10-13 12:59:06 +02:00
paseo-bot[bot] e5efa7324d test(server): expect failure activity during agent creation
Verify fetched and live creation timelines use the typed error item when an initial provider prompt is rejected.
2026-10-13 05:03:13 +02:00
paseo-bot[bot]andAndonis 303ee418cc test(server): verify failure activity on current timeline paths
Keep daemon failures separate from preceding assistant output and verify live error delivery, including assistant text that resembles the legacy error marker. Update startup failure and successful session-replacement assertions for typed errors.

Co-authored-by: Andonis <qq3290949433@gmail.com>
2026-10-13 04:57:22 +02:00
paseo-bot[bot] b282bbb944 Merge remote-tracking branch 'origin/main' into bot/fix-4262-131bae8c 2026-10-13 04:53:51 +02:00
Andonis 3e977b5e7b fix(server): keep system errors separate from replies 2026-09-03 15:56:42 +08:00
22 changed files with 419 additions and 151 deletions
@@ -303,10 +303,12 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise<void
} }
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) { async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message")); const messages = page
.getByTestId("user-message")
.or(page.getByTestId("timeline-notification").filter({ hasText: errorMessage }));
await expect(messages).toHaveCount(2); await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt); await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`); await expect(messages.nth(1)).toHaveText(errorMessage);
} }
async function configureSteerInSettings(page: Page): Promise<void> { async function configureSteerInSettings(page: Page): Promise<void> {
@@ -51,7 +51,7 @@ test.describe("Assistant fork menu", () => {
await openAgentRoute(page, session); await openAgentRoute(page, session);
await expectComposerVisible(page); await expectComposerVisible(page);
await submitMessage(page, "Emit a synthetic turn failure."); await submitMessage(page, "Emit a synthetic turn failure.");
await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({ await expect(page.getByText("Requested mock provider failure")).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
+10 -8
View File
@@ -186,8 +186,9 @@ export async function createCreationScenario(page: Page) {
await requests.settled(); await requests.settled();
await expect( await expect(
page page
.getByTestId("assistant-message") .getByTestId("timeline-notification")
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }), .filter({ hasText: "Requested mock prompt rejection" })
.filter({ visible: true }),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page page
@@ -203,11 +204,15 @@ export async function createCreationScenario(page: Page) {
async expectPromptBeforeError(prompt: string) { async expectPromptBeforeError(prompt: string) {
const messages = page const messages = page
.getByTestId("user-message") .getByTestId("user-message")
.or(page.getByTestId("assistant-message")) .or(
page
.getByTestId("timeline-notification")
.filter({ hasText: "Requested mock prompt rejection" }),
)
.filter({ visible: true }); .filter({ visible: true });
await expect(messages).toHaveCount(2); await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt); await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText("[System Error]"); await expect(messages.nth(1)).toHaveText("Requested mock prompt rejection");
await expect(messages.nth(1)).toBeInViewport(); await expect(messages.nth(1)).toBeInViewport();
}, },
async reloadAgent() { async reloadAgent() {
@@ -217,10 +222,7 @@ export async function createCreationScenario(page: Page) {
}, },
async expectAssistantReply() { async expectAssistantReply() {
await expect( await expect(
page page.getByTestId("assistant-message").filter({ visible: true }).first(),
.getByTestId("assistant-message")
.filter({ visible: true, hasNotText: "[System Error]" })
.first(),
).toBeVisible(); ).toBeVisible();
}, },
async expectStartupFailure() { async expectStartupFailure() {
@@ -82,7 +82,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
isTurnActive: input.isTurnActive, isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]), history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [], liveHead: [],
timingByAssistantId: timingFor(assistant.id), timingByResponseItemId: timingFor(assistant.id),
}), }),
}; };
} }
@@ -126,7 +126,7 @@ describe("turn footer spacing", () => {
isTurnActive, isTurnActive,
history: strategy.orderTail([userMessage("user"), first]), history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]), liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id), timingByResponseItemId: timingFor(last.id),
}); });
const firstLayout = layout.history.find((item) => item.item.id === first.id); const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id); const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
+32 -3
View File
@@ -105,7 +105,7 @@ function layoutFor(input: {
strategy, strategy,
streamHead: input.head ?? [], streamHead: input.head ?? [],
}), }),
timingByAssistantId: timingFor(...(input.timingIds ?? [])), timingByResponseItemId: timingFor(...(input.timingIds ?? [])),
}); });
} }
@@ -149,6 +149,35 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
} }
describe("layoutStream", () => { describe("layoutStream", () => {
it.each(["web", "android"] as const)(
"hosts failed response footers on the error row on %s",
(platform) => {
const error: StreamItem = {
kind: "notification",
sourceType: "error",
level: "error",
id: "error-1",
message: "Provider failed",
timestamp: timestamp(2),
timelineCursor: { epoch: "timeline-1", seq: 42 },
};
const user = userMessage("user-1", 1);
const latest = layoutFor({ platform, tail: [user, error] });
expect(latest.auxiliaryTurnFooter?.itemId).toBe(error.id);
const completed = layoutFor({
platform,
tail: [user, error, userMessage("user-2", 3)],
isTurnActive: true,
});
expect(findLayoutItem(completed, error.id).completedFooter?.itemId).toBe(error.id);
const afterReply = layoutFor({
platform,
tail: [user, assistantMessage("assistant-1", 2), error],
});
expect(afterReply.auxiliaryTurnFooter?.itemId).toBe(error.id);
},
);
it("places one response footer after an adjacent tagged tool-only turn", () => { it("places one response footer after an adjacent tagged tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2"); const nextTool = toolCall("next-tool", 2, "turn-2");
@@ -172,7 +201,7 @@ describe("layoutStream", () => {
isTurnActive: true, isTurnActive: true,
history, history,
liveHead, liveHead,
timingByAssistantId: timingFor(priorAssistant.id), timingByResponseItemId: timingFor(priorAssistant.id),
}); });
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -181,7 +210,7 @@ describe("layoutStream", () => {
isTurnActive: true, isTurnActive: true,
history, history,
liveHead, liveHead,
timingByAssistantId: timingFor(priorAssistant.id), timingByResponseItemId: timingFor(priorAssistant.id),
}); });
expect(footerOwners(first)).toEqual([]); expect(footerOwners(first)).toEqual([]);
+28 -25
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean; isTurnActive: boolean;
history: StreamItem[]; history: StreamItem[];
liveHead: StreamItem[]; liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>; timingByResponseItemId: Map<string, TurnTiming>;
} }
interface LayoutSegmentInput { interface LayoutSegmentInput {
strategy: StreamStrategy; strategy: StreamStrategy;
items: StreamItem[]; items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>; timingByResponseItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean; hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder; frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete"; phase: "streaming" | "complete";
} }
interface AssistantFooterSource { interface ResponseFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>; item: Extract<StreamItem, { kind: "assistant_message" | "notification" }>;
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
} }
@@ -67,23 +67,23 @@ function createTurnFooterHost(input: {
item: StreamItem; item: StreamItem;
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
timingByAssistantId: Map<string, TurnTiming>; timingByResponseItemId: Map<string, TurnTiming>;
}): TurnFooterHost { }): TurnFooterHost {
return { return {
itemId: input.item.id, itemId: input.item.id,
items: input.items, items: input.items,
timing: input.timingByAssistantId.get(input.item.id), timing: input.timingByResponseItemId.get(input.item.id),
startIndex: input.index, startIndex: input.index,
}; };
} }
function findLatestAssistantInResponse(input: { function findLatestFooterSourceInResponse(input: {
strategy: StreamStrategy; strategy: StreamStrategy;
items: StreamItem[]; items: StreamItem[];
startIndex: number; startIndex: number;
boundaryAboveItems?: StreamItem[] | null; boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null; boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null { }): ResponseFooterSource | null {
let items = input.items; let items = input.items;
let index = input.startIndex; let index = input.startIndex;
let canCrossBoundary = true; let canCrossBoundary = true;
@@ -99,7 +99,10 @@ function findLatestAssistantInResponse(input: {
if (!item || (laterItem && !continuesResponse(item, laterItem))) { if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return null; return null;
} }
if (item.kind === "assistant_message") { if (
item.kind === "assistant_message" ||
(item.kind === "notification" && item.sourceType === "error")
) {
return { item, items, index }; return { item, items, index };
} }
laterItem = item; laterItem = item;
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null; return null;
} }
const assistant = findLatestAssistantInResponse({ const source = findLatestFooterSourceInResponse({
strategy: input.strategy, strategy: input.strategy,
items: footerItems, items: footerItems,
startIndex: latestIndex, startIndex: latestIndex,
}); });
if (!assistant) { if (!source) {
return null; return null;
} }
return createTurnFooterHost({ return createTurnFooterHost({
item: assistant.item, item: source.item,
items: assistant.items, items: source.items,
index: assistant.index, index: source.index,
timingByAssistantId: input.timingByAssistantId, timingByResponseItemId: input.timingByResponseItemId,
}); });
} }
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
index: number; index: number;
item: StreamItem; item: StreamItem;
belowItem: StreamItem | null; belowItem: StreamItem | null;
timingByAssistantId: Map<string, TurnTiming>; timingByResponseItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null; boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null; boundaryAboveIndex: number | null;
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
return null; return null;
} }
const assistant = findLatestAssistantInResponse({ const source = findLatestFooterSourceInResponse({
strategy: input.strategy, strategy: input.strategy,
items: input.items, items: input.items,
startIndex: input.index, startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems, boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex, boundaryAboveIndex: input.boundaryAboveIndex,
}); });
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) { if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
return null; return null;
} }
return createTurnFooterHost({ return createTurnFooterHost({
item: assistant.item, item: source.item,
items: assistant.items, items: source.items,
index: assistant.index, index: source.index,
timingByAssistantId: input.timingByAssistantId, timingByResponseItemId: input.timingByResponseItemId,
}); });
} }
@@ -306,7 +309,7 @@ function layoutSegmentItem(
index, index,
item, item,
belowItem, belowItem,
timingByAssistantId: input.timingByAssistantId, timingByResponseItemId: input.timingByResponseItemId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter, auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems, boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex, boundaryAboveIndex: input.boundaryAboveIndex,
@@ -377,7 +380,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({ history = layoutSegment({
strategy: input.strategy, strategy: input.strategy,
items: input.history, items: input.history,
timingByAssistantId: input.timingByAssistantId, timingByResponseItemId: input.timingByResponseItemId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter, hasAuxiliaryFooter,
frameOrder, frameOrder,
@@ -397,7 +400,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({ const liveHead = layoutSegment({
strategy: input.strategy, strategy: input.strategy,
items: input.liveHead, items: input.liveHead,
timingByAssistantId: input.timingByAssistantId, timingByResponseItemId: input.timingByResponseItemId,
auxiliaryTurnFooter, auxiliaryTurnFooter,
hasAuxiliaryFooter, hasAuxiliaryFooter,
frameOrder, frameOrder,
+6 -6
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2, historyStart: 2,
}); });
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); expect(model.turnTiming.byResponseItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ expect(model.turnTiming.byResponseItemId.get("visible-a")).toEqual({
completedAt: tail[3]?.timestamp, completedAt: tail[3]?.timestamp,
durationMs: 1000, durationMs: 1000,
}); });
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
}); });
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); expect(model.turnTiming.byResponseItemId.has("live-a")).toBe(false);
}); });
it("maps completed turn timing to assistant ids across committed history and live head", () => { it("maps completed turn timing to assistant ids across committed history and live head", () => {
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
}); });
expect(model.turnTiming.runningStartedAt).toBe(null); expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ expect(model.turnTiming.byResponseItemId.get("live-a")).toEqual({
completedAt: head[0]?.timestamp, completedAt: head[0]?.timestamp,
durationMs: 3000, durationMs: 3000,
}); });
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
}); });
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ expect(model.turnTiming.byResponseItemId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp, completedAt: tail[1]?.timestamp,
durationMs: 3000, durationMs: 3000,
}); });
@@ -238,6 +238,6 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false, isMobileBreakpoint: false,
}); });
expect(model.turnTiming.byAssistantId.size).toBe(0); expect(model.turnTiming.byResponseItemId.size).toBe(0);
}); });
}); });
@@ -32,7 +32,12 @@ function assistantMessage(
describe("resolveAssistantTurnForkBoundary", () => { describe("resolveAssistantTurnForkBoundary", () => {
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => { it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
const failedTurn = { const failedTurn = {
...assistantMessage("assistant-error", 2), kind: "notification" as const,
sourceType: "error" as const,
level: "error" as const,
message: "Provider failed",
id: "error-1",
timestamp: timestamp(2),
timelineCursor: { epoch: "timeline-1", seq: 42 }, timelineCursor: { epoch: "timeline-1", seq: 42 },
}; };
@@ -10,6 +10,11 @@ export function resolveAssistantTurnForkBoundary(input: {
supportsTimelineCursor: boolean; supportsTimelineCursor: boolean;
}): AssistantTurnForkBoundary | undefined { }): AssistantTurnForkBoundary | undefined {
const item = input.items[input.startIndex]; const item = input.items[input.startIndex];
if (item?.kind === "notification" && item.sourceType === "error") {
return input.supportsTimelineCursor && item.timelineCursor
? { boundaryCursor: item.timelineCursor }
: undefined;
}
if (item?.kind !== "assistant_message") { if (item?.kind !== "assistant_message") {
return undefined; return undefined;
} }
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
isTurnActive, isTurnActive,
history: model.segments.historyMounted, history: model.segments.historyMounted,
liveHead: model.segments.liveHead, liveHead: model.segments.liveHead,
timingByAssistantId: model.turnTiming.byAssistantId, timingByResponseItemId: model.turnTiming.byResponseItemId,
}), }),
}; };
} }
@@ -123,7 +123,7 @@ describe("canonical turn membership", () => {
const completedItems = [...build(turnId), assistant("done", 9, turnId)]; const completedItems = [...build(turnId), assistant("done", 9, turnId)];
const completed = layoutFor(completedItems, false); const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]); expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ expect(completed.model.turnTiming.byResponseItemId.get("done")).toMatchObject({
completedAt: at(9), completedAt: at(9),
durationMs: 8000, durationMs: 8000,
}); });
+2 -2
View File
@@ -593,12 +593,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive, isTurnActive,
history: baseRenderModel.history, history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead, liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, timingByResponseItemId: baseRenderModel.turnTiming.byResponseItemId,
}), }),
[ [
baseRenderModel.history, baseRenderModel.history,
baseRenderModel.segments.liveHead, baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId, baseRenderModel.turnTiming.byResponseItemId,
isTurnActive, isTurnActive,
streamRenderStrategy, streamRenderStrategy,
], ],
+1 -1
View File
@@ -2161,7 +2161,7 @@ export const Notification = memo(function Notification({
[resolvedDisableOuterSpacing, config.bg], [resolvedDisableOuterSpacing, config.bg],
); );
return ( return (
<View style={containerStyle}> <View style={containerStyle} testID="timeline-notification">
<View style={notificationStylesheet.content}> <View style={notificationStylesheet.content}>
<View style={notificationStylesheet.row}> <View style={notificationStylesheet.row}>
<View style={notificationStylesheet.iconContainer}> <View style={notificationStylesheet.iconContainer}>
+30 -5
View File
@@ -22,6 +22,31 @@ function assistant(id: string, timestamp: Date): StreamItem {
} }
describe("deriveStreamTurnTiming", () => { describe("deriveStreamTurnTiming", () => {
it("retains completed timing for a failed response with or without assistant text", () => {
const userAt = new Date("2026-05-15T00:00:00.000Z");
const failedAt = new Date("2026-05-15T00:00:07.000Z");
const error: StreamItem = {
kind: "notification",
sourceType: "error",
level: "error",
id: "error-1",
message: "Provider failed",
timestamp: failedAt,
};
for (const replies of [[], [assistant("assistant-1", new Date("2026-05-15T00:00:03.000Z"))]]) {
const timing = deriveStreamTurnTiming({
isTurnActive: false,
activeTurnStartedAt: null,
tail: [user("user-1", userAt), ...replies, error],
head: [],
});
assert.deepEqual(timing.byResponseItemId.get(error.id), {
completedAt: failedAt,
durationMs: 7000,
});
}
});
it("starts elapsed time from the submitted prompt", () => { it("starts elapsed time from the submitted prompt", () => {
const submittedAt = new Date("2026-05-15T00:00:00.000Z"); const submittedAt = new Date("2026-05-15T00:00:00.000Z");
@@ -51,7 +76,7 @@ describe("deriveStreamTurnTiming", () => {
}); });
assert.equal(timing.runningStartedAt, secondUserAt); assert.equal(timing.runningStartedAt, secondUserAt);
assert.equal(timing.byAssistantId.has("a2"), false); assert.equal(timing.byResponseItemId.has("a2"), false);
}); });
it("derives completed turn timing from user and assistant item timestamps", () => { it("derives completed turn timing from user and assistant item timestamps", () => {
@@ -69,7 +94,7 @@ describe("deriveStreamTurnTiming", () => {
head: [], head: [],
}); });
assert.deepEqual(timing.byAssistantId.get("a1"), { assert.deepEqual(timing.byResponseItemId.get("a1"), {
completedAt: assistantAt, completedAt: assistantAt,
durationMs: 7000, durationMs: 7000,
}); });
@@ -95,8 +120,8 @@ describe("deriveStreamTurnTiming", () => {
completedAt: lastAssistantAt, completedAt: lastAssistantAt,
durationMs: 7000, durationMs: 7000,
}; };
assert.deepEqual(timing.byAssistantId.get("a1"), expected); assert.deepEqual(timing.byResponseItemId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), expected); assert.deepEqual(timing.byResponseItemId.get("a2"), expected);
}); });
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
@@ -120,7 +145,7 @@ describe("deriveStreamTurnTiming", () => {
head: [], head: [],
}); });
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { assert.deepEqual(timing.byResponseItemId.get("hidden-prompt-a2"), {
completedAt: hiddenPromptTurnAt, completedAt: hiddenPromptTurnAt,
durationMs: null, durationMs: null,
}); });
+13 -10
View File
@@ -7,7 +7,7 @@ export interface TurnTiming {
} }
export interface StreamTurnTiming { export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>; byResponseItemId: Map<string, TurnTiming>;
runningStartedAt: Date | null; runningStartedAt: Date | null;
} }
@@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: {
tail: StreamItem[]; tail: StreamItem[];
head: StreamItem[]; head: StreamItem[];
}): StreamTurnTiming { }): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>(); const byResponseItemId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null; let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null; let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = []; let currentResponseItemIds: string[] = [];
let previousItem: StreamItem | null = null; let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => { const flushCompletedTurn = () => {
if (!currentLastItemAt || currentAssistantIds.length === 0) { if (!currentLastItemAt || currentResponseItemIds.length === 0) {
return; return;
} }
const timing: TurnTiming = { const timing: TurnTiming = {
@@ -33,8 +33,8 @@ export function deriveStreamTurnTiming(params: {
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()) ? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
: null, : null,
}; };
for (const id of currentAssistantIds) { for (const id of currentResponseItemIds) {
byAssistantId.set(id, timing); byResponseItemId.set(id, timing);
} }
}; };
@@ -43,11 +43,14 @@ export function deriveStreamTurnTiming(params: {
flushCompletedTurn(); flushCompletedTurn();
currentUserAt = item.kind === "user_message" ? item.timestamp : null; currentUserAt = item.kind === "user_message" ? item.timestamp : null;
currentLastItemAt = null; currentLastItemAt = null;
currentAssistantIds = []; currentResponseItemIds = [];
} }
currentLastItemAt = item.timestamp; currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") { if (
currentAssistantIds.push(item.id); item.kind === "assistant_message" ||
(item.kind === "notification" && item.sourceType === "error")
) {
currentResponseItemIds.push(item.id);
} }
previousItem = item; previousItem = item;
}; };
@@ -65,7 +68,7 @@ export function deriveStreamTurnTiming(params: {
} }
return { return {
byAssistantId, byResponseItemId,
runningStartedAt, runningStartedAt,
}; };
} }
@@ -3966,11 +3966,7 @@ test("a prompt after provider replacement reopens the stale session", async () =
expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([ expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([
{ text: "continue after reload", clientMessageId: "stale-session-retry" }, { text: "continue after reload", clientMessageId: "stale-session-retry" },
]); ]);
expect( expect(timeline.some((item) => item.type === "error")).toBe(false);
timeline.some(
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
),
).toBe(false);
} finally { } finally {
await manager.closeAgent(created.id).catch(() => undefined); await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined); await manager.flush().catch(() => undefined);
@@ -7032,47 +7028,78 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
}); });
}); });
test("preserves terminal fallback when no active turn identity was observed", async () => { test.each(["Task completed.", "[System Error] untracked failure"])(
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-")); "preserves terminal fallback and separates untracked failure activity after %s",
const storage = new AgentStorage(join(workdir, "agents"), logger); async (assistantText) => {
let capturedSession: TestAgentSession | null = null; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let capturedSession: TestAgentSession | null = null;
class LiveEventClient extends TestAgentClient { class LiveEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> { override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new TestAgentSession(config); capturedSession = new TestAgentSession(config);
return capturedSession; return capturedSession;
}
} }
}
const manager = new AgentManager({ const manager = new AgentManager({
clients: { codex: new LiveEventClient() }, clients: { codex: new LiveEventClient() },
registry: storage, registry: storage,
logger, logger,
idFactory: () => "00000000-0000-4000-8000-000000000136", idFactory: () => "00000000-0000-4000-8000-000000000136",
}); });
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined, workspaceId: undefined,
}); });
const events: AgentManagerEvent[] = [];
capturedSession!.pushEvent({ manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
type: "turn_failed", await manager.appendTimelineItem(snapshot.id, {
provider: "codex",
turnId: "untracked-turn",
error: "untracked failure",
});
await vi.waitFor(() => {
const failed = manager.getAgent(snapshot.id);
expect(failed?.lifecycle).toBe("error");
expect(failed?.lastError).toBe("untracked failure");
});
expect(manager.getTimeline(snapshot.id)).toContainEqual(
expect.objectContaining({
type: "assistant_message", type: "assistant_message",
text: expect.stringContaining("untracked failure"), text: assistantText,
}), messageId: "assistant-1",
); });
});
capturedSession!.pushEvent({
type: "turn_failed",
provider: "codex",
turnId: "untracked-turn",
error: "untracked failure",
});
await vi.waitFor(() => {
const failed = manager.getAgent(snapshot.id);
expect(failed?.lifecycle).toBe("error");
expect(failed?.lastError).toBe("untracked failure");
});
expect(manager.getTimeline(snapshot.id)).toContainEqual(
expect.objectContaining({
type: "error",
message: expect.stringContaining("untracked failure"),
}),
);
const projected = projectTimelineRows({
rows: await manager.getTimelineRows(snapshot.id),
mode: "projected",
});
expect(projected.map((entry) => entry.item)).toEqual([
{
type: "assistant_message",
text: assistantText,
messageId: "assistant-1",
},
{ type: "error", message: "untracked failure" },
]);
expect(events).toContainEqual(
expect.objectContaining({
type: "agent_stream",
event: expect.objectContaining({
type: "timeline",
item: { type: "error", message: "untracked failure" },
}),
}),
);
},
);
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => { test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
@@ -8357,7 +8384,7 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist
clientMessageId: "rejected-prompt", clientMessageId: "rejected-prompt",
messageId: "rejected-prompt", messageId: "rejected-prompt",
}, },
{ type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" }, { type: "error", message: "Invalid request: missing field `text`" },
]); ]);
await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual( await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual(
@@ -9184,7 +9211,7 @@ test("archiveAgent cascade surfaces partial child archive failures", async () =>
); );
}); });
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => { test("turn_failed emits error activity and keeps error lifecycle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
@@ -9252,12 +9279,9 @@ test("turn_failed emits a system error assistant timeline message and keeps erro
const systemErrors = manager const systemErrors = manager
.getTimeline(agent.id) .getTimeline(agent.id)
.filter( .filter((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message" && item.text.includes("[System Error]"),
);
expect(systemErrors).toHaveLength(1); expect(systemErrors).toHaveLength(1);
expect(systemErrors[0]?.text).toContain("invalid model id"); expect(systemErrors[0]?.message).toContain("invalid model id");
}); });
test("turn_failed surfaces provider code and diagnostic in system error message", async () => { test("turn_failed surfaces provider code and diagnostic in system error message", async () => {
@@ -9324,17 +9348,16 @@ test("turn_failed surfaces provider code and diagnostic in system error message"
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed"); await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed");
expect(manager.getAgent(agent.id)?.lastError).toBe("Provider execution failed"); expect(manager.getAgent(agent.id)?.lastError).toBe(
"Provider execution failed\n\ncode: 126\n\nNo preset version installed for command claude",
);
const systemError = manager const systemError = manager
.getTimeline(agent.id) .getTimeline(agent.id)
.find( .find((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> => expect(systemError?.message).toContain("Provider execution failed");
item.type === "assistant_message" && item.text.includes("[System Error]"), expect(systemError?.message).toContain("code: 126");
); expect(systemError?.message).toContain("No preset version installed for command claude");
expect(systemError?.text).toContain("Provider execution failed");
expect(systemError?.text).toContain("code: 126");
expect(systemError?.text).toContain("No preset version installed for command claude");
}); });
test("permission request notifies once without forcing unread attention state", async () => { test("permission request notifies once without forcing unread attention state", async () => {
@@ -551,8 +551,6 @@ interface AgentMetadataPatch {
labels?: AgentLabelPatch; labels?: AgentLabelPatch;
} }
const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd( function attachPersistenceCwd(
handle: AgentPersistenceHandle | null, handle: AgentPersistenceHandle | null,
cwd: string, cwd: string,
@@ -4633,13 +4631,8 @@ export class AgentManager {
if (!isForegroundEvent && !agent.activeForegroundTurnId) { if (!isForegroundEvent && !agent.activeForegroundTurnId) {
agent.lifecycle = "error"; agent.lifecycle = "error";
} }
agent.lastError = event.error; agent.lastError = this.formatTurnFailedMessage(event);
await this.appendSystemErrorTimelineMessage( await this.appendSystemErrorTimelineMessage(agent, event.provider, agent.lastError, options);
agent,
event.provider,
this.formatTurnFailedMessage(event),
options,
);
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed"); this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
if (!isForegroundEvent && !agent.activeForegroundTurnId) { if (!isForegroundEvent && !agent.activeForegroundTurnId) {
this.emitState(agent); this.emitState(agent);
@@ -4883,13 +4876,12 @@ export class AgentManager {
return; return;
} }
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
const lastItem = await this.getLastItemFromStores(agent.id); const lastItem = await this.getLastItemFromStores(agent.id);
if (lastItem?.type === "assistant_message" && lastItem.text === text) { if (lastItem?.type === "error" && lastItem.message === normalized) {
return; return;
} }
const item: AgentTimelineItem = { type: "assistant_message", text }; const item: AgentTimelineItem = { type: "error", message: normalized };
const row = this.recordTimeline(agent.id, item); const row = this.recordTimeline(agent.id, item);
this.dispatchStream( this.dispatchStream(
agent.id, agent.id,
@@ -15,6 +15,7 @@ import { join } from "node:path";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js"; import { AgentManager } from "./agent-manager.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { AgentStorage } from "./agent-storage.js"; import { AgentStorage } from "./agent-storage.js";
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
@@ -861,3 +862,101 @@ test("a human pasting an agent envelope stays a human message through provider r
clientMessageId: "human-submission", clientMessageId: "human-submission",
}); });
}); });
test.each([
{
error: "Requested fake provider failure",
code: "126",
diagnostic: "Provider executable failed",
closeChild: true,
},
{ error: "long failure " + "x".repeat(5000), closeChild: false },
])(
"errored finish notifications preserve a bounded failure while delivery waits ($closeChild)",
async ({ closeChild, ...failure }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-finish-error-"));
const logger = createTestLogger();
let releaseRead!: () => void;
let signalRead!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseRead = resolve;
});
const readStarted = new Promise<void>((resolve) => {
signalRead = resolve;
});
class HoldingStorage extends AgentStorage {
blockedAgentId: string | null = null;
override async get(id: string) {
if (id === this.blockedAgentId) {
signalRead();
await blocked;
}
return super.get(id);
}
}
const storage = new HoldingStorage(join(workdir, "agents"), logger);
await storage.initialize();
const prompts: string[] = [];
const manager = new AgentManager({
logger,
registry: storage,
clients: createTestAgentClients({
turnFailure: failure,
onStartTurn(prompt) {
prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
},
}),
});
try {
const parent = await manager.createAgent(
{ provider: "claude", cwd: workdir, modeId: "bypassPermissions" },
undefined,
{ workspaceId: undefined },
);
const child = await manager.createAgent(
{ provider: "claude", cwd: workdir, modeId: "bypassPermissions" },
undefined,
{ workspaceId: undefined },
);
await manager.runAgent(child.id, "Earlier successful request");
const earlierReply = await manager.getLastAssistantMessage(child.id);
expect(earlierReply).toBeTruthy();
storage.blockedAgentId = parent.id;
setupFinishNotification({
agentManager: manager,
agentStorage: storage,
childAgentId: child.id,
callerAgentId: parent.id,
logger,
});
await expect(manager.runAgent(child.id, "Emit a turn failure")).rejects.toThrow(
failure.error,
);
await readStarted;
if (closeChild) await manager.closeAgent(child.id);
releaseRead();
const fullReason = [
failure.error,
...(failure.code ? [`code: ${failure.code}`] : []),
...(failure.diagnostic ? [failure.diagnostic] : []),
].join("\n\n");
const expectedReason =
fullReason.length > 4000
? `${fullReason.slice(0, 4000)}\n[truncated ${fullReason.length - 4000} chars; use get_agent_activity for the full response]`
: fullReason;
await vi.waitFor(() =>
expect(
parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text,
).toContain(`<agent-error>\n${expectedReason}\n</agent-error>`),
);
expect(
parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text,
).not.toContain("<agent-response>");
if (!closeChild) await manager.closeAgent(child.id);
await manager.closeAgent(parent.id);
} finally {
releaseRead();
rmSync(workdir, { recursive: true, force: true });
}
},
);
@@ -430,9 +430,17 @@ interface FinishNotificationBodyInput {
title: string; title: string;
reason: FinishNotificationReason; reason: FinishNotificationReason;
lastAssistantMessage: string | null; lastAssistantMessage: string | null;
lastError?: string;
permissionRequest?: AgentPermissionRequest; permissionRequest?: AgentPermissionRequest;
} }
function limitFinishNotificationMessage(message: string): string {
const text = message.trim();
if (text.length <= FINISH_NOTIFICATION_MESSAGE_LIMIT) return text;
const omitted = text.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
return `${text.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
}
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const sections = [statusLine]; const sections = [statusLine];
@@ -450,18 +458,21 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
)}\n</permission-request>`, )}\n</permission-request>`,
); );
} }
let lastAssistantMessage = params.lastAssistantMessage?.trim(); if (params.reason === "errored" && params.lastError) {
if (lastAssistantMessage) { sections.push(
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { `<agent-error>\n${limitFinishNotificationMessage(params.lastError)}\n</agent-error>`,
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; );
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; }
} if (params.lastAssistantMessage?.trim()) {
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`); sections.push(
`<agent-response>\n${limitFinishNotificationMessage(params.lastAssistantMessage)}\n</agent-response>`,
);
} }
return sections.join("\n\n"); return sections.join("\n\n");
} }
interface NotifySafelyOptions { interface NotifySafelyOptions {
lastError?: string;
terminal?: boolean; terminal?: boolean;
permissionRequest?: AgentPermissionRequest; permissionRequest?: AgentPermissionRequest;
} }
@@ -505,7 +516,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
async function notify( async function notify(
reason: FinishNotificationReason, reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest, { permissionRequest, lastError }: Pick<NotifySafelyOptions, "permissionRequest" | "lastError">,
): Promise<void> { ): Promise<void> {
const callerRecord = await agentStorage.get(callerAgentId); const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) { if (callerRecord?.archivedAt) {
@@ -517,12 +528,14 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return; return;
} }
const title = record?.title ?? childAgentId; const title = record?.title ?? childAgentId;
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId); const lastAssistantMessage =
reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({ const body = formatFinishNotificationBody({
childAgentId, childAgentId,
title, title,
reason, reason,
lastAssistantMessage, lastAssistantMessage,
lastError,
permissionRequest, permissionRequest,
}); });
@@ -546,7 +559,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
if (stopped) return; if (stopped) return;
if (options.terminal ?? true) stop(); if (options.terminal ?? true) stop();
notificationQueue = notificationQueue notificationQueue = notificationQueue
.then(() => notify(reason, options.permissionRequest)) .then(() => notify(reason, options))
.catch((error) => { .catch((error) => {
logger.error( logger.error(
{ err: error, childAgentId, callerAgentId, reason }, { err: error, childAgentId, callerAgentId, reason },
@@ -572,7 +585,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
// Prior terminal state is not this task's outcome. A failure before turn start // Prior terminal state is not this task's outcome. A failure before turn start
// is reported by the dispatch acknowledgement instead of a finish callback. // is reported by the dispatch acknowledgement instead of a finish callback.
if (agent.lifecycle === "error" && hasSeenTurn) { if (agent.lifecycle === "error" && hasSeenTurn) {
notifySafely("errored"); notifySafely("errored", { lastError: agent.lastError });
return; return;
} }
// Idle is published before the terminal stream event for both completion // Idle is published before the terminal stream event for both completion
@@ -374,7 +374,7 @@ test.each(["agent", "workspace"] as const)(
clientMessageId: agent.clientMessageId, clientMessageId: agent.clientMessageId,
messageId: agent.clientMessageId, messageId: agent.clientMessageId,
}, },
{ type: "assistant_message", text: `[System Error] ${rejection}` }, { type: "error", message: rejection },
]; ];
expect( expect(
(await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item),
@@ -1776,6 +1776,57 @@ describe("ScheduleService", () => {
expect(storedAgent?.archivedAt).toBeTruthy(); expect(storedAgent?.archivedAt).toBeTruthy();
}); });
test.each(["new-agent", "agent"] as const)(
"scheduled %s failures preserve the provider reason",
async (targetType) => {
const manager = new AgentManager({
logger: createTestLogger(),
clients: createTestAgentClients({
turnFailure: {
error: "Requested fake provider failure",
code: "126",
diagnostic: "Provider executable failed",
},
}),
registry: agentStorage,
});
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const agent = await manager.createAgent(
{ provider: "claude", cwd: tempDir, modeId: "bypassPermissions" },
undefined,
{ workspaceId: undefined },
);
await manager.runAgent(agent.id, "Earlier successful request");
const earlierReply = await manager.getLastAssistantMessage(agent.id);
expect(earlierReply).toBeTruthy();
const schedule = await service.create({
prompt: "Emit a turn failure",
cadence: { type: "every", everyMs: 60_000 },
target:
targetType === "agent"
? { type: "agent", agentId: agent.id }
: {
type: "new-agent",
config: { provider: "claude", cwd: tempDir, archiveOnFinish: false },
},
maxRuns: 1,
});
await service.runOnce(schedule.id);
expect((await service.inspect(schedule.id)).runs[0]).toMatchObject({
status: "failed",
error: "Requested fake provider failure\n\ncode: 126\n\nProvider executable failed",
});
await manager.closeAgent(agent.id);
},
);
test("records prompt-start failures as failed and archives the scheduled agent", async () => { test("records prompt-start failures as failed and archives the scheduled agent", async () => {
class StartFailureScheduleSession implements AgentSession { class StartFailureScheduleSession implements AgentSession {
readonly provider = "claude"; readonly provider = "claude";
@@ -912,6 +912,12 @@ export class ScheduleService {
requireSchedule(updatedSchedule, params.scheduleId); requireSchedule(updatedSchedule, params.scheduleId);
} }
private scheduledAgentFailure(agentId: string): Error {
return new Error(
this.agentManager.getAgent(agentId)?.lastError ?? `Scheduled agent ${agentId} failed`,
);
}
private async executeSchedule( private async executeSchedule(
schedule: StoredSchedule, schedule: StoredSchedule,
runId: string, runId: string,
@@ -945,7 +951,7 @@ export class ScheduleService {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`); throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
} }
if (waitResult.status === "error") { if (waitResult.status === "error") {
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`); throw this.scheduledAgentFailure(agent.id);
} }
return { return {
agentId: agent.id, agentId: agent.id,
@@ -1013,7 +1019,7 @@ export class ScheduleService {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`); throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
} }
if (waitResult.status === "error") { if (waitResult.status === "error") {
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`); throw this.scheduledAgentFailure(agent.id);
} }
const timelineText = curateAgentActivity(result.timeline); const timelineText = curateAgentActivity(result.timeline);
return { return {
@@ -52,6 +52,7 @@ interface Deferred<T> {
} }
interface FakeAgentSessionOptions { interface FakeAgentSessionOptions {
turnFailure?: TestAgentClientOptions["turnFailure"];
providerName: string; providerName: string;
config: AgentSessionConfig; config: AgentSessionConfig;
supportsMcpServers?: boolean; supportsMcpServers?: boolean;
@@ -64,6 +65,10 @@ interface FakeAgentSessionOptions {
} }
export interface TestAgentClientOptions { export interface TestAgentClientOptions {
turnFailure?: Pick<
Extract<AgentStreamEvent, { type: "turn_failed" }>,
"error" | "code" | "diagnostic"
>;
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>; beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
closeSession?: () => Promise<void>; closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
@@ -343,6 +348,7 @@ class FakeAgentSession implements AgentSession {
private readonly closeSession: (() => Promise<void>) | undefined; private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
private readonly turnFailure: TestAgentClientOptions["turnFailure"];
private readonly historyEvents: readonly AgentStreamEvent[] | undefined; private readonly historyEvents: readonly AgentStreamEvent[] | undefined;
constructor(options: FakeAgentSessionOptions) { constructor(options: FakeAgentSessionOptions) {
@@ -358,6 +364,7 @@ class FakeAgentSession implements AgentSession {
this.onStartTurn = options.onStartTurn; this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete; this.beforeTurnComplete = options.beforeTurnComplete;
this.historyEvents = options.historyEvents; this.historyEvents = options.historyEvents;
this.turnFailure = options.turnFailure;
this.historyPath = path.join( this.historyPath = path.join(
tmpdir(), tmpdir(),
"paseo-fake-provider-history", "paseo-fake-provider-history",
@@ -761,6 +768,7 @@ class FakeAgentSession implements AgentSession {
type: "turn_failed", type: "turn_failed",
provider: this.providerName, provider: this.providerName,
error: "Requested fake provider failure", error: "Requested fake provider failure",
...this.turnFailure,
}; };
await this.appendHistoryEvent(failed); await this.appendHistoryEvent(failed);
this.notifySubscribers(failed); this.notifySubscribers(failed);
@@ -1235,6 +1243,7 @@ class FakeAgentClient implements AgentClient {
onStartTurn: this.options.onStartTurn, onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete, beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents, historyEvents: this.options.historyEvents,
turnFailure: this.options.turnFailure,
}); });
} }
@@ -1262,6 +1271,7 @@ class FakeAgentClient implements AgentClient {
onStartTurn: this.options.onStartTurn, onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete, beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents, historyEvents: this.options.historyEvents,
turnFailure: this.options.turnFailure,
}); });
} }