#12113·qwen-code

bug(acp): reports end_turn after repeated finish_reason=length responses (0.24.0)

Author: erich04Created Sep 17, 2026Updated Sep 17, 2026
Labelspriority/P2type/bugcategory/integrationscope/token-managementscope/ide

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:

bash
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:

bash
node repro.ts ./node_modules/@qwen-code/qwen-code/cli-entry.js

The 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.

Standalone reproduction script
typescript
// 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 });
}
Observed output from a fresh npm installation
{"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.0

The 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 /about session 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:

  1. The OpenAI converter already maps lowercase length to FinishReason.MAX_TOKENS: converter.ts.
  2. The ACP stream consumer passes candidate.finishReason to output capture: Session.ts.
  3. Its stop-hook path defaults to end_turn when 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 密钥。