mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user