diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index 0be281e5cb97..1328d43a8564 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -580,7 +580,7 @@ function extractAssistantTextForSilentCheck(message: unknown): string | undefine return undefined; } const typed = block as { type?: unknown; text?: unknown }; - if ((typed.type !== "text" && typed.type !== "output_text") || typeof typed.text !== "string") { + if (!isAssistantTextContentType(typed.type) || typeof typed.text !== "string") { return undefined; } texts.push(typed.text); @@ -588,6 +588,10 @@ function extractAssistantTextForSilentCheck(message: unknown): string | undefine return texts.length > 0 ? texts.join("\n") : undefined; } +function isAssistantTextContentType(type: unknown): boolean { + return type === "text" || type === "input_text" || type === "output_text"; +} + function hasAssistantNonTextContent(message: unknown): boolean { if (!message || typeof message !== "object") { return false; @@ -600,8 +604,7 @@ function hasAssistantNonTextContent(message: unknown): boolean { (block) => block && typeof block === "object" && - (block as { type?: unknown }).type !== "text" && - (block as { type?: unknown }).type !== "output_text", + !isAssistantTextContentType((block as { type?: unknown }).type), ); } @@ -623,7 +626,11 @@ function hasAssistantMixedToolVisibleText(message: unknown): boolean { if (isToolHistoryBlockType(entry.type)) { hasToolHistoryBlock = true; } - if (entry.type === "text" && typeof entry.text === "string" && entry.text.trim()) { + if ( + isAssistantTextContentType(entry.type) && + typeof entry.text === "string" && + entry.text.trim() + ) { hasText = true; } } @@ -1648,7 +1655,7 @@ function projectEmptyAssistantErrorMessages( } const type = (block as { type?: unknown }).type; return ( - type !== "text" && + !isAssistantTextContentType(type) && type !== "thinking" && type !== "reasoning" && type !== "redacted_thinking" @@ -1669,7 +1676,7 @@ function projectEmptyAssistantErrorMessages( continue; } const entry = block as { type?: unknown; text?: unknown }; - if (entry.type === "text" && typeof entry.text === "string") { + if (isAssistantTextContentType(entry.type) && typeof entry.text === "string") { visibleTexts.push(entry.text); } } diff --git a/src/gateway/chat-sanitize.test.ts b/src/gateway/chat-sanitize.test.ts index 6de855579b3e..49d9c14dd558 100644 --- a/src/gateway/chat-sanitize.test.ts +++ b/src/gateway/chat-sanitize.test.ts @@ -44,6 +44,20 @@ describe("stripEnvelopeFromMessage", () => { expect(assistant.content?.[0]?.text).toBe("Assistant body"); }); + test("strips internal metadata from assistant input_text blocks", () => { + const assistant = stripEnvelopeFromMessage({ + role: "assistant", + content: [ + { + type: "input_text", + text: 'Conversation info (untrusted metadata):\n```json\n{"message_id":"123"}\n```\n\nAssistant body', + }, + ], + }) as { content?: Array<{ text?: string }> }; + + expect(assistant.content?.[0]?.text).toBe("Assistant body"); + }); + test("does not strip inline message_id text that is part of a line", () => { const input = { role: "user", diff --git a/src/gateway/chat-sanitize.ts b/src/gateway/chat-sanitize.ts index 26905ba485af..628dda8b387d 100644 --- a/src/gateway/chat-sanitize.ts +++ b/src/gateway/chat-sanitize.ts @@ -59,7 +59,7 @@ function stripEnvelopeFromContentWithRole( const isRoleTextBlock = entry.type === "text" || (role === "user" && entry.type === "input_text") || - (role === "assistant" && entry.type === "output_text"); + (role === "assistant" && (entry.type === "input_text" || entry.type === "output_text")); if (!isRoleTextBlock || typeof entry.text !== "string") { return item; } diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 44ba7792da11..f9f51c18ac3c 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -941,6 +941,43 @@ describe("projectRecentChatDisplayMessages", () => { ]); }); + it.each([ + ["output_text", ""], + ["output_text", "NO_REPLY"], + ["input_text", ""], + ["input_text", "NO_REPLY"], + ])("projects hidden %s assistant errors %j as a generic safe failure", (type, text) => { + const result = projectRecentChatDisplayMessages([ + { + role: "assistant", + content: [{ type, text }], + stopReason: "error", + errorMessage: "Connection error.", + timestamp: 1, + }, + ]); + + expect(result[0]?.content).toEqual([ + { type: "text", text: "The agent run failed before producing a reply." }, + ]); + }); + + it("preserves visible output_text from a failed assistant turn", () => { + const result = projectRecentChatDisplayMessages([ + { + role: "assistant", + content: [{ type: "output_text", text: "A partial reply before the run failed." }], + stopReason: "error", + errorMessage: "Connection error.", + timestamp: 1, + }, + ]); + + expect(result[0]?.content).toEqual([ + { type: "output_text", text: "A partial reply before the run failed." }, + ]); + }); + it("projects thinking-only assistant errors as a generic safe failure", () => { const result = projectRecentChatDisplayMessages([ { diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 40b308a120e8..4fd028286a2f 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -714,9 +714,22 @@ describe("gateway server chat", () => { content: [{ type: "output_text", text: "visible response" }], timestamp: 2, }, + { + role: "assistant", + content: [{ type: "input_text", text: "NO_REPLY" }], + timestamp: 3, + }, + { + role: "assistant", + content: [{ type: "input_text", text: "visible assistant input" }], + timestamp: 4, + }, ]); - expect(collectHistoryTextValues(historyMessages)).toEqual(["visible response"]); + expect(collectHistoryTextValues(historyMessages)).toEqual([ + "visible response", + "visible assistant input", + ]); }); test("chat.history mirrors current-session message tool sends before NO_REPLY", async () => { diff --git a/src/shared/chat-message-content.test.ts b/src/shared/chat-message-content.test.ts index f022c5f0ef91..479cecca9aac 100644 --- a/src/shared/chat-message-content.test.ts +++ b/src/shared/chat-message-content.test.ts @@ -147,6 +147,15 @@ describe("extractAssistantVisibleText", () => { ).toBe("Persisted assistant answer"); }); + it("extracts persisted Responses assistant input_text blocks", () => { + expect( + extractAssistantVisibleText({ + role: "assistant", + content: [{ type: "input_text", text: "Persisted assistant input" }], + }), + ).toBe("Persisted assistant input"); + }); + it("does not mix unphased legacy text into final_answer output", () => { expect( extractAssistantVisibleText({ diff --git a/src/shared/chat-message-content.ts b/src/shared/chat-message-content.ts index 8fb79c556f4d..1d610e8e8fb9 100644 --- a/src/shared/chat-message-content.ts +++ b/src/shared/chat-message-content.ts @@ -24,7 +24,7 @@ export function extractFirstTextBlock(message: unknown): string | undefined { export type AssistantPhase = "commentary" | "final_answer"; function isAssistantTextContentBlockType(value: unknown): boolean { - return value === "text" || value === "output_text"; + return value === "text" || value === "input_text" || value === "output_text"; } /** Narrows unknown phase metadata to assistant text phases that affect visibility. */ diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts index 4d84c0cc38c8..3615968b3a68 100644 --- a/ui/src/ui/chat/message-extract.test.ts +++ b/ui/src/ui/chat/message-extract.test.ts @@ -59,7 +59,7 @@ describe("extractTextCached", () => { ).toBe("Persisted assistant answer"); }); - it("ignores persisted Responses text blocks from the wrong role", () => { + it("accepts assistant Responses input blocks but ignores user output blocks", () => { expect( extractText({ role: "user", @@ -71,7 +71,7 @@ describe("extractTextCached", () => { role: "assistant", content: [{ type: "input_text", text: "User-only block" }], }), - ).toBeNull(); + ).toBe("User-only block"); }); it("prefers final_answer assistant text over commentary text", () => { diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index c7fd961829f6..cef618ea84d8 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -13,7 +13,7 @@ function isTextContentBlockType(value: unknown, role: string): boolean { return ( value === "text" || (role === "user" && value === "input_text") || - (role === "assistant" && value === "output_text") + (role === "assistant" && (value === "input_text" || value === "output_text")) ); } diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index 40324224d822..be2e841a9f63 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -111,7 +111,7 @@ describe("message-normalizer", () => { expect(assistant.content).toEqual([{ type: "text", text: "Persisted assistant answer" }]); }); - it("does not reinterpret persisted Responses text blocks from the wrong role", () => { + it("accepts assistant Responses input blocks but rejects user output blocks", () => { const user = normalizeMessage({ role: "user", content: [{ type: "output_text", text: "Assistant-only block" }], @@ -122,7 +122,7 @@ describe("message-normalizer", () => { }); expect(user.content).not.toContainEqual({ type: "text", text: "Assistant-only block" }); - expect(assistant.content).not.toContainEqual({ type: "text", text: "User-only block" }); + expect(assistant.content).toContainEqual({ type: "text", text: "User-only block" }); }); it("normalizes structured base64 audio content blocks as renderable attachments", () => { diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index 65f65b161ad9..a19e73150705 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -23,7 +23,7 @@ function isTextContentBlock( typeof item.text === "string" && (item.type === "text" || (role === "user" && item.type === "input_text") || - (role === "assistant" && item.type === "output_text")) + (role === "assistant" && (item.type === "input_text" || item.type === "output_text"))) ); }