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
@@ -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;
}