diff --git a/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx b/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx index 46388d23..5c025b32 100644 --- a/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx +++ b/app/(authenticated)/chat/[sessionId]/_components/conversation/index.test.tsx @@ -4,8 +4,57 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { ChatConversation } from "."; import type { ChatAgent } from "../chat-agent"; +import { sendMessageOutputSchema } from "@shared/chat/message-delivery"; describe("chat conversation", () => { + it.each([ + "Please keep *literal stars* in the filename.", + "Save the `draft` filename.", + "#42 is my order number.", + "1. First note\n2. Second note", + "Line one \nLine two", + ])("renders delivered plain text exactly: %s", async (text) => { + const agent = await deliveredAgent(text); + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain(text); + expect(markup).not.toMatch(/<(?:em|code|ol|h1)[ >]/u); + }); + + it.each([ + "https://example.com/report", + "See https://example.com/report_2026?view=notes for details.", + ])("keeps delivered HTTP URLs tappable: %s", async (text) => { + const markup = renderToStaticMarkup( + + ); + expect(markup).toContain('data-streamdown="link"'); + expect(markup).toContain("https://example.com/report"); + expect(markup).toContain( + text.startsWith("See ") ? "for details." : "https://example.com/report" + ); + }); + + it("keeps intentional trace Markdown", () => { + const agent = { + data: { messages: [message("turn-1:user", "*trace emphasis*")] }, + error: undefined, + events: [], + respond: async () => undefined, + status: "ready", + } satisfies Pick< + ChatAgent, + "data" | "error" | "events" | "respond" | "status" + >; + expect( + renderToStaticMarkup() + ).toContain("trace emphasis"); + }); + it("shows send_message output instead of assistant stream text", () => { const agent = { data: { @@ -109,6 +158,41 @@ describe("chat conversation", () => { }); }); +async function deliveredAgent(text: string) { + const output = sendMessageOutputSchema.parse({ kind: "message", text }); + return { + data: { + messages: [ + { id: "turn-1:assistant", parts: [], role: "assistant" as const }, + ], + }, + error: undefined, + events: [ + { + data: { + result: { + callId: "call-plain", + kind: "tool-result" as const, + output, + toolName: "send_message", + }, + sequence: 0, + status: "completed" as const, + stepIndex: 0, + turnId: "turn-1", + }, + meta: { at: "2026-10-07T00:00:00.000Z", id: "event-plain" }, + type: "action.result" as const, + }, + ], + respond: async () => undefined, + status: "ready" as const, + } satisfies Pick< + ChatAgent, + "data" | "error" | "events" | "respond" | "status" + >; +} + function message(id: string, text: string): EveMessage { return { id, diff --git a/app/(authenticated)/chat/[sessionId]/_components/conversation/message/parts.tsx b/app/(authenticated)/chat/[sessionId]/_components/conversation/message/parts.tsx index 5fdd3d60..ba05e705 100644 --- a/app/(authenticated)/chat/[sessionId]/_components/conversation/message/parts.tsx +++ b/app/(authenticated)/chat/[sessionId]/_components/conversation/message/parts.tsx @@ -34,6 +34,24 @@ export function AgentMessagePart({ case "step-start": return null; case "text": + if (userVisibleOnly) { + return ( +
+ {part.text.split(/(https?:\/\/\S+)/iu).map((text, index) => + /^https?:\/\//iu.test(text) ? ( + + {text} + + ) : ( + text + ) + )} +
+ ); + } return ( {part.text} diff --git a/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts b/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts index 50e0cfa5..ecc4f5d7 100644 --- a/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts +++ b/app/(authenticated)/chat/[sessionId]/_lib/message-events.test.ts @@ -153,7 +153,7 @@ describe("iMessage event projection", () => { expect(sentMessages(events).get("turn-1:assistant")).toEqual([ expect.objectContaining({ - parts: [expect.objectContaining({ text: "line one \nline two" })], + parts: [expect.objectContaining({ text: "line one\nline two" })], }), ]); }); diff --git a/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts b/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts index 7497e8ea..022ea533 100644 --- a/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts +++ b/app/(authenticated)/chat/[sessionId]/_lib/message-events.ts @@ -61,12 +61,7 @@ export function sentMessages(events: readonly MessageStreamEvent[]) { }); } else if (delivery) { const { output } = delivery; - // Delivered text is plain and reaches the user verbatim. The chat view - // renders text parts as Markdown, so keep every line break as a hard break. - const text = - output.kind === "link" - ? output.url - : output.text?.replaceAll("\n", " \n"); + const text = output.kind === "link" ? output.url : output.text; if (text) { parts.push({ state: "done", diff --git a/tests/agent/tools/share-contact.test.ts b/tests/agent/tools/share-contact.test.ts index 8d3d0767..9f55bbd9 100644 --- a/tests/agent/tools/share-contact.test.ts +++ b/tests/agent/tools/share-contact.test.ts @@ -72,6 +72,25 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); describe("share_contact", () => { + it("preserves exact plain text in the send_message producer", async () => { + const text = + "Keep *literal stars*, `draft` and the spaces \nbefore this line."; + const resolve = messaging.events["turn.started"]; + if (!resolve) throw new Error("Expected messaging resolver."); + const tools = await resolve({}, { + channel: { kind: "http", metadata: {} }, + messages: [], + model: null, + session: toolContext().session, + } satisfies DynamicResolveContext); + if (!tools || !("send_message" in tools)) + throw new Error("Expected send_message."); + const input = sendMessageOutputSchema.parse({ kind: "message", text }); + expect(await tools.send_message.execute(input, toolContext())).toEqual( + input + ); + }); + it("renders the contact in browser chat and suppresses repeats without sending to Linq", async () => { const tool = await shareContactTool(); const first = await tool.execute(