From 7d7040353a9ee67243d2f75392a24146876b1ea9 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 7 Oct 2026 16:11:58 +0800 Subject: [PATCH] feat: list an answer's sources under it, and keep them when copying The list belongs to the answer rather than to the tool line, so it is drawn with tool calls hidden too. It waits for the answer to cite something before appearing, because otherwise it arrives in full with the first token and then shrinks as citations arrive. A copied conversation carries the same list, worked out from a plain scan: it is markdown on its way somewhere else, where a [1] inside a snippet counting as a citation costs nothing. --- src/__tests__/markdown.test.ts | 81 ++++++++++++++++++++++++++++++---- src/lib/markdown.ts | 39 +++++++++++++--- 2 files changed, 105 insertions(+), 15 deletions(-) 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"); }