Skip to content
94 changes: 94 additions & 0 deletions packages/cli/src/serve/routes/session-turn-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { describePromptTurnFailure } from './session.js';

describe('describePromptTurnFailure', () => {
it('keeps Error name and message', () => {
const err = new Error('agent channel closed mid-request');
err.name = 'BridgeChannelClosedError';
Comment thread
yiliang114 marked this conversation as resolved.
expect(describePromptTurnFailure(err)).toBe(
'[BridgeChannelClosedError] agent channel closed mid-request',
);
});

it('extracts data details from Error rejections carrying JSON-RPC data', () => {
const err = Object.assign(new Error('Internal error'), {
name: 'RequestError',
data: { details: 'session not found' },
});
expect(describePromptTurnFailure(err)).toBe(
'[RequestError] session not found',
);
});

it('keeps the Error name prefix over a code prefix', () => {
const err = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
expect(describePromptTurnFailure(err)).toBe('[Error] write EPIPE');
});

it('extracts message and code from a bare JSON-RPC error object', () => {
expect(
describePromptTurnFailure({ code: -32603, message: 'Internal error' }),
).toBe('[code -32603] Internal error');
});

it('prefers JSON-RPC data details over the generic message', () => {
expect(
describePromptTurnFailure({
code: -32603,
message: 'Internal error',
data: { details: 'model provider rejected the request' },
}),
).toBe('[code -32603] model provider rejected the request');
});

it('prefers nested provider error text shipped as parsed data', () => {
expect(
describePromptTurnFailure({
code: -32603,
message: 'Internal error',
data: { error: { message: 'upstream 429 rate limited' } },
}),
).toBe('[code -32603] upstream 429 rate limited');
});

it('reads a plain object message property without a code', () => {
expect(describePromptTurnFailure({ message: 'something broke' })).toBe(
'something broke',
);
});

it('never degrades structured rejections to [object Object]', () => {
const candidates: unknown[] = [
{ code: -32000, message: 'boom' },
{ code: 'EPIPE', message: 'write failed' },
{ data: 'provider closed the stream' },
{ message: 'partial' },
{ code: -32603 },
{},
{ message: '' },
];
for (const candidate of candidates) {
const rendered = describePromptTurnFailure(candidate);
expect(rendered).not.toBe('');
expect(rendered).not.toContain('[object Object]');
}
});

it('renders circular rejections without degrading', () => {
const err: Record<string, unknown> = { code: -32603 };
err['self'] = err;
const rendered = describePromptTurnFailure(err);
expect(rendered).toContain('[code -32603]');
expect(rendered).not.toContain('[object Object]');
});

it('stringifies primitive rejections', () => {
expect(describePromptTurnFailure('socket hang up')).toBe('socket hang up');
});
});
35 changes: 32 additions & 3 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { inspect } from 'node:util';
import {
APPROVAL_MODES,
BTW_MAX_INPUT_LENGTH,
Expand Down Expand Up @@ -46,7 +47,11 @@ import {
type BridgeBranchedSession,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import { parseSessionSource } from '@qwen-code/acp-bridge';
import {
extractErrorCode,
extractErrorMessage,
parseSessionSource,
} from '@qwen-code/acp-bridge';
import {
isReservedLiveSessionSource,
isReservedStandaloneSessionSource,
Expand Down Expand Up @@ -450,6 +455,31 @@ function sendSessionOrganizationError(res: Response, err: unknown): boolean {
return true;
}

/**
* Renders a prompt-turn rejection for the daemon log. Non-Error rejections
* (bare JSON-RPC error objects forwarded by the bridge) must not degrade to
* `[object Object]` — that hid the failure cause in production incidents
* (#10710), so extract the structured message/code instead.
*/
export function describePromptTurnFailure(err: unknown): string {
const detail = extractErrorMessage(err);
Comment thread
yiliang114 marked this conversation as resolved.
if (err instanceof Error) {
return `[${err.name}] ${detail}`;
}
const code = extractErrorCode(err);
// `extractErrorMessage` terminates in `String(err)`, which renders a plain
// object as `[object Object]`. Fall back to `inspect` for those: unlike
// `JSON.stringify` it never throws, so circular and non-serializable
// rejections stay readable instead of degrading again.
const rendered =
typeof err === 'object' &&
err !== null &&
(detail === '' || detail === String(err))
? inspect(err, { depth: 2, breakLength: Infinity })
: detail;
return code === undefined ? rendered : `[code ${code}] ${rendered}`;
}

function parseTranscriptLimitQuery(
rawLimit: unknown,
res: Response,
Expand Down Expand Up @@ -5397,9 +5427,8 @@ export function registerSessionRoutes(
},
(err) => {
if (daemonLog) {
const errName = err instanceof Error ? err.name : undefined;
daemonLog.warn(
`prompt turn failed: ${errName ? `[${errName}] ` : ''}${err instanceof Error ? err.message : String(err)}`,
`prompt turn failed: ${describePromptTurnFailure(err)}`,
{ sessionId, promptId, clientId },
);
}
Expand Down
Loading