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.
This commit is contained in:
paseo-bot[bot]
2026-10-13 12:59:06 +02:00
parent e5efa7324d
commit 9e596e3377
11 changed files with 185 additions and 30 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.getByText(errorMessage, { exact: true }));
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,
});
+5 -8
View File
@@ -186,8 +186,8 @@ 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 }),
.getByText("Requested mock prompt rejection", { exact: true })
.filter({ visible: true }),
).toBeVisible();
await expect(
page
@@ -203,11 +203,11 @@ export async function createCreationScenario(page: Page) {
async expectPromptBeforeError(prompt: string) {
const messages = page
.getByTestId("user-message")
.or(page.getByTestId("assistant-message"))
.or(page.getByText("Requested mock prompt rejection", { exact: true }))
.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 +217,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() {
@@ -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");
+18 -15
View File
@@ -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;
}
@@ -77,13 +77,13 @@ function createTurnFooterHost(input: {
};
}
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,19 +134,19 @@ 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,
item: source.item,
items: source.items,
index: source.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -163,20 +166,20 @@ 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,
item: source.item,
items: source.items,
index: source.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -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;
}