bug(acp): reports end_turn after repeated finish_reason=length responses (0.24.0)
What happened?
With the official npm package @qwen-code/[email protected], a direct qwen --acp client receives stopReason: "end_turn" even when the model's final response is still truncated by its output-token limit.
This is reproducible without a real model service, credentials, an IDE, or a downstream Gateway/adapter. A local HTTP fixture serves valid OpenAI Chat Completions SSE frames: a short text delta, an empty delta carrying finish_reason: "length", and [DONE]. Every recovery request receives the same length-truncated result.
Qwen does attempt output recovery. The issue is that, after five model requests all ending with length, ACP still reports a normal turn ending. The same result occurs with generationConfig.maxRetries: 0 and with that setting omitted. The normal stop control takes one model request and correctly returns end_turn.
| Provider terminal reason on every request | generationConfig.maxRetries |
Observed model requests | ACP session/prompt result |
|---|---|---|---|
stop |
0 |
1 | {"stopReason":"end_turn"} |
length |
0 |
5 | {"stopReason":"end_turn"} |
length |
omitted / default | 5 | {"stopReason":"end_turn"} |
An ACP consumer therefore cannot distinguish this unresolved output-length limit from a normally completed turn and can incorrectly mark partial output as completed.
What did you expect to happen?
When bounded output recovery has not produced a complete response and the final provider finish reason is still length / MAX_TOKENS, return ACP stopReason: "max_tokens" (or an explicit non-success outcome), rather than end_turn.
Already-emitted partial text can remain visible. If a continuation actually succeeds, normal end_turn is appropriate. Cancellation and other failure outcomes should retain their own semantics.
Steps to reproduce
Use Node 24 and install the published package in an empty directory:
mkdir qwen-acp-repro
cd qwen-acp-repro
npm init -y
npm install --save-exact --ignore-scripts --no-audit --no-fund @qwen-code/[email protected]Save the standalone script below as repro.ts, then run:
node repro.ts ./node_modules/@qwen-code/qwen-code/cli-entry.jsThe script creates an isolated temporary configuration and workspace, starts a loopback-only synthetic model server, and exchanges JSON-RPC directly with the Qwen ACP process. It imports only Node built-ins. No Qwen SDK or application adapter is involved. It uses a dummy API key, disables hooks/skills/extensions/telemetry, denies any permission request, and removes the temporary runtime directory on exit.
It prints the three cases above, then fails its assertion because end_turn was received where max_tokens was expected. The five-request count is an observation on 0.24.0, not a hard-coded test requirement.
// Run with Node 24: node repro.ts /absolute/path/to/qwen-code/cli-entry.js
import http from 'node:http';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createInterface } from 'node:readline';
const cliEntry = process.argv[2];
if (!cliEntry) throw new Error('Usage: node repro.ts /path/to/qwen-code/cli-entry.js');
const root = await mkdtemp(path.join(tmpdir(), 'qwen-acp-length-'));
async function runCase(finishReason, maxRetries) {
const directory = path.join(root, `${finishReason}-${maxRetries ?? 'default'}`);
await mkdir(directory);
let requestCount = 0;
const server = http.createServer(async (req, res) => {
for await (const _ of req) { /* Drain the synthetic model request. */ }
if (req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
requestCount++;
res.writeHead(200, { 'content-type': 'text/event-stream' });
const common = { id: 'chatcmpl-repro', object: 'chat.completion.chunk', created: 1, model: 'fixture-model' };
const send = (event) => res.write(`data: ${JSON.stringify(event)}\n\n`);
send({ ...common, choices: [{ index: 0, delta: { role: 'assistant', content: 'partial fixture reply' }, finish_reason: null }] });
send({ ...common, choices: [{ index: 0, delta: {}, finish_reason: finishReason }], usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 } });
res.end('data: [DONE]\n\n');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const settingsPath = path.join(directory, 'settings.json');
const defaultsPath = path.join(directory, 'defaults.json');
await writeFile(defaultsPath, '{}');
await writeFile(settingsPath, JSON.stringify({
security: { auth: { selectedType: 'openai' } },
model: { name: 'fixture-model' },
modelProviders: { openai: [{
id: 'fixture-model', baseUrl: `http://127.0.0.1:${port}/v1`, envKey: 'REPRO_API_KEY',
...(maxRetries === undefined ? {} : { generationConfig: { maxRetries } }),
}] },
general: { enableAutoUpdate: false, chatRecording: false },
telemetry: { enabled: false },
privacy: { usageStatisticsEnabled: false },
agents: { crossSessionMessaging: false },
ui: { enableFollowupSuggestions: false },
context: { fileName: ['REPRO_EMPTY_CONTEXT.md'], includeDirectories: [] },
skills: { disabledLevels: ['project', 'user', 'extension', 'bundled'] },
disableAllHooks: true,
mcp: { excluded: ['*'] },
}));
const env = {};
for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TMPDIR', 'TEMP', 'TMP', 'LANG']) {
if (process.env[key]) env[key] = process.env[key];
}
Object.assign(env, {
QWEN_HOME: directory,
QWEN_RUNTIME_DIR: directory,
QWEN_CODE_SYSTEM_SETTINGS_PATH: settingsPath,
QWEN_CODE_SYSTEM_DEFAULTS_PATH: defaultsPath,
REPRO_API_KEY: 'not-a-real-key',
NO_PROXY: '127.0.0.1,localhost',
no_proxy: '127.0.0.1,localhost',
});
const child = spawn(process.execPath, [path.resolve(cliEntry), '--acp', '--auth-type', 'openai', '--model', 'fixture-model', '--extensions', 'none'], {
cwd: directory, env, stdio: ['pipe', 'pipe', 'pipe'],
});
const exited = new Promise((resolve) => child.once('close', resolve));
let stderr = '';
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-12000); });
let nextId = 0;
const pending = new Map();
const text = [];
function rejectPending(error) {
for (const { reject, timer } of pending.values()) {
clearTimeout(timer);
reject(error);
}
pending.clear();
}
child.on('error', rejectPending);
child.on('exit', (code) => rejectPending(new Error(`Qwen exited (${code}): ${stderr}`)));
const lines = createInterface({ input: child.stdout });
lines.on('line', (line) => {
let message;
try { message = JSON.parse(line); } catch { return; }
if (message.method && message.id !== undefined) {
const reply = message.method === 'session/request_permission'
? { result: { outcome: { outcome: 'cancelled' } } }
: { error: { code: -32601, message: 'Unsupported in this text-only reproduction' } };
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, ...reply }) + '\n');
return;
}
if (message.method === 'session/update') {
const update = message.params?.update;
if (update?.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') text.push(update.content.text);
}
const waiter = pending.get(message.id);
if (waiter) {
clearTimeout(waiter.timer);
pending.delete(message.id);
if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
else waiter.resolve(message.result);
}
});
function rpc(method, params) {
return new Promise((resolve, reject) => {
const id = ++nextId;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Timeout: ${method}; requests=${requestCount}; ${stderr}`));
}, 30000);
pending.set(id, { resolve, reject, timer });
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
});
}
try {
const initialized = await rpc('initialize', { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 'acp-length-repro', version: '1.0.0' } });
const session = await rpc('session/new', { cwd: directory, mcpServers: [] });
const result = await rpc('session/prompt', { sessionId: session.sessionId, prompt: [{ type: 'text', text: 'Reply briefly.' }] });
console.log(JSON.stringify({
agentVersion: initialized.agentInfo?.version,
providerFinishReason: finishReason,
configuredMaxRetries: maxRetries ?? 'default',
requestCount,
assistantText: text.join(''),
acpResult: result,
}));
return result;
} finally {
lines.close();
child.stdin.end();
child.kill();
const killTimer = setTimeout(() => child.kill('SIGKILL'), 2000);
await exited;
clearTimeout(killTimer);
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
}
try {
const normal = await runCase('stop', 0);
const truncated = await runCase('length', 0);
const truncatedDefault = await runCase('length', undefined);
assert.equal(normal.stopReason, 'end_turn');
assert.equal(truncated.stopReason, 'max_tokens');
assert.equal(truncatedDefault.stopReason, 'max_tokens');
} finally {
await rm(root, { recursive: true, force: true });
}{"agentVersion":"0.24.0","providerFinishReason":"stop","configuredMaxRetries":0,"requestCount":1,"assistantText":"partial fixture reply","acpResult":{"stopReason":"end_turn"}}
{"agentVersion":"0.24.0","providerFinishReason":"length","configuredMaxRetries":0,"requestCount":5,"assistantText":"partial fixture replypartial fixture replypartial fixture replypartial fixture replypartial fixture reply","acpResult":{"stopReason":"end_turn"}}
{"agentVersion":"0.24.0","providerFinishReason":"length","configuredMaxRetries":"default","requestCount":5,"assistantText":"partial fixture replypartial fixture replypartial fixture replypartial fixture replypartial fixture reply","acpResult":{"stopReason":"end_turn"}}
node:internal/modules/run_main:107
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'end_turn'
- 'max_tokens'
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 'end_turn',
expected: 'max_tokens',
operator: 'strictEqual',
diff: 'simple'
}
Node.js v24.18.0The intentionally short fixture text isolates finish-reason handling; this test does not claim to have exhausted a real model's token budget or to measure the frequency of truncation in real workloads.
Client information
- Qwen Code: official npm
@qwen-code/[email protected], without local patches. - Release commit:
56b003be0785412ed06673948f781dc70a686b5a(v0.24.0). - OS: macOS 26.5.2, arm64.
- Node: v24.18.0.
- Transport: direct stdio ACP, protocol version 1.
- Model provider: OpenAI-compatible Chat Completions; model ID
fixture-model; a local HTTP fixture serves the response. - Confirmed both against an existing installation and a fresh isolated npm installation. Windows and Linux were not tested.
- Client details above are from the controlled reproduction; no interactive
/aboutsession or real model account is required.
Login information
API Config / --auth-type openai. The configured base URL is an ephemeral http://127.0.0.1:<port>/v1 endpoint. REPRO_API_KEY=not-a-real-key is a placeholder accepted by the fixture; no actual API credentials or OAuth are involved.
Anything else we need to know?
Source observations pinned to the released commit, to help locate the missing terminal mapping:
- The OpenAI converter already maps lowercase
lengthtoFinishReason.MAX_TOKENS: converter.ts. - The ACP stream consumer passes
candidate.finishReasonto output capture: Session.ts. - Its stop-hook path defaults to
end_turnwhen there is no external stop-hook reason or guard continuation: Session.ts.
These observations suggest that the final unresolved provider finish reason needs to participate in ACP terminal selection after output recovery. No proposed patch has been applied or validated here.
I searched existing issues. #9882 concerns uppercase provider finish-reason conversion and is already fixed; this reproduction uses lowercase length, which the converter already recognizes. #3655 reports a similar user-visible symptom, but its root cause was not established, so I cannot confirm it is the same defect.
中文补充:直接调用官方 0.24.0 的 ACP,即使模型连续 5 次都返回 finish_reason: length,最终仍得到 end_turn。千问有尝试续写,问题在于续写后仍未完成,却向 ACP 调用方报告正常结束。上面的脚本不依赖任何下游项目,也不需要真实 API 密钥。
Source: QwenLM/qwen-code