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) {
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.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> {
@@ -51,7 +51,7 @@ test.describe("Assistant fork menu", () => {
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({
await expect(page.getByText("Requested mock provider failure")).toBeVisible({
timeout: 30_000,
});
+10 -8
View File
@@ -186,8 +186,9 @@ export async function createCreationScenario(page: Page) {
await requests.settled();
await expect(
page
.getByTestId("assistant-message")
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
.getByTestId("timeline-notification")
.filter({ hasText: "Requested mock prompt rejection" })
.filter({ visible: true }),
).toBeVisible();
await expect(
page
@@ -203,11 +204,15 @@ export async function createCreationScenario(page: Page) {
async expectPromptBeforeError(prompt: string) {
const messages = page
.getByTestId("user-message")
.or(page.getByTestId("assistant-message"))
.or(
page
.getByTestId("timeline-notification")
.filter({ hasText: "Requested mock prompt rejection" }),
)
.filter({ visible: true });
await expect(messages).toHaveCount(2);
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();
},
async reloadAgent() {
@@ -217,10 +222,7 @@ export async function createCreationScenario(page: Page) {
},
async expectAssistantReply() {
await expect(
page
.getByTestId("assistant-message")
.filter({ visible: true, hasNotText: "[System Error]" })
.first(),
page.getByTestId("assistant-message").filter({ visible: true }).first(),
).toBeVisible();
},
async expectStartupFailure() {
@@ -82,7 +82,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByAssistantId: timingFor(assistant.id),
timingByResponseItemId: timingFor(assistant.id),
}),
};
}
@@ -126,7 +126,7 @@ describe("turn footer spacing", () => {
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByAssistantId: timingFor(last.id),
timingByResponseItemId: timingFor(last.id),
});
const firstLayout = layout.history.find((item) => item.item.id === first.id);
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
+32 -3
View File
@@ -105,7 +105,7 @@ function layoutFor(input: {
strategy,
streamHead: input.head ?? [],
}),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
timingByResponseItemId: timingFor(...(input.timingIds ?? [])),
});
}
@@ -149,6 +149,35 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
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", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2");
@@ -172,7 +201,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByAssistantId: timingFor(priorAssistant.id),
timingByResponseItemId: timingFor(priorAssistant.id),
});
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -181,7 +210,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByAssistantId: timingFor(priorAssistant.id),
timingByResponseItemId: timingFor(priorAssistant.id),
});
expect(footerOwners(first)).toEqual([]);
+28 -25
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean;
history: StreamItem[];
liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
timingByResponseItemId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
timingByResponseItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete";
}
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
interface ResponseFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" | "notification" }>;
items: StreamItem[];
index: number;
}
@@ -67,23 +67,23 @@ function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
timingByResponseItemId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
timing: input.timingByResponseItemId.get(input.item.id),
startIndex: input.index,
};
}
function findLatestAssistantInResponse(input: {
function findLatestFooterSourceInResponse(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null {
}): ResponseFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
@@ -99,7 +99,10 @@ function findLatestAssistantInResponse(input: {
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return null;
}
if (item.kind === "assistant_message") {
if (
item.kind === "assistant_message" ||
(item.kind === "notification" && item.sourceType === "error")
) {
return { item, items, index };
}
laterItem = item;
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const assistant = findLatestAssistantInResponse({
const source = findLatestFooterSourceInResponse({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (!assistant) {
if (!source) {
return null;
}
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
item: source.item,
items: source.items,
index: source.index,
timingByResponseItemId: input.timingByResponseItemId,
});
}
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
index: number;
item: StreamItem;
belowItem: StreamItem | null;
timingByAssistantId: Map<string, TurnTiming>;
timingByResponseItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
return null;
}
const assistant = findLatestAssistantInResponse({
const source = findLatestFooterSourceInResponse({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
return null;
}
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
item: source.item,
items: source.items,
index: source.index,
timingByResponseItemId: input.timingByResponseItemId,
});
}
@@ -306,7 +309,7 @@ function layoutSegmentItem(
index,
item,
belowItem,
timingByAssistantId: input.timingByAssistantId,
timingByResponseItemId: input.timingByResponseItemId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
@@ -377,7 +380,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({
strategy: input.strategy,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
timingByResponseItemId: input.timingByResponseItemId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
@@ -397,7 +400,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
timingByResponseItemId: input.timingByResponseItemId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
+6 -6
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
expect(model.turnTiming.byResponseItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byResponseItemId.get("visible-a")).toEqual({
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
});
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", () => {
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
});
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,
durationMs: 3000,
});
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
});
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,
durationMs: 3000,
});
@@ -238,6 +238,6 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.turnTiming.byAssistantId.size).toBe(0);
expect(model.turnTiming.byResponseItemId.size).toBe(0);
});
});
@@ -32,7 +32,12 @@ function assistantMessage(
describe("resolveAssistantTurnForkBoundary", () => {
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
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 },
};
@@ -10,6 +10,11 @@ export function resolveAssistantTurnForkBoundary(input: {
supportsTimelineCursor: boolean;
}): AssistantTurnForkBoundary | undefined {
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") {
return undefined;
}
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
isTurnActive,
history: model.segments.historyMounted,
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 completed = layoutFor(completedItems, false);
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),
durationMs: 8000,
});
+2 -2
View File
@@ -593,12 +593,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
timingByResponseItemId: baseRenderModel.turnTiming.byResponseItemId,
}),
[
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
baseRenderModel.turnTiming.byResponseItemId,
isTurnActive,
streamRenderStrategy,
],
+1 -1
View File
@@ -2161,7 +2161,7 @@ export const Notification = memo(function Notification({
[resolvedDisableOuterSpacing, config.bg],
);
return (
<View style={containerStyle}>
<View style={containerStyle} testID="timeline-notification">
<View style={notificationStylesheet.content}>
<View style={notificationStylesheet.row}>
<View style={notificationStylesheet.iconContainer}>
+30 -5
View File
@@ -22,6 +22,31 @@ function assistant(id: string, timestamp: Date): StreamItem {
}
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", () => {
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
@@ -51,7 +76,7 @@ describe("deriveStreamTurnTiming", () => {
});
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", () => {
@@ -69,7 +94,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byAssistantId.get("a1"), {
assert.deepEqual(timing.byResponseItemId.get("a1"), {
completedAt: assistantAt,
durationMs: 7000,
});
@@ -95,8 +120,8 @@ describe("deriveStreamTurnTiming", () => {
completedAt: lastAssistantAt,
durationMs: 7000,
};
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
assert.deepEqual(timing.byResponseItemId.get("a1"), expected);
assert.deepEqual(timing.byResponseItemId.get("a2"), expected);
});
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
@@ -120,7 +145,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
assert.deepEqual(timing.byResponseItemId.get("hidden-prompt-a2"), {
completedAt: hiddenPromptTurnAt,
durationMs: null,
});
+13 -10
View File
@@ -7,7 +7,7 @@ export interface TurnTiming {
}
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
byResponseItemId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
}
@@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: {
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
const byResponseItemId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
let currentResponseItemIds: string[] = [];
let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => {
if (!currentLastItemAt || currentAssistantIds.length === 0) {
if (!currentLastItemAt || currentResponseItemIds.length === 0) {
return;
}
const timing: TurnTiming = {
@@ -33,8 +33,8 @@ export function deriveStreamTurnTiming(params: {
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
: null,
};
for (const id of currentAssistantIds) {
byAssistantId.set(id, timing);
for (const id of currentResponseItemIds) {
byResponseItemId.set(id, timing);
}
};
@@ -43,11 +43,14 @@ export function deriveStreamTurnTiming(params: {
flushCompletedTurn();
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
currentLastItemAt = null;
currentAssistantIds = [];
currentResponseItemIds = [];
}
currentLastItemAt = item.timestamp;
if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id);
if (
item.kind === "assistant_message" ||
(item.kind === "notification" && item.sourceType === "error")
) {
currentResponseItemIds.push(item.id);
}
previousItem = item;
};
@@ -65,7 +68,7 @@ export function deriveStreamTurnTiming(params: {
}
return {
byAssistantId,
byResponseItemId,
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([
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
]);
expect(
timeline.some(
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
),
).toBe(false);
expect(timeline.some((item) => item.type === "error")).toBe(false);
} finally {
await manager.closeAgent(created.id).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 () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let capturedSession: TestAgentSession | null = null;
test.each(["Task completed.", "[System Error] untracked failure"])(
"preserves terminal fallback and separates untracked failure activity after %s",
async (assistantText) => {
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 {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new TestAgentSession(config);
return capturedSession;
class LiveEventClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
capturedSession = new TestAgentSession(config);
return capturedSession;
}
}
}
const manager = new AgentManager({
clients: { codex: new LiveEventClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000136",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
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({
const manager = new AgentManager({
clients: { codex: new LiveEventClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000136",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
await manager.appendTimelineItem(snapshot.id, {
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 () => {
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",
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(
@@ -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 storagePath = join(workdir, "agents");
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
.getTimeline(agent.id)
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message" && item.text.includes("[System Error]"),
);
.filter((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
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 () => {
@@ -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");
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
.getTimeline(agent.id)
.find(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message" && item.text.includes("[System Error]"),
);
expect(systemError?.text).toContain("Provider execution failed");
expect(systemError?.text).toContain("code: 126");
expect(systemError?.text).toContain("No preset version installed for command claude");
.find((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
expect(systemError?.message).toContain("Provider execution failed");
expect(systemError?.message).toContain("code: 126");
expect(systemError?.message).toContain("No preset version installed for command claude");
});
test("permission request notifies once without forcing unread attention state", async () => {
@@ -551,8 +551,6 @@ interface AgentMetadataPatch {
labels?: AgentLabelPatch;
}
const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd(
handle: AgentPersistenceHandle | null,
cwd: string,
@@ -4633,13 +4631,8 @@ export class AgentManager {
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
agent.lifecycle = "error";
}
agent.lastError = event.error;
await this.appendSystemErrorTimelineMessage(
agent,
event.provider,
this.formatTurnFailedMessage(event),
options,
);
agent.lastError = this.formatTurnFailedMessage(event);
await this.appendSystemErrorTimelineMessage(agent, event.provider, agent.lastError, options);
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
this.emitState(agent);
@@ -4883,13 +4876,12 @@ export class AgentManager {
return;
}
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
const lastItem = await this.getLastItemFromStores(agent.id);
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
if (lastItem?.type === "error" && lastItem.message === normalized) {
return;
}
const item: AgentTimelineItem = { type: "assistant_message", text };
const item: AgentTimelineItem = { type: "error", message: normalized };
const row = this.recordTimeline(agent.id, item);
this.dispatchStream(
agent.id,
@@ -15,6 +15,7 @@ import { join } from "node:path";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { AgentStorage } from "./agent-storage.js";
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.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",
});
});
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;
reason: FinishNotificationReason;
lastAssistantMessage: string | null;
lastError?: string;
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 {
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const sections = [statusLine];
@@ -450,18 +458,21 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
)}\n</permission-request>`,
);
}
let lastAssistantMessage = params.lastAssistantMessage?.trim();
if (lastAssistantMessage) {
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
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]`;
}
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
if (params.reason === "errored" && params.lastError) {
sections.push(
`<agent-error>\n${limitFinishNotificationMessage(params.lastError)}\n</agent-error>`,
);
}
if (params.lastAssistantMessage?.trim()) {
sections.push(
`<agent-response>\n${limitFinishNotificationMessage(params.lastAssistantMessage)}\n</agent-response>`,
);
}
return sections.join("\n\n");
}
interface NotifySafelyOptions {
lastError?: string;
terminal?: boolean;
permissionRequest?: AgentPermissionRequest;
}
@@ -505,7 +516,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
async function notify(
reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest,
{ permissionRequest, lastError }: Pick<NotifySafelyOptions, "permissionRequest" | "lastError">,
): Promise<void> {
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
@@ -517,12 +528,14 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
const title = record?.title ?? childAgentId;
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
const lastAssistantMessage =
reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({
childAgentId,
title,
reason,
lastAssistantMessage,
lastError,
permissionRequest,
});
@@ -546,7 +559,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
if (stopped) return;
if (options.terminal ?? true) stop();
notificationQueue = notificationQueue
.then(() => notify(reason, options.permissionRequest))
.then(() => notify(reason, options))
.catch((error) => {
logger.error(
{ 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
// is reported by the dispatch acknowledgement instead of a finish callback.
if (agent.lifecycle === "error" && hasSeenTurn) {
notifySafely("errored");
notifySafely("errored", { lastError: agent.lastError });
return;
}
// Idle is published before the terminal stream event for both completion
@@ -374,7 +374,7 @@ test.each(["agent", "workspace"] as const)(
clientMessageId: agent.clientMessageId,
messageId: agent.clientMessageId,
},
{ type: "assistant_message", text: `[System Error] ${rejection}` },
{ type: "error", message: rejection },
];
expect(
(await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item),
@@ -1776,6 +1776,57 @@ describe("ScheduleService", () => {
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 () => {
class StartFailureScheduleSession implements AgentSession {
readonly provider = "claude";
@@ -912,6 +912,12 @@ export class ScheduleService {
requireSchedule(updatedSchedule, params.scheduleId);
}
private scheduledAgentFailure(agentId: string): Error {
return new Error(
this.agentManager.getAgent(agentId)?.lastError ?? `Scheduled agent ${agentId} failed`,
);
}
private async executeSchedule(
schedule: StoredSchedule,
runId: string,
@@ -945,7 +951,7 @@ export class ScheduleService {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
}
if (waitResult.status === "error") {
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
throw this.scheduledAgentFailure(agent.id);
}
return {
agentId: agent.id,
@@ -1013,7 +1019,7 @@ export class ScheduleService {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
}
if (waitResult.status === "error") {
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
throw this.scheduledAgentFailure(agent.id);
}
const timelineText = curateAgentActivity(result.timeline);
return {
@@ -52,6 +52,7 @@ interface Deferred<T> {
}
interface FakeAgentSessionOptions {
turnFailure?: TestAgentClientOptions["turnFailure"];
providerName: string;
config: AgentSessionConfig;
supportsMcpServers?: boolean;
@@ -64,6 +65,10 @@ interface FakeAgentSessionOptions {
}
export interface TestAgentClientOptions {
turnFailure?: Pick<
Extract<AgentStreamEvent, { type: "turn_failed" }>,
"error" | "code" | "diagnostic"
>;
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
@@ -343,6 +348,7 @@ class FakeAgentSession implements AgentSession {
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
private readonly turnFailure: TestAgentClientOptions["turnFailure"];
private readonly historyEvents: readonly AgentStreamEvent[] | undefined;
constructor(options: FakeAgentSessionOptions) {
@@ -358,6 +364,7 @@ class FakeAgentSession implements AgentSession {
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
this.historyEvents = options.historyEvents;
this.turnFailure = options.turnFailure;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -761,6 +768,7 @@ class FakeAgentSession implements AgentSession {
type: "turn_failed",
provider: this.providerName,
error: "Requested fake provider failure",
...this.turnFailure,
};
await this.appendHistoryEvent(failed);
this.notifySubscribers(failed);
@@ -1235,6 +1243,7 @@ class FakeAgentClient implements AgentClient {
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents,
turnFailure: this.options.turnFailure,
});
}
@@ -1262,6 +1271,7 @@ class FakeAgentClient implements AgentClient {
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents,
turnFailure: this.options.turnFailure,
});
}