fix: show model response errors in chat

This commit is contained in:
Federico Jaramillo Martinez
2026-06-11 11:51:51 +02:00
parent 577594a622
commit 9dd59c0f64
5 changed files with 78 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show model response errors in the chat transcript instead of leaving the conversation blank.
+13
View File
@@ -29,6 +29,19 @@ describe("chat message normalization", () => {
]);
});
it("shows assistant model errors as system chat messages", () => {
expect(normalizeMessage({ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limit", timestamp: "2026-05-09T12:00:00.000Z", provider: "openai", model: "gpt-4.1" })).toEqual([
{ role: "system", parts: [{ type: "text", text: "Model response failed: 429 rate limit" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" } } },
]);
});
it("keeps partial assistant content and adds a visible error line", () => {
expect(normalizeMessage({ role: "assistant", content: [{ type: "text", text: "partial answer" }], stopReason: "error", errorMessage: "connection lost" })).toEqual([
textMessage("assistant", "partial answer"),
textMessage("system", "Model response failed: connection lost"),
]);
});
it("extracts skill invocation blocks into dedicated skill and user messages", () => {
expect(normalizeMessage({ role: "user", content: "<skill name=\"playwright\" location=\"/skills/playwright\">\nUse browser\n</skill>\n\nNow test the UI" })).toEqual([
{ role: "user", parts: [{ type: "skillInvocation", name: "playwright", location: "/skills/playwright", content: "Use browser" }] },
+10 -1
View File
@@ -53,7 +53,16 @@ export function normalizeMessage(message: unknown): ChatLine[] {
const visible = parts.filter((part) => part.type !== "empty");
const displayRole = role === "assistant" && visible.length > 0 && visible.every((part) => part.type === "skillRead") ? "skill" : role;
return visible.length > 0 ? [withMessageMeta({ role: displayRole, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : [];
const lines = visible.length > 0 ? [withMessageMeta({ role: displayRole, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : [];
const errorLine = assistantErrorLine(message);
return errorLine === undefined ? lines : [...lines, withMessageMeta(errorLine, message)];
}
function assistantErrorLine(message: unknown): ChatLine | undefined {
if (getString(message, "role") !== "assistant" || getString(message, "stopReason") !== "error") return undefined;
const errorMessage = getString(message, "errorMessage")?.trim();
const detail = errorMessage === undefined || errorMessage === "" ? "The model returned an error." : errorMessage;
return textMessage("system", `Model response failed: ${detail}`);
}
function isChatLine(message: unknown): message is ChatLine {
+40
View File
@@ -61,6 +61,46 @@ describe("applyTranscriptEvent", () => {
]);
});
it("appends finalized assistant errors that have no displayable content", () => {
expect(applyTranscriptEvent([textMessage("user", "question")], {
type: "message.end",
message: {
role: "assistant",
content: [],
stopReason: "error",
errorMessage: "provider returned 500",
timestamp: "2026-05-09T12:00:00.000Z",
provider: "anthropic",
model: "claude-sonnet",
},
})).toEqual([
textMessage("user", "question"),
{ role: "system", parts: [{ type: "text", text: "Model response failed: provider returned 500" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "anthropic", id: "claude-sonnet" } } },
]);
});
it("replaces streamed assistant text and keeps the finalized error line", () => {
const streamed: ChatLine[] = [
textMessage("user", "question"),
textMessage("assistant", "partial"),
];
expect(applyTranscriptEvent(streamed, {
type: "message.end",
message: {
role: "assistant",
content: [{ type: "text", text: "partial answer" }],
stopReason: "error",
errorMessage: "connection lost",
timestamp: "2026-05-09T12:00:00.000Z",
},
})).toEqual([
textMessage("user", "question"),
{ ...textMessage("assistant", "partial answer"), meta: { timestamp: "2026-05-09T12:00:00.000Z" } },
{ role: "system", parts: [{ type: "text", text: "Model response failed: connection lost" }], meta: { timestamp: "2026-05-09T12:00:00.000Z" } },
]);
});
it("replaces streamed skill reads when the finalized assistant message includes thinking", () => {
const streamed: ChatLine[] = [
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
+10 -4
View File
@@ -25,10 +25,16 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
return finalizeToolExecution(messages, rawToolResult.toolCallId, rawToolResult.toolName, summarizeArgs(rawToolResult.content), rawToolResult.text, rawToolResult.isError, rawToolResult.content, rawToolResult.details);
}
const ended = normalizeMessage(rawMessage)[0];
if (ended === undefined) return undefined;
const displayEnded = ended.role === "assistant" ? withoutToolCalls(ended) : ended;
if (displayEnded.parts.length === 0) return messages;
const ended = normalizeMessage(rawMessage);
if (ended.length === 0) return undefined;
const displayEnded = ended
.map((line) => line.role === "assistant" ? withoutToolCalls(line) : line)
.filter((line) => line.parts.length > 0);
if (displayEnded.length === 0) return messages;
return displayEnded.reduce((next, line) => applyFinalLine(next, line), messages);
}
function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] {
const skillReadIndex = findMatchingSkillRead(messages, displayEnded);
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), displayEnded, ...messages.slice(skillReadIndex + 1)];
const last = messages.at(-1);