Skip to content

fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode#3749

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
umut-polat:fix/non-interactive-error-double-wrap
May 3, 2026
Merged

fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode#3749
wenshao merged 3 commits into
QwenLM:mainfrom
umut-polat:fix/non-interactive-error-double-wrap

Conversation

@umut-polat

Copy link
Copy Markdown
Contributor

Summary

  • What changed: Fix the non-interactive (-p) error pipeline so an upstream 4xx prints exactly one cleanly-formatted line to stderr and exits with a non-zero code, instead of three lines (the second double-wrapped) followed by a stack trace under "An unexpected critical error occurred:".
  • Why it changed: A user reporting a routine 4xx from their gateway saw what looked like a CLI crash. Tracing the code shows three independent sites all formatting/printing the same error: the stream handler, handleError, and JsonOutputAdapter.emitResult in TEXT mode — with parseAndFormatApiError re-running on its own already-wrapped output along the way.
  • Reviewer focus: packages/cli/src/utils/errors.ts (the new AlreadyReportedError marker + handleError short-circuit) and the catch block in packages/cli/src/nonInteractiveCli.ts (skips adapter emit in TEXT mode for already-reported errors). The idempotency guard in parseAndFormatApiError is the safety net so a future caller can't regress the double-wrap symptom by forgetting to mark its throw.

Validation

  • Commands run:

    npm run lint
    npm run typecheck
    npm test --workspace @qwen-code/qwen-code-core -- --run src/utils/errorParsing.test.ts
    npm test --workspace @qwen-code/qwen-code -- --run src/utils/errors.test.ts
    npm test --workspace @qwen-code/qwen-code -- --run src/nonInteractiveCli.test.ts
    
    # End-to-end against a real provider returning a real 4xx:
    npm run bundle
    OPENAI_API_KEY=$DEEPSEEK_KEY \
    OPENAI_BASE_URL=https://api.deepseek.com/v1 \
      node dist/cli.js --auth-type openai \
        --model "nonexistent-model-12345" -p "say hi"; echo "exit=$?"
  • Prompts / inputs used: nonexistent-model-12345 against DeepSeek's API to force a deterministic 400 from a real provider, plus the new vitest cases for the unit layers.

  • Expected result (after the fix):

    [API Error: 400 The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed nonexistent-model-12345.]
    exit=1
    
  • Observed result: matches expected. Single line, no double-wrap, no An unexpected critical error occurred: framing, exit code is 1. A successful happy-path call with the same flags continues to print only the model output and exit 0 — verified with a known-good model on a separate provider.

  • Quickest reviewer verification path: run the DeepSeek command above (any OpenAI-compatible endpoint with a wrong model name will reproduce the same 4xx; what matters is exactly one stderr line, no double-wrap, no stack trace, exit code != 0). Then git stash the patch and re-run to see the three-line / double-wrap baseline.

  • Evidence:

    Before:

    [API Error: 400 The supported API model names are ... but you passed nonexistent-model-12345.]
    [API Error: 400 The supported API model names are ... but you passed nonexistent-model-12345.]
    [API Error: [API Error: 400 The supported API model names are ... but you passed nonexistent-model-12345.]]
    An unexpected critical error occurred:
    Error: [API Error: 400 The supported API model names are ... but you passed nonexistent-model-12345.]
        at file:///.../dist/cli.js:485490:19
        at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
        at async main (file:///.../dist/cli.js:548854:5)
    

    After:

    [API Error: 400 The supported API model names are ... but you passed nonexistent-model-12345.]
    

    All targeted vitest suites pass:

    • errorParsing.test.ts: 14/14 (3 new idempotency cases)
    • errors.test.ts: 39/39 (1 new AlreadyReportedError short-circuit case)
    • nonInteractiveCli.test.ts: 30/30 (1 new regression case asserting no double-wrap on stderr)

    Pre-existing UI snapshot failures in StatsDisplay, SessionSummaryDisplay, and ArenaCards reproduce on a clean checkout of main and are unrelated to this change.

Scope / Risk

  • Main risk or tradeoff: Behaviour change is observable to anyone who was scraping the previous noisy three-line output. Single-line behaviour matches what most users would expect from an OpenAI-compatible CLI on a 4xx. JSON output mode is unaffected (the adapter remains the canonical channel there).
  • Not covered / not validated: Interactive (TUI) mode was already correct (it calls parseAndFormatApiError exactly once and stores the formatted string as a pending UI item) — no change there. The streaming-without-finish_reason path that surfaces "Model stream ended without a finish reason." flows through the same handler now and benefits from the same de-duplication; no separate test added for that variant since it shares the regression test path.
  • Breaking changes / migration notes: None. AlreadyReportedError is a new export from ./utils/errors.js for callers that want to opt in; existing throws are unaffected.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️ ⚠️
Docker ⚠️ ⚠️ ⚠️
Podman ⚠️ N/A N/A
Seatbelt ⚠️ N/A N/A

Testing matrix notes: developed and validated end-to-end on macOS 26.2 (Node v24.7.0) via npm run bundle + node dist/cli.js. The fix is pure TypeScript with no platform-specific code, runtime, or syscall behaviour, so I'd expect parity on Linux/Windows; happy to extend the matrix if a maintainer prefers.

Fixes #3748

…interactive mode

In non-interactive (-p) mode, any upstream 4xx ended up on stderr three
times: once from the stream-error handler, once from handleError after
the thrown Error.message (already containing the formatted text) was
fed back through parseAndFormatApiError producing
"[API Error: [API Error: ...]]", and once more from
JsonOutputAdapter.emitResult writing the same errorMessage out in TEXT
mode. The top-level catch then framed the resulting throw as
"An unexpected critical error occurred:" with a stack trace, which
made a routine 4xx look like a CLI crash.

Three coordinated changes:

- AlreadyReportedError marks a throw whose message is already on the
  wire. handleError short-circuits on it: no second writeStderrLine,
  no second parseAndFormatApiError, just propagate the exit code.
- The non-interactive stream-error handler now throws
  AlreadyReportedError, and the catch block skips the adapter's
  emitResult in TEXT mode so we don't get a third copy.
- The top-level .catch in packages/cli/index.ts treats
  AlreadyReportedError as a routine, already-reported failure: exit
  with the carried code without printing the "unexpected critical"
  framing or the stack trace.

parseAndFormatApiError is also made idempotent — input that already
starts with "[API Error: " and ends with "]" is returned unchanged.
That is the safety net: even if a future caller forgets to mark its
throw, the double-wrap symptom is impossible.

Tests cover all three layers: idempotency in errorParsing, the
short-circuit in handleError, and a regression test on
runNonInteractive that asserts no "[API Error: [API Error: ...]" line
is ever produced on stderr.

Fixes QwenLM#3748
Bojun-Vvibe added a commit to Bojun-Vvibe/oss-contributions that referenced this pull request Apr 29, 2026
- google-gemini/gemini-cli#26225: MessageBus.publish now throws after emit('error'), MessageBus.request awaits and rejects on publish failure to fix latent hang where invalid/policy-denied requests would set up a response listener that never resolved (merge-after-nits)
- QwenLM/qwen-code#3749: AlreadyReportedError marker + handleError/JsonAdapter short-circuit + parseAndFormatApiError idempotency net to stop double-wrap '[API Error: [API Error: ...]]' and duplicate stderr emission in non-interactive mode (merge-as-is)
- INDEX.md: append drip-188 section (8 entries, 4 merge-as-is + 4 merge-after-nits, 5/6 repo coverage with goose skipped)
Comment thread packages/core/src/utils/errorParsing.ts Outdated
function isAlreadyFormatted(value: string): boolean {
// Trailing ']' check guards against false positives where a raw upstream
// message just happens to start with the prefix mid-string.
return value.startsWith(API_ERROR_PREFIX) && value.trimEnd().endsWith(']');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] isAlreadyFormatted() only recognizes already-formatted messages when the whole string ends with ], but 429/rate-limit formatting appends quota guidance after the closing bracket. If one of those formatted 429 messages is parsed again, this guard does not match and the message can still be double-wrapped as [API Error: [API Error: ...]...]; for StructuredError with status === 429, the second pass can also append quota guidance again, possibly with a different auth-specific suffix.

Please make the idempotency check accept the known 429 suffix form, for example by detecting the formatted [API Error: ...] header without requiring the entire trimmed string to end with ], and add a regression test for an already-formatted 429 string with appended quota guidance.

— gpt-5.5 via Qwen Code /review

Comment thread packages/cli/src/nonInteractiveCli.ts Outdated
// primary output channel there, not a duplicate of stderr.
const isAlreadyReportedError =
error instanceof Error &&
(error as { isAlreadyReported?: boolean }).isAlreadyReported === true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AlreadyReportedError is already imported here, so error instanceof AlreadyReportedError would be simpler and consistent with index.ts:98. The duck-typing check duplicates the logic from errors.ts without the helper.

@umut-polat

umut-polat commented Apr 30, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both review points in 96a460e.

  • isAlreadyFormatted() now treats already-formatted 429 messages with appended quota guidance as already formatted, so they are not wrapped again.
  • Added regression tests for already-formatted 429 messages (plain string and StructuredError.message path).
  • Switched non-interactive check to error instanceof AlreadyReportedError (already imported in this file).

Thanks for the careful review.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Multi-agent review (Correctness, Test Coverage, 3am-oncall, Maintainer; Security/Performance skipped — error-pipeline change with no perf surface and no auth/data handling). The fix is correct: stream-error → AlreadyReportedError marker → top-level catch propagates exit code without reformat/reprint, with parseAndFormatApiError idempotency as the safety net. The two prior open inline reviews are demonstrably addressed in 96a460e:

  • @wenshao's Critical on isAlreadyFormatted() 429 handling — now checks all three rate-limit suffix constants in errorParsing.ts:58-62, with new idempotency tests at errorParsing.test.ts:133/146.
  • @yiliang114's instanceof AlreadyReportedError suggestion — applied at nonInteractiveCli.ts:779 and index.ts:102. The errors.ts helper retains duck-typing intentionally as a separate decision (see Suggestion 3 inline).

Verdict: Comment — 0 Critical, 7 Suggestion (inline). The Suggestions cluster around three durable concerns:

  1. Hidden coupling between getRateLimitMessage() and isAlreadyFormatted() with no test that they stay in sync — the next authType-specific suffix added to getRateLimitMessage will silently regress the 429 idempotency guard. The current 429 idempotency tests only pin the DEFAULT suffix; USE_GEMINI / VERTEX branches are uncovered. (Inline 1 + 4)
  2. Three different conventions for detecting the marker (duck-type helper in errors.ts, instanceof in index.ts/nonInteractiveCli.ts, plus a discriminator field whose JSDoc rationale doesn't match how callers actually use it). Future maintainers won't know which convention to copy. (Inline 3)
  3. The most user-visible part of the fix — the main().catch short-circuit at packages/cli/index.ts:96-104 that suppresses the "unexpected critical error" framing — has no test coverage at all. Removing the instanceof AlreadyReportedError block would not fail any test, just regress the original bug. The companion JSON-mode short-circuit in errors.ts:161-167 is also untested. (Inline 5 + 6)

Also flagged: API_ERROR_PREFIX is exported with a comment referencing a non-existent ALREADY_FORMATTED symbol and has no external consumer (Inline 2); the regression test's rejects.toBeTruthy() doesn't pin the marker contract that the rest of the fix depends on (Inline 7).

Skipped (terminal-only Nice-to-have)

  • Cron-drain second AlreadyReportedError throw site at nonInteractiveCli.ts:570 is structurally untested (cron mocked off in all suites). Risk low — literal copy of tested site — but author's "shares the regression test path" is inaccurate for that branch.
  • Regression-test regex /\[API Error: /g count at nonInteractiveCli.test.ts:920-927 is fragile to future fixtures whose upstream message contains [API Error: legitimately.

Pipeline note

1 prior @wenshao Critical comment classified as Stale (on superseded commit 128430de) — verified addressed in HEAD, not re-raised. @yiliang114's open inline comment (no via Qwen Code footer, so not in presubmit's bucket) also verified addressed at nonInteractiveCli.ts:779.

via Qwen Code /review

Comment thread packages/core/src/utils/errorParsing.ts Outdated
// Prefix this function emits when wrapping any error message. Exported so
// callers (and the function itself) can detect already-formatted strings and
// skip re-wrapping. See ALREADY_FORMATTED below.
export const API_ERROR_PREFIX = '[API Error: ';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] API_ERROR_PREFIX is exported (and re-exported via packages/core/src/index.ts) but has no external consumer — its only reader is isAlreadyFormatted() two lines below, which is itself unexported. The header comment also says "See ALREADY_FORMATTED below" but the symbol below is named isAlreadyFormatted, not ALREADY_FORMATTED — that reference is dangling.

Future-maintainer trap: a reader sees an exported API_ERROR_PREFIX with a comment promising callers can detect already-formatted strings, hunts for ALREADY_FORMATTED, finds nothing, then either gives up or re-implements the prefix-only check (the broken-on-429 logic this PR just fixed) instead of consolidating on one tested entry point.

Pick one:

  • Drop the export. API_ERROR_PREFIX becomes a file-local const and the comment loses the misleading "Exported so callers can detect…" line.
  • Promote isAlreadyFormatted to the export. Add export to its declaration, fix the comment to say "See isAlreadyFormatted below for the matching guard", and external callers get a single tested entry point.

via Qwen Code /review

* Error.message, and the message reaches us a second time, we should return it
* unchanged rather than producing "[API Error: [API Error: ...]]".
*/
function isAlreadyFormatted(value: string): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] isAlreadyFormatted and getRateLimitMessage are coupled by hand: every authType-specific suffix added to the switch in getRateLimitMessage (lines 18-28) must also be appended to the three-way || chain at lines 58-62. Today there are five AuthType values but only three distinct suffix constants (USE_OPENAI / QWEN_OAUTH / USE_ANTHROPIC all fall through to _DEFAULT), so the coupling happens to be intact — but it's a side effect, not an invariant.

When a future patch adds, say, case AuthType.USE_OPENAI: return RATE_LIMIT_ERROR_MESSAGE_OPENAI;, the idempotency guard silently drops to "matches none of the three known suffixes", and the OpenAI 429 path quietly regresses to [API Error: [API Error: ...]]\n<openai message> — exactly the bug this PR fixes. The omission is invisible at the diff site (the developer is editing getRateLimitMessage, not isAlreadyFormatted).

Suggested fix: drive both functions from a single source of truth — e.g. const RATE_LIMIT_SUFFIXES = { USE_GEMINI: '...', VERTEX: '...', DEFAULT: '...' } as const; — and have isAlreadyFormatted iterate over Object.values(RATE_LIMIT_SUFFIXES). Pair with a parametric test that loops every AuthType value through getRateLimitMessage and asserts the constructed [API Error: foo]<suffix> round-trips through parseAndFormatApiError unchanged.

via Qwen Code /review

Comment thread packages/cli/src/utils/errors.ts Outdated
}
}

function isAlreadyReported(error: unknown): error is AlreadyReportedError {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Three different detection conventions for the same marker:

  • errors.ts:46-50 — duck-type helper (error.isAlreadyReported === true)
  • index.ts:102instanceof AlreadyReportedError
  • nonInteractiveCli.ts:779instanceof AlreadyReportedError

The discriminator field isAlreadyReported = true as const (line 35) carries the JSDoc "Discriminator so tests and other consumers don't need instanceof" — but errors.test.ts imports the class and uses new AlreadyReportedError(...) directly, no consumer reaches for the discriminator. There's also no cross-realm scenario in this repo (no vm, no Workers in the CLI bundle) that would justify duck-typing over instanceof.

The current half-and-half is the worst of both worlds: a future maintainer adding a fourth detection site has no rule to follow. Pick one:

  • Drop the discriminator and helper, use instanceof everywhere. Consistent with the two existing call sites; no behavior change.
  • Export the helper, use it everywhere. Add a comment "isAlreadyReported(err) instead of err instanceof AlreadyReportedError to tolerate cross-bundle class-identity mismatches" so future readers know why the indirection exists.

The @yiliang114 comment was about the call-site duck-typing in nonInteractiveCli.ts (correctly fixed); this is the orthogonal helper-vs-instanceof inconsistency that remains.

via Qwen Code /review

expect(parseAndFormatApiError(formatted)).toBe(formatted);
});

it('returns an already-formatted 429 plain string with quota guidance unchanged', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Both new "already-formatted 429" idempotency cases (lines 133-138 and 146-151) pin the same RATE_LIMIT_ERROR_MESSAGE_DEFAULT suffix ("Possible quota limitations in place..."). The other two suffix branches in isAlreadyFormattedRATE_LIMIT_ERROR_MESSAGE_USE_GEMINI ("...request a quota increase through AI Studio...") and RATE_LIMIT_ERROR_MESSAGE_VERTEX ("...through Vertex...") — are not covered.

This is exactly the regression class @wenshao's prior [Critical] comment was protecting against. If a future refactor renames a quota-suffix constant or drops one branch from the || chain at errorParsing.ts:58-62, USE_GEMINI- or VERTEX-authenticated 429s would silently regress to double-wrap and these tests would still pass.

Suggested addition:

it('returns an already-formatted 429 USE_GEMINI message unchanged', () => {
  const formatted =
    '[API Error: Rate limit exceeded]\nPlease wait and try again later. To increase your limits, request a quota increase through AI Studio, or switch to another /auth method';
  expect(parseAndFormatApiError(formatted, AuthType.USE_GEMINI)).toBe(formatted);
});
it('returns an already-formatted 429 VERTEX message unchanged', () => {
  const formatted =
    '[API Error: Rate limit exceeded]\nPlease wait and try again later. To increase your limits, request a quota increase through Vertex, or switch to another /auth method';
  expect(parseAndFormatApiError(formatted, AuthType.USE_VERTEX_AI)).toBe(formatted);
});

via Qwen Code /review

});
});

describe('in JSON mode', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The 'in JSON mode' describe block has no test for the isAlreadyReported short-circuit at errors.ts:160-167 — only the TEXT-mode case is pinned (line 208). The JSON-mode branch allocates a JsonFormatter, picks customErrorCode ?? error.exitCode, calls formatter.formatError, writes via writeStderrLine, and exits with getNumericExitCode. None of that is exercised.

A refactor of JsonFormatter.formatError or the exit-code resolution path could silently break JSON-mode error reporting for AlreadyReportedError — exactly the regression category this PR set out to make robust. The failure surface is "headless JSON consumers see the wrong exit code or a malformed error envelope".

Suggested test (sibling to the existing TEXT-mode case):

it('does not reformat or reprint AlreadyReportedError in JSON mode', async () => {
  // ...mode=JSON setup...
  const reported = new AlreadyReportedError('[API Error: 402 ...]', 42);
  await expect(handleError(reported, mockConfig)).rejects.toThrow();
  expect(mockWriteStderrLine).toHaveBeenCalledTimes(1);
  expect(mockWriteStderrLine.mock.calls[0][0]).toContain('"errorCode":42'); // or whatever JsonFormatter envelope shape
  expect(processExitMock).toHaveBeenCalledWith(42);
});

via Qwen Code /review

Comment thread packages/cli/index.ts
// "An unexpected critical error occurred:" with a stack trace — that
// framing is for genuinely unexpected, programmer-level bugs, and a
// routine 4xx from an upstream API does not qualify.
if (error instanceof AlreadyReportedError) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This block — the part of the fix responsible for suppressing the literal An unexpected critical error occurred:\n<stack trace> framing called out in the bug report — has zero test coverage. There is no index.test.ts, and the runNonInteractive regression tests can't reach main().catch (they call runNonInteractive directly).

Deleting these three lines (if (error instanceof AlreadyReportedError) { process.exit(error.exitCode); }) would not fail any test. The control flow falls through to console.error('An unexpected critical error occurred:') plus the stack trace — exactly the original bug.

Two options to pin this:

  1. Pin the producer end via the regression test. In nonInteractiveCli.test.ts:902, replace rejects.toBeTruthy() with rejects.toBeInstanceOf(AlreadyReportedError) (see separate inline). That at least guarantees runNonInteractive throws the marker; the index.ts catch then just has to recognize it.
  2. Add a small index.test.ts. Mock main to reject with new AlreadyReportedError('x', 7); assert console.error was NOT called with the framing string and process.exit was called with 7.

Both together is even better — option 1 pins the contract, option 2 pins the consumer.

via Qwen Code /review

'Test input',
'prompt-id-double-wrap',
),
).rejects.toBeTruthy();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] rejects.toBeTruthy() only verifies that something was thrown. It does not pin the contract that the rest of the fix depends on:

  • index.ts:102 short-circuits on error instanceof AlreadyReportedError. If a refactor of this code path replaces AlreadyReportedError with a plain new Error(text) while still preventing the double-wrap, this test passes — but the index.ts short-circuit silently breaks, and the user is back to seeing An unexpected critical error occurred: + stack trace (exactly the symptom the bug report names as item Are you interested in AI Terminal? #4 in the "before" output).
  • error.exitCode === 1 is also unverified.

Suggested tightening:

const thrown = await expect(
  runNonInteractive(/* ... */),
).rejects.toBeInstanceOf(AlreadyReportedError);
// or if you'd rather assert against the rejected value:
await expect(runNonInteractive(/* ... */)).rejects.toMatchObject({
  isAlreadyReported: true,
  exitCode: 1,
});

This pins the marker contract end-to-end (producer and consumer) without adding a separate index.test.ts.

via Qwen Code /review

@umut-polat

Copy link
Copy Markdown
Contributor Author

Thanks, I addressed the follow-up review comments in e3d3f3c.

Changes in this pass:

  • removed the unused API_ERROR_PREFIX export and cleaned up the surrounding comment
  • made the 429 suffix handling use a single source of truth for both formatting and idempotency checks
  • standardized AlreadyReportedError detection on instanceof
  • added coverage for Gemini/Vertex 429 idempotency, JSON-mode AlreadyReportedError, and the non-interactive throw contract

// See the matching note in the first stream loop above —
// we mark the throw so handleError doesn't reformat or
// reprint downstream.
throw new AlreadyReportedError(errorText);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This second throw new AlreadyReportedError(errorText) mirrors the first-turn loop at line 399, but the regression test at nonInteractiveCli.test.ts:867 only exercises the first. Every test in the suite mocks isCronEnabled to false (line 161-162), so the drain-loop branch is unreachable from CI today. A future refactor that changes the throw class here (or drops the throw) would silently re-introduce the [API Error: [API Error: ...]] double-wrap for cron / background-task notification flows, and CI would stay green.

Consider adding a sibling regression test that enables isCronEnabled, seeds a job whose itemStream yields a GeminiEventType.Error event, and asserts the same triple the first-loop test pins: rejects.toBeInstanceOf(AlreadyReportedError), no [API Error: [API Error: substring, and at most one [API Error: per stderr line.

— claude-opus-4-7[1m] via Qwen Code /qreview

const skipAdapterEmit =
outputFormat === OutputFormat.TEXT && isAlreadyReportedError;

if (!skipAdapterEmit) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The skipAdapterEmit guard is the part of the fix that suppresses the duplicate [API Error: ...] stderr line (per the comment at lines 772-778), but the regression test at nonInteractiveCli.test.ts:867 doesn't directly pin this behavior. The existing assertion (nonInteractiveCli.test.ts:928) counts [API Error: occurrences per line — if a future refactor deletes this guard, two separate stderr writes appear (the stream-error line and the adapter's emit), each with only one prefix per line, and the per-line assertion still passes. The test comment at 874-877 explicitly says "JsonOutputAdapter also emits the result message on the error path, which legitimately hits stderr in TEXT mode" — that's now stale: with this guard the adapter intentionally does NOT emit in TEXT-mode AlreadyReportedError.

A total-count assertion would close the gap. The test already joins all stderr writes into stderrOutput at line 909 — adding the following one-liner pins the contract:

expect(stderrOutput.match(/\[API Error: /g)?.length ?? 0).toBe(1);

Also worth refreshing the comment at 874-877 to reflect post-PR behavior.

— claude-opus-4-7[1m] via Qwen Code /qreview

@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label May 1, 2026
@wenshao wenshao merged commit a08d48b into QwenLM:main May 3, 2026
13 checks passed
TaimoorSiddiquiOfficial pushed a commit to TaimoorSiddiquiOfficial/HopCode that referenced this pull request May 3, 2026
DragonnZhang pushed a commit that referenced this pull request May 8, 2026
…interactive mode (#3749)

* fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode

In non-interactive (-p) mode, any upstream 4xx ended up on stderr three
times: once from the stream-error handler, once from handleError after
the thrown Error.message (already containing the formatted text) was
fed back through parseAndFormatApiError producing
"[API Error: [API Error: ...]]", and once more from
JsonOutputAdapter.emitResult writing the same errorMessage out in TEXT
mode. The top-level catch then framed the resulting throw as
"An unexpected critical error occurred:" with a stack trace, which
made a routine 4xx look like a CLI crash.

Three coordinated changes:

- AlreadyReportedError marks a throw whose message is already on the
  wire. handleError short-circuits on it: no second writeStderrLine,
  no second parseAndFormatApiError, just propagate the exit code.
- The non-interactive stream-error handler now throws
  AlreadyReportedError, and the catch block skips the adapter's
  emitResult in TEXT mode so we don't get a third copy.
- The top-level .catch in packages/cli/index.ts treats
  AlreadyReportedError as a routine, already-reported failure: exit
  with the carried code without printing the "unexpected critical"
  framing or the stack trace.

parseAndFormatApiError is also made idempotent — input that already
starts with "[API Error: " and ends with "]" is returned unchanged.
That is the safety net: even if a future caller forgets to mark its
throw, the double-wrap symptom is impossible.

Tests cover all three layers: idempotency in errorParsing, the
short-circuit in handleError, and a regression test on
runNonInteractive that asserts no "[API Error: [API Error: ...]" line
is ever produced on stderr.

Fixes #3748

* fix(cli): make API error formatting idempotent for 429 suffix + use AlreadyReportedError

* fix(cli): follow up on error reporting review
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: Aryan Sawant <156219699+aryanjsawant@users.noreply.github.com>
Co-authored-by: neo.alienson <neo@01man.com>
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…interactive mode (QwenLM#3749)

* fix(cli): stop double-wrapping and double-printing API errors in non-interactive mode

In non-interactive (-p) mode, any upstream 4xx ended up on stderr three
times: once from the stream-error handler, once from handleError after
the thrown Error.message (already containing the formatted text) was
fed back through parseAndFormatApiError producing
"[API Error: [API Error: ...]]", and once more from
JsonOutputAdapter.emitResult writing the same errorMessage out in TEXT
mode. The top-level catch then framed the resulting throw as
"An unexpected critical error occurred:" with a stack trace, which
made a routine 4xx look like a CLI crash.

Three coordinated changes:

- AlreadyReportedError marks a throw whose message is already on the
  wire. handleError short-circuits on it: no second writeStderrLine,
  no second parseAndFormatApiError, just propagate the exit code.
- The non-interactive stream-error handler now throws
  AlreadyReportedError, and the catch block skips the adapter's
  emitResult in TEXT mode so we don't get a third copy.
- The top-level .catch in packages/cli/index.ts treats
  AlreadyReportedError as a routine, already-reported failure: exit
  with the carried code without printing the "unexpected critical"
  framing or the stack trace.

parseAndFormatApiError is also made idempotent — input that already
starts with "[API Error: " and ends with "]" is returned unchanged.
That is the safety net: even if a future caller forgets to mark its
throw, the double-wrap symptom is impossible.

Tests cover all three layers: idempotency in errorParsing, the
short-circuit in handleError, and a regression test on
runNonInteractive that asserts no "[API Error: [API Error: ...]" line
is ever produced on stderr.

Fixes QwenLM#3748

* fix(cli): make API error formatting idempotent for 429 suffix + use AlreadyReportedError

* fix(cli): follow up on error reporting review
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-interactive (-p) mode prints API errors three times and double-wraps the message

4 participants