diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts index e7b97d1..352f2f1 100644 --- a/src/__tests__/markdown.test.ts +++ b/src/__tests__/markdown.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - STREAMING_CURSOR, - buildThreadMarkdown, - quote, - sessionToPlainText, -} from "../lib/markdown"; +import { STREAMING_CURSOR, buildThreadMarkdown, quote, sessionToPlainText } from "../lib/markdown"; import type { ChatTurn, ThreadMessage } from "../lib/types"; function message(overrides: Partial & Pick): ThreadMessage { @@ -235,9 +230,7 @@ describe("buildThreadMarkdown", () => { }); it("links a lone URL the model put in inline code", () => { - const markdown = buildThreadMarkdown([ - message({ role: "assistant", text: "`https://example.com`" }), - ]); + const markdown = buildThreadMarkdown([message({ role: "assistant", text: "`https://example.com`" })]); expect(markdown).not.toContain("```"); expect(markdown).toContain("[https://example.com](https://example.com)"); @@ -655,3 +648,73 @@ describe("attachments in the transcript", () => { expect(text).toContain("_(screenshot attached)_"); }); }); + +describe("sources", () => { + const sources = [ + { index: 1, title: "First", url: "https://first.example" }, + { index: 2, title: "Second", url: "https://second.example" }, + ]; + + /** One search, and the answer that may or may not point at it. */ + function answered(text: string, streaming = false): string { + return buildThreadMarkdown( + [ + message({ + id: "t1:assistant", + role: "assistant", + text, + isStreaming: streaming, + toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }], + }), + message({ id: "t1:user", role: "user", text: "When is the rain?" }), + ], + { showToolCalls: false, showReasoning: false }, + ); + } + + it("links the markers and lists the sources the answer leaned on", () => { + const text = answered("Rain is likely [1]."); + + expect(text).toContain("Rain is likely [\\[1\\]](https://first.example)."); + expect(text).toContain("_Sources_"); + expect(text).toContain("- [\\[1\\]](https://first.example) First"); + // The answer did not lean on the second one. + expect(text).not.toContain("Second"); + }); + + it("lists everything the turn consulted when the answer cited nothing", () => { + const text = answered("Rain is likely."); + + expect(text).toContain("- [\\[1\\]](https://first.example) First"); + expect(text).toContain("- [\\[2\\]](https://second.example) Second"); + }); + + it("waits for a citation before drawing anything", () => { + // Otherwise the list arrives in full with the first token and then shrinks as + // citations appear, which reads as a glitch rather than as settling. + expect(answered("Rain is", true)).not.toContain("_Sources_"); + }); + + it("adds nothing to a turn that consulted nothing", () => { + expect(buildThreadMarkdown([message({ role: "assistant", text: "Plain." })])).toBe("Plain."); + }); + + it("carries the list into the copied conversation", () => { + const copied = sessionToPlainText([ + { + id: "t1", + prompt: "When is the rain?", + response: "Rain [1].", + thinking: "", + model: "m", + createdAt: 0, + toolCalls: [{ name: "web_search", arguments: {}, result: "", isError: false, sources }], + attachments: [], + }, + ]); + + expect(copied).toContain("**Lam**\n\nRain [1]."); + expect(copied).toContain("_Sources_"); + expect(copied).toContain("- [\\[1\\]](https://first.example) First"); + }); +}); diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 8a01e31..8c134e3 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,5 +1,6 @@ import { imageSource } from "./attachments"; -import { renderInlineContent, renderModelOutput } from "./render"; +import { citedIndices, collectSources, renderSources } from "./citations"; +import { renderInlineContent, renderModelOutput, type RenderedMarkdown } from "./render"; import { describeActivity } from "./thread"; import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types"; @@ -150,11 +151,20 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri /** The answer, or whatever stands in for it while there is none. */ const answered = message.text.trim(); + /** + * The turn's sources, read back off its records rather than out of a tool result + * string, so a reopened conversation draws the same list with the same numbers. + */ + const sources = collectSources(message.toolCalls); + const drawn: RenderedMarkdown = answered + ? // One pass, in one place: fences get their languages and links recovered, then + // TeX becomes something drawable, a citation marker becomes a link to the page + // it names, and the HTML around all of it is filtered to the subset the + // renderer acts on. See `render.ts` for the order and why. + renderModelOutput(answered, { sources }) + : { text: "", cited: new Set() }; const body = answered - ? // One pass, in one place: fences get their languages and links recovered, - // then TeX becomes something drawable and the HTML around it is filtered to - // the subset the renderer acts on. See `render.ts` for the order and why. - renderModelOutput(answered).text + ? drawn.text : !message.isStreaming ? "_No response._" : activityLine @@ -178,6 +188,14 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri // something went wrong and should not be skimmed past. if (message.error) sections.push("", `**Error:** ${message.error}`); + /** + * Sources belong to the answer rather than to the tool list, so they are drawn + * whether or not the user asked to see tool calls. Hiding the activity line is + * about chrome; a citation list is the answer's own provenance. + */ + const list = renderSources(sources, drawn.cited, message.isStreaming); + if (list) sections.push("", list); + /** * Tool calls are a display preference. When they are on, every tool is named * once. When they are off, nothing is listed; a failure or a refused repeat @@ -276,7 +294,16 @@ export function sessionToPlainText(turns: ChatTurn[]): string { const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : ""; const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_"); const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; - return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`; + /** + * The same list the transcript draws, worked out from a plain scan rather than + * from the panel's own set. A copied conversation is markdown on its way + * somewhere else, where a `[1]` inside a snippet counting as a citation costs a + * reader nothing. + */ + const sources = collectSources(turn.toolCalls); + const list = renderSources(sources, citedIndices(turn.response, sources), false); + const cited = list ? `\n\n${list}` : ""; + return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}${cited}`; }) .join("\n\n---\n\n"); }