summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/logic/tool-summary.test.ts15
-rw-r--r--src/features/chat/logic/tool-summary.ts19
-rw-r--r--src/features/chat/ui/ChatView.svelte6
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">