ConsoleInterceptor's JSON.stringify renderer erases Error arguments and throws inside the wrapped run
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
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:
- The message of an
Errorargument is destroyed.Errorhas no enumerable own properties, soJSON.stringify(err) === '{}'.agent-serverlogs failures in exactly this shape —src/api/controllers/sessions.ts:139isconsole.error('Failed to create session:', error)— so the line the user reads back isFailed to create session: {}, with the reason removed. It is removed precisely when the run has gone wrong, because these calls are incatchblocks. - A logging call can throw.
JSON.stringifyraisesTypeError: Converting circular structure to JSONon an object with a back-reference, and it does so insideconsole.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:
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')returns100%% 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 inagent-server/src,agent/src,mcp-agent/srcoragent-cli/srcdoes that today (0 matches for a specifier in aconsole.*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 underagent-cli/tests/— #1939config-builder.test.ts, #1948cli-version.test.ts, #2008misc.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'sstreamparameter (:83) is unused onmainand is left unused by this change; deleting it is a separate cleanup.agent-cli's suite onmain:CI=true ./node_modules/.bin/vitest rungives 18 passing tests and 4 files that fail to collect (config-builder,config-loader,config-paths,workspace-config-e2e) withFailed 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 onmain(expected TypeError: Converting circular structure … to be undefined,expected 'value: ' to be 'value: undefined', …) and 6/6 pass after it.tsc --noEmitinagent-clireports 55 errors onmainand 55 with this change; none of them is insrc/utils/console-interceptor.tsor the new test.
Source: bytedance/UI-TARS-desktop