From 1ded0d375920e82364ea507ea1c28da02b6eb7fb Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 2 Jul 2026 02:40:07 +0200 Subject: [PATCH] fix(coding-agent): restricted key extraction to top-level json depth - Introduced JSON depth tracking to ensure string keys are only extracted at the top level. - Prevented nested keys matching designated streaming keys from being prematurely or incorrectly captured. - Added comprehensive unit tests validating nested key exclusion and correct top-level extraction order. --- .../src/modes/controllers/tool-args-reveal.ts | 11 +++++++- .../test/tool-args-reveal.test.ts | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts b/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts index aa412f3ac..a4c67905e 100644 --- a/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts +++ b/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts @@ -64,6 +64,10 @@ class StreamingJsonStringExtractor { readonly #keys: Set; #source = ""; #offset = 0; + /** `{`/`[` nesting outside strings. Candidate keys match only at depth 1 — + * the top level of the args object — so a nested object's key (e.g. + * `{"meta":{"content":…}}`) is never captured as a streamed top-level arg. */ + #depth = 0; #state: StreamingJsonStringExtractorState = "scan"; #candidate = ""; #candidateEscaped = false; @@ -82,6 +86,7 @@ class StreamingJsonStringExtractor { reset(): void { this.#source = ""; this.#offset = 0; + this.#depth = 0; this.#state = "scan"; this.#candidate = ""; this.#candidateEscaped = false; @@ -129,6 +134,10 @@ class StreamingJsonStringExtractor { this.#candidateEscaped = false; this.#candidateUnicode = ""; this.#state = "candidate"; + } else if (ch === "{" || ch === "[") { + this.#depth++; + } else if (ch === "}" || ch === "]") { + this.#depth--; } this.#offset++; } @@ -154,7 +163,7 @@ class StreamingJsonStringExtractor { return; } if (ch === '"') { - this.#matchedKey = this.#keys.has(this.#candidate) ? this.#candidate : undefined; + this.#matchedKey = this.#depth === 1 && this.#keys.has(this.#candidate) ? this.#candidate : undefined; this.#state = "afterCandidate"; this.#offset++; return; diff --git a/packages/coding-agent/test/tool-args-reveal.test.ts b/packages/coding-agent/test/tool-args-reveal.test.ts index 7dbe7abd4..651c709f7 100644 --- a/packages/coding-agent/test/tool-args-reveal.test.ts +++ b/packages/coding-agent/test/tool-args-reveal.test.ts @@ -193,6 +193,31 @@ describe("tool args reveal", () => { expect(latest!.content).toBe(`${initialContent}${appendedContent}`); }); + it("ignores streaming string keys nested below the top level", () => { + vi.useFakeTimers(); + const { controller } = makeController({ smooth: false }); + const streamingStringKeys = ["content"]; + + // Prefix ends inside the nested object: the nested "content" key must + // NOT be captured and injected as a top-level preview arg. + const nestedOnly = `{"meta":{"content":"NESTED"`; + const first = controller.setTarget("call-1", nestedOnly, jsonTarget({ streamingStringKeys })); + expect(first.content).toBeUndefined(); + + // Growth past the nested object: the real top-level key must be captured. + const full = `${nestedOnly}"},"content":"real"}`; + const second = controller.setTarget("call-1", full, jsonTarget({ streamingStringKeys })); + expect(second.content).toBe("real"); + + // Reverse order: a nested duplicate arriving AFTER the top-level key must + // not reset/overwrite the captured top-level value. Extractor values are + // merged over the parsed args, so a depth-blind capture would win here. + const reversed = `{"content":"real","meta":{"content":"NESTED"}}`; + const decoded = decodeStreamedToolArgs(reversed, { rawInput: false, streamingStringKeys }); + expect(decoded.content).toBe("real"); + expect(decoded.meta).toEqual({ content: "NESTED" }); + }); + it("decodes the full received buffer, unpaced, when smoothing is disabled", () => { vi.useFakeTimers(); const requestRender = vi.fn();