Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChatConversation agent={agent} traceView="imessage" />
);
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(
<ChatConversation
agent={await deliveredAgent(text)}
traceView="imessage"
/>
);
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(<ChatConversation agent={agent} traceView="trace" />)
).toContain("<em>trace emphasis</em>");
});

it("shows send_message output instead of assistant stream text", () => {
const agent = {
data: {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ export function AgentMessagePart({
case "step-start":
return null;
case "text":
if (userVisibleOnly) {
return (
<div className="wrap-break-word whitespace-pre-wrap">
{part.text.split(/(https?:\/\/\S+)/iu).map((text, index) =>
/^https?:\/\//iu.test(text) ? (
<MessageResponse
className="inline [&>p]:inline"
key={`${String(index)}:${text}`}
>
{text}
</MessageResponse>
) : (
text
)
)}
</div>
);
}
return (
<MessageResponse caret="block" isAnimating={showCaret}>
{part.text}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" })],
}),
]);
});
Expand Down
7 changes: 1 addition & 6 deletions app/(authenticated)/chat/[sessionId]/_lib/message-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions tests/agent/tools/share-contact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down