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.
This commit is contained in:
2026-10-07 16:11:58 +08:00
parent c767595a76
commit 7d7040353a
2 changed files with 105 additions and 15 deletions
+72 -9
View File
@@ -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<ThreadMessage> & Pick<ThreadMessage, "role">): 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");
});
});