From 69ce2a292c6933d6e09c4caebc3b81c6de8fdbd3 Mon Sep 17 00:00:00 2001 From: wonhyo Date: Mon, 10 Aug 2026 12:51:51 +0900 Subject: [PATCH] fix(coding-agent): avoid recursive render tail hashing --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/interactive/changes.md | 18 +++++ .../components/render-signature.ts | 45 +++++++++++-- .../test/render-signature.test.ts | 67 +++++++++++++++++++ 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9a9d52092..3f12a4911 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,9 @@ ### Fixed +- Fixed interactive render signature hashing to process omitted array items and object keys iteratively, preventing stack + overflows on very large tool and assistant values while retaining tail-change cache invalidation. + ### Removed ## [2026.8.10] - 2026-08-10 diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 71b1dc989..d25dcf84f 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,5 +1,23 @@ # changes +## Bound omitted render-signature hashing (2026-08-10) + +### What changed + +- `components/render-signature.ts` now hashes omitted array items and sorted object entries with an iterative FNV-1a + accumulator instead of recursively summarizing sliced tails. This prevents omitted sibling counts in wide arrays and + objects from increasing call-stack depth while preserving cache invalidation and the existing nested-tail depth budget. + +### Why this cannot be expressed externally + +- Assistant-message and tool-execution render signatures are private built-in TUI cache keys; extensions cannot replace + their hashing or invalidation behavior. + +### Expected merge conflict zones + +- LOW: the omitted array/object branches in `components/render-signature.ts` and + `test/render-signature.test.ts`. + ## Correct extension-command immediate-dispatch comments (2026-08-09) ### What changed diff --git a/packages/coding-agent/src/modes/interactive/components/render-signature.ts b/packages/coding-agent/src/modes/interactive/components/render-signature.ts index 5afeea499..1672d66aa 100644 --- a/packages/coding-agent/src/modes/interactive/components/render-signature.ts +++ b/packages/coding-agent/src/modes/interactive/components/render-signature.ts @@ -4,6 +4,9 @@ const SIGNATURE_STRING_SAMPLE_WINDOW_LENGTH = 64; const SIGNATURE_ARRAY_ITEM_LIMIT = 40; const SIGNATURE_OBJECT_KEY_LIMIT = 80; const SIGNATURE_DEPTH_LIMIT = 8; +const SIGNATURE_OMITTED_VALUE_DEPTH = 1; +const SIGNATURE_HASH_OFFSET_BASIS = 0x811c9dc5; +const SIGNATURE_HASH_PRIME = 0x01000193; type RenderSignatureValue = | string @@ -40,17 +43,45 @@ function sampleSignatureString(text: string): string { ].join("\u0000"); } -function hashSignatureString(source: string): string { - let hash = 0x811c9dc5; +function updateSignatureHash(hash: number, source: string): number { for (let index = 0; index < source.length; index++) { hash ^= source.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); + hash = Math.imul(hash, SIGNATURE_HASH_PRIME); } + return hash; +} + +function formatSignatureHash(hash: number): string { return (hash >>> 0).toString(36); } -function hashSignatureValue(value: unknown): string { - return hashSignatureString(JSON.stringify(summarizeSignatureValue(value))); +function hashSignatureString(source: string): string { + return formatSignatureHash(updateSignatureHash(SIGNATURE_HASH_OFFSET_BASIS, source)); +} + +function hashSignatureEntry(hash: number, key: string, value: unknown, seen: WeakSet): number { + const summarized = summarizeSignatureValue(value, key, SIGNATURE_OMITTED_VALUE_DEPTH, seen); + return updateSignatureHash(hash, JSON.stringify([key, summarized])); +} + +function hashSignatureArrayTail(value: readonly unknown[], seen: WeakSet): string { + let hash = SIGNATURE_HASH_OFFSET_BASIS; + for (let index = SIGNATURE_ARRAY_ITEM_LIMIT; index < value.length; index++) { + if (!(index in value)) { + hash = updateSignatureHash(hash, JSON.stringify([String(index), null])); + continue; + } + hash = hashSignatureEntry(hash, String(index), value[index], seen); + } + return formatSignatureHash(hash); +} + +function hashSignatureEntries(entries: readonly (readonly [string, unknown])[], seen: WeakSet): string { + let hash = SIGNATURE_HASH_OFFSET_BASIS; + for (const [key, value] of entries) { + hash = hashSignatureEntry(hash, key, value, seen); + } + return formatSignatureHash(hash); } function summarizeSignatureValue( @@ -97,7 +128,7 @@ function summarizeSignatureValue( .slice(0, SIGNATURE_ARRAY_ITEM_LIMIT) .map((item, index) => summarizeSignatureValue(item, String(index), depth + 1, seen)); if (value.length > SIGNATURE_ARRAY_ITEM_LIMIT) { - const tailHash = hashSignatureValue(value.slice(SIGNATURE_ARRAY_ITEM_LIMIT)); + const tailHash = hashSignatureArrayTail(value, seen); seen.delete(value); return [...summarized, `[+${value.length - SIGNATURE_ARRAY_ITEM_LIMIT} items hash=${tailHash}]`]; } @@ -112,7 +143,7 @@ function summarizeSignatureValue( } if (entries.length > SIGNATURE_OBJECT_KEY_LIMIT) { const omitted = entries.slice(SIGNATURE_OBJECT_KEY_LIMIT); - summarized.__truncatedKeys = `[+${omitted.length} keys hash=${hashSignatureValue(Object.fromEntries(omitted))}]`; + summarized.__truncatedKeys = `[+${omitted.length} keys hash=${hashSignatureEntries(omitted, seen)}]`; } seen.delete(value); return summarized; diff --git a/packages/coding-agent/test/render-signature.test.ts b/packages/coding-agent/test/render-signature.test.ts index e79912a4c..1c8fb8fa5 100644 --- a/packages/coding-agent/test/render-signature.test.ts +++ b/packages/coding-agent/test/render-signature.test.ts @@ -1,7 +1,74 @@ import { describe, expect, test, vi } from "vitest"; import { createBoundedRenderSignature } from "../src/modes/interactive/components/render-signature.ts"; +function createDepthBoundaryValue(marker: string): unknown { + let value: unknown = { marker }; + for (let index = 0; index < 6; index++) { + value = { nested: value }; + } + return value; +} + describe("createBoundedRenderSignature", () => { + test("#given a very large array #when creating a render signature #then hashing does not overflow the stack", () => { + const values = Array.from({ length: 160_000 }, (_item, index) => index); + + expect(() => createBoundedRenderSignature(values)).not.toThrow(); + }); + + test("#given arrays differing after the item limit #when creating signatures #then the tail change is detected", () => { + const unchangedPrefix = Array.from({ length: 40 }, (_item, index) => `prefix-${index}`); + const previous = [...unchangedPrefix, "tail-old"]; + const next = [...unchangedPrefix, "tail-new"]; + + expect(createBoundedRenderSignature(next)).not.toBe(createBoundedRenderSignature(previous)); + }); + + test("#given an omitted array hole and undefined #when creating signatures #then they remain distinct", () => { + const withHole = Array.from({ length: 41 }, () => 0); + delete withHole[40]; + const withUndefined: (number | undefined)[] = Array.from({ length: 41 }, () => 0); + withUndefined[40] = undefined; + + expect(createBoundedRenderSignature(withHole)).not.toBe(createBoundedRenderSignature(withUndefined)); + }); + + test("#given an omitted circular reference #when creating a signature #then ancestor tracking remains bounded", () => { + const values: unknown[] = Array.from({ length: 41 }, () => 0); + values[40] = values; + + expect(() => createBoundedRenderSignature(values)).not.toThrow(); + }); + + test("#given objects differing after the key limit #when creating signatures #then the omitted change is detected", () => { + const entries = Array.from( + { length: 81 }, + (_item, index) => [`key-${index.toString().padStart(3, "0")}`, index] as const, + ); + const previous = Object.fromEntries(entries); + const next = { ...previous, "key-080": "changed" }; + + expect(createBoundedRenderSignature(next)).not.toBe(createBoundedRenderSignature(previous)); + }); + + test("#given deeply nested omitted array values #when creating signatures #then the prior depth budget is preserved", () => { + const unchangedPrefix = Array.from({ length: 40 }, (_item, index) => `prefix-${index}`); + const previous = { content: [...unchangedPrefix, createDepthBoundaryValue("depth-old")] }; + const next = { content: [...unchangedPrefix, createDepthBoundaryValue("depth-new")] }; + + expect(createBoundedRenderSignature(next)).not.toBe(createBoundedRenderSignature(previous)); + }); + + test("#given deeply nested omitted object values #when creating signatures #then the prior depth budget is preserved", () => { + const unchangedPrefix = Object.fromEntries( + Array.from({ length: 80 }, (_item, index) => [`key-${index.toString().padStart(3, "0")}`, index]), + ); + const previous = { details: { ...unchangedPrefix, "zz-tail": createDepthBoundaryValue("depth-old") } }; + const next = { details: { ...unchangedPrefix, "zz-tail": createDepthBoundaryValue("depth-new") } }; + + expect(createBoundedRenderSignature(next)).not.toBe(createBoundedRenderSignature(previous)); + }); + test("#given large strings #when creating a render signature #then string hashing work is bounded", () => { const largeText = `large-signature:${"a".repeat(64 * 1024)}`; const charCodeSpy = vi.spyOn(String.prototype, "charCodeAt");