ConsoleInterceptor's JSON.stringify renderer erases Error arguments and throws inside the wrapped run

Author: sxh313Created Sep 23, 2026Updated Sep 23, 2026

Version

main @ c2ad42e3; multimodal/tarko/agent-cli/package.json is version 0.3.0.

Issue Description

ConsoleInterceptor builds every captured line by hand, with JSON.stringify as the renderer for non-string arguments:

multimodal/tarko/agent-cli/src/utils/console-interceptor.ts:86-88

typescript
const message = args
  .map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg)))
  .join(' ');

Node's own console renders its arguments with util.format (→ util.inspect). The buffer therefore is not the text the interceptor suppressed, and in the one command that uses it the buffer is all the user gets: tarko run silences the real write (src/core/commands/run.ts:35 silent: shouldSilenceLogs, :122 silent: !isDebug) and then replays the buffer — as the logs field of --format json (run.ts:45, :131) or after a --- Logs --- header (run.ts:55-58, :141-144). That invocation is documented: agent-cli/README.md:85 shows tarko run --headless --input "Analyze files" --include-logs.

Two consequences:

  1. The message of an Error argument is destroyed. Error has no enumerable own properties, so JSON.stringify(err) === '{}'. agent-server logs failures in exactly this shape — src/api/controllers/sessions.ts:139 is console.error('Failed to create session:', error) — so the line the user reads back is Failed to create session: {}, with the reason removed. It is removed precisely when the run has gone wrong, because these calls are in catch blocks.
  2. A logging call can throw. JSON.stringify raises TypeError: Converting circular structure to JSON on an object with a back-reference, and it does so inside console.warn(...), i.e. at the call site in the code being run — where the un-intercepted console would simply have printed [Circular *1]. Suppressing output must not change the control flow of the program whose output is being suppressed.

How ordinary is argument #2 shape? Scanning the 103 .ts files under agent-server/src, agent/src, mcp-agent/src and agent-cli/src on main for single-line console.* calls, 70 pass at least one non-string argument, 63 of them in agent-server/src — which is the code that executes inside the wrapped region (run.ts:24/:81 wrap the agent run and the whole AgentServer start → oneshot query → stop lifecycle). Multi-line calls were skipped, so 70 is a lower bound; all of them are rendered by the line above.

Measured

Ground truth is taken independently of the fix: for each case the probe hooks process.stdout.write / process.stderr.write and calls the real console, which gives the text the user would have seen. The same call is then run through ConsoleInterceptor.run(…, { silent: true, capture: true }) against the base module and the fixed module, both imported into one Node process (--experimental-strip-types; the base copy comes from git show c2ad42e3:…). For the Error rows the at … stack lines are dropped before comparing, because the two runs have different call depths.

case what console prints captured on main captured with util.format
real site sessions.ts:139 Failed to create session: Error: connect ECONNREFUSED 127.0.0.1:8000 Failed to create session: {} identical to console
real site sessions.ts:131 Return initializationEvents [ { type: 'text', content: 'hello' } ] Return initializationEvents [{"type":"text","content":"hello"}] identical to console
synthetic: argument with a back-reference client state: <ref *1> { name: 'mcp-client', … self: [Circular *1] } TypeError: Converting circular structure to JSON thrown at the console.warn call identical to console
synthetic: undefined argument value: undefined value: identical to console
synthetic: %s specifier with an argument Error getting session details for sess-42: { status: 500 } Error getting session details for %s: sess-42 {"status":500} identical to console
control: single string argument (the shape of the banner logged at start.ts:82) Workspace: C:\work\demo identical to console identical to console

Five rows differ from the console on main and match it after the change; the string-only row is identical in all three columns.

Expected vs. Actual

Expected: a captured line is the line that was suppressed. Actual: for the agent-server error handlers it is the message with the error replaced by {}, and for a circular argument the console call itself raises inside the wrapped run.

Suggested fix

Let the interceptor use the renderer the console uses:

typescript
import { format } from 'util';
…
const message = format(...args);

Two things were checked about the blast radius of that swap:

  • a call with one string argument is unchanged, including strings containing % — format('100%% done') returns 100%% done, so the startup banner/box output (src/core/commands/start.ts:82) is byte-identical;
  • substitution of %s-style specifiers only starts when extra arguments are present. No call site in agent-server/src, agent/src, mcp-agent/src or agent-cli/src does that today (0 matches for a specifier in a console.* call), so this is inert in-repo — it is the case where the new behaviour matches the console and the old one did not.

Rendering consequence worth stating: an object payload now occupies the same (possibly multi-line) form the console prints, instead of one JSON line. getCapturedOutput() is still one entry per call and run.ts joins with \n, so the shape of --include-logs output does not change.

Additional context

  • Of the 135 open pull requests at the time of scanning, none touches agent-cli/src/utils/console-interceptor.ts. Others do add files under agent-cli/tests/ — #1939 config-builder.test.ts, #1948 cli-version.test.ts, #2008 misc.test.ts — so the regression test here is its own new file (tests/console-interceptor.test.ts) and cannot collide with any of them.
  • createInterceptor's stream parameter (:83) is unused on main and is left unused by this change; deleting it is a separate cleanup.
  • agent-cli's suite on main: CI=true ./node_modules/.bin/vitest run gives 18 passing tests and 4 files that fail to collect (config-builder, config-loader, config-paths, workspace-config-e2e) with Failed to resolve entry for package "@tarko/…" — unbuilt workspace dependencies, not test failures. With this change: the same 4 collection failures, 24 passing tests. The new file goes red→green: 5 of its 6 cases fail on main (expected TypeError: Converting circular structure … to be undefined, expected 'value: ' to be 'value: undefined', …) and 6/6 pass after it.
  • tsc --noEmit in agent-cli reports 55 errors on main and 55 with this change; none of them is in src/utils/console-interceptor.ts or the new test.

Source: bytedance/UI-TARS-desktop