fix(terminal): truncate console values by size and cycles, not nesting depth (#4924)

normalizeConsoleValue replaced any value at depth >= 6 with
[Truncated object], discarding tiny payloads solely for their position
in the tree (agent tool-call rows sit at exactly depth 6). The depth
cap was also the only guard against infinite recursion on circular
structures.

Add a path-tracked WeakSet so true ancestor cycles resolve to
[Circular] while values shared across sibling positions still render
fully, and raise MAX_DEPTH to 12 as a pathological-nesting backstop.
Actual size stays bounded by the existing 50KB string cap and 256KB
byte cap.
This commit is contained in:
Waleed
2026-06-09 11:10:28 -07:00
committed by GitHub
parent 24f04162fb
commit 37f1141fc0
2 changed files with 105 additions and 25 deletions
@@ -34,6 +34,61 @@ describe('terminal console utils', () => {
expect(result).toContain('"name": "root"')
})
it('preserves small objects nested at the agent tool-call depth', () => {
const output = normalizeConsoleOutput({
toolCalls: {
list: [
{
name: 'table_query_rows',
result: {
rows: [{ data: { deal_id: 'DEAL-001', client_name: 'Jennifer Martinez' } }],
},
},
],
},
}) as {
toolCalls: { list: Array<{ result: { rows: Array<{ data: Record<string, unknown> }> } }> }
}
const row = output.toolCalls.list[0].result.rows[0]
expect(row).not.toBe('[Truncated object]')
expect(row.data.deal_id).toBe('DEAL-001')
expect(row.data.client_name).toBe('Jennifer Martinez')
})
it('resolves true circular references without infinite recursion', () => {
const circular: { name: string; self?: unknown } = { name: 'root' }
circular.self = circular
const output = normalizeConsoleOutput(circular) as { name: string; self: unknown }
expect(output.name).toBe('root')
expect(output.self).toBe('[Circular]')
})
it('renders a value shared across sibling positions fully (not circular)', () => {
const shared = { x: 1 }
const output = normalizeConsoleOutput({ a: shared, b: shared }) as {
a: { x: number }
b: { x: number }
}
expect(output.a).toEqual({ x: 1 })
expect(output.b).toEqual({ x: 1 })
})
it('truncates structures nested beyond MAX_DEPTH as a backstop', () => {
let deep: Record<string, unknown> = { value: 'leaf' }
for (let i = 0; i < TERMINAL_CONSOLE_LIMITS.MAX_DEPTH + 2; i++) {
deep = { nested: deep }
}
const serialized = safeConsoleStringify(normalizeConsoleOutput(deep))
expect(serialized).toContain('[Truncated object]')
expect(serialized).not.toContain('leaf')
})
it('truncates oversized nested strings in console output', () => {
const output = normalizeConsoleOutput({
stdout: 'x'.repeat(TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH + 100),
+50 -25
View File
@@ -9,7 +9,7 @@ export const TERMINAL_CONSOLE_LIMITS = {
MAX_STRING_LENGTH: 50_000,
MAX_OBJECT_KEYS: 100,
MAX_ARRAY_ITEMS: 100,
MAX_DEPTH: 6,
MAX_DEPTH: 12,
MAX_SERIALIZED_BYTES: 256 * 1024,
MAX_SERIALIZED_PREVIEW_LENGTH: 10_000,
} as const
@@ -92,8 +92,18 @@ export function safeConsoleStringify(value: unknown): string {
/**
* Produces a terminal-safe representation of any value.
*
* Recursion is bounded by two independent guards: `seen` tracks the current
* ancestor chain so true circular references resolve to `[Circular]` (a value
* reused across sibling positions is not a cycle and renders fully), and
* `MAX_DEPTH` is a pathological-nesting backstop. Actual payload size is bounded
* downstream by `truncateString` and `capNormalizedValue`, not by depth.
*/
export function normalizeConsoleValue(value: unknown, depth = 0): unknown {
export function normalizeConsoleValue(
value: unknown,
depth = 0,
seen: WeakSet<object> = new WeakSet()
): unknown {
if (value === null || value === undefined) {
return value
}
@@ -130,33 +140,48 @@ export function normalizeConsoleValue(value: unknown, depth = 0): unknown {
return `[Truncated ${Array.isArray(value) ? 'array' : 'object'}]`
}
if (Array.isArray(value)) {
const normalizedItems = value
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
.map((item) => normalizeConsoleValue(item, depth + 1))
const objectValue = value as object
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
normalizedItems.push(
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
)
if (seen.has(objectValue)) {
return '[Circular]'
}
seen.add(objectValue)
try {
if (Array.isArray(value)) {
const normalizedItems = value
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
.map((item) => normalizeConsoleValue(item, depth + 1, seen))
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
normalizedItems.push(
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
)
}
return normalizedItems
}
return normalizedItems
const objectEntries = Object.entries(value as Record<string, unknown>)
const normalizedObject: Record<string, unknown> = {}
for (const [key, entryValue] of objectEntries.slice(
0,
TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
)) {
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1, seen)
}
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
normalizedObject.__simTruncatedKeys =
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
}
return normalizedObject
} finally {
seen.delete(objectValue)
}
const objectEntries = Object.entries(value as Record<string, unknown>)
const normalizedObject: Record<string, unknown> = {}
for (const [key, entryValue] of objectEntries.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS)) {
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1)
}
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
normalizedObject.__simTruncatedKeys =
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
}
return normalizedObject
}
/**