diff --git a/.changeset/show-model-response-errors.md b/.changeset/show-model-response-errors.md new file mode 100644 index 0000000..cad4995 --- /dev/null +++ b/.changeset/show-model-response-errors.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Show model response errors in the chat transcript instead of leaving the conversation blank. diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index 75fbf12..a53e6a8 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -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: "\nUse browser\n\n\nNow test the UI" })).toEqual([ { role: "user", parts: [{ type: "skillInvocation", name: "playwright", location: "/skills/playwright", content: "Use browser" }] }, diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 06ce2d8..736ae28 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -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 { diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 6308187..a4454eb 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -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" }] }, diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 0185083..84e976d 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -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);