Skip to content
Merged
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
55 changes: 55 additions & 0 deletions apps/sim/stores/terminal/console/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
69 changes: 47 additions & 22 deletions apps/sim/stores/terminal/console/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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> = {}

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)
}

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
}

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)
}

return normalizedObject
}

/**
Expand Down
Loading