Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
([#782](https://github.com/code-yeongyu/senpi/pull/782) by [@achieve0410](https://github.com/achieve0410)).
- Route Anthropic provider-native refusal fallbacks through the configured Senpi chain after an active-turn model
change, instead of persisting the server-selected substitute because the run retained the previous model's policy
([#796](https://github.com/code-yeongyu/senpi/pull/796)).
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/modes/interactive/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<object>): 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<object>): 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<object>): string {
let hash = SIGNATURE_HASH_OFFSET_BASIS;
for (const [key, value] of entries) {
hash = hashSignatureEntry(hash, key, value, seen);
}
return formatSignatureHash(hash);
}

function summarizeSignatureValue(
Expand Down Expand Up @@ -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}]`];
}
Expand All @@ -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;
Expand Down
67 changes: 67 additions & 0 deletions packages/coding-agent/test/render-signature.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down