diff options
Diffstat (limited to 'src/features/chat')
| -rw-r--r-- | src/features/chat/logic/tool-summary.test.ts | 15 | ||||
| -rw-r--r-- | src/features/chat/logic/tool-summary.ts | 19 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 6 |
3 files changed, 38 insertions, 2 deletions
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts index b6c7e0b..c0caa0d 100644 --- a/src/features/chat/logic/tool-summary.test.ts +++ b/src/features/chat/logic/tool-summary.test.ts @@ -311,6 +311,21 @@ describe("tool summaries", () => { }); expect(() => view("mcp", { integer: 1n })).not.toThrow(); }); + it("rejects ancestor cycles but preserves shared non-cyclic tool values", () => { + const nested: Record<string, unknown> = { name: "nested" }; + const root = { nested }; + nested.parent = root; + expect(view("mcp", { payload: root }).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "payload", value: "[unserializable input]", multiline: false }], + }); + const shared = { name: "shared" }; + const payload = { first: shared, second: shared, values: [shared, shared] }; + const detail = view("mcp", { payload }).callDetail; + expect(detail.kind).toBe("fields"); + if (detail.kind !== "fields") throw new Error("Expected fields"); + expect(detail.fields[0]?.value).toBe(JSON.stringify(payload, null, 2)); + }); it("caps and whitespace-collapses header strings but retains full detail", () => { const command = ` echo\n${"x".repeat(300)}`; const card = view("run_shell", { command }); diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts index f6d1569..2380adc 100644 --- a/src/features/chat/logic/tool-summary.ts +++ b/src/features/chat/logic/tool-summary.ts @@ -74,7 +74,24 @@ function asArgs(input: unknown): Args { function serialize(input: unknown, pretty = false): string { if (typeof input === "string") return input; try { - return JSON.stringify(input, null, pretty ? 2 : undefined) ?? String(input); + // Detect ancestor cycles before the native serializer recurses. Bun's + // pretty JSON cycle detection can otherwise consume seconds of UI time. + // Track ancestors, not every visited object: shared non-cyclic values are valid. + const ancestors: object[] = []; + return ( + JSON.stringify( + input, + function (this: unknown, _key, value: unknown) { + if (value !== null && typeof value === "object") { + while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop(); + if (ancestors.includes(value)) throw new TypeError("Circular tool input"); + ancestors.push(value); + } + return value; + }, + pretty ? 2 : undefined, + ) ?? String(input) + ); } catch { return "[unserializable input]"; } diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index c7f566a..84e66d8 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -11,7 +11,7 @@ viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import { Markdown, type TextFileClient } from "../../markdown"; import CacheTimerBadge from "./CacheTimerBadge.svelte"; import LazyCollapse from "./LazyCollapse.svelte"; import ToolCard from "./ToolCard.svelte"; @@ -32,6 +32,7 @@ providerRetry = null, apiBaseUrl = "", cwd = null, + textFiles = undefined, cacheTimer = null, }: { chunks: readonly RenderedChunk[]; @@ -68,6 +69,8 @@ * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. */ cwd?: string | null; + /** Context-bound, injected live-file display port (never model history). */ + textFiles?: TextFileClient | undefined; /** * The cache timer (gaps between model requests): a running step gap shows * live at the bottom (under the executing tools), a running turn gap live @@ -216,6 +219,7 @@ text={rendered.chunk.text} streaming={rendered.streaming ?? false} image={{ apiBase: apiBaseUrl, cwd }} + {textFiles} /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> |
