mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-26 00:39:51 +00:00
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
Managed Agent gate / Select the Managed Agent tier (push) Waiting to run
Managed Agent gate / SDK Java () (push) Blocked by required conditions
Managed Agent gate / Managed Agent gate (push) Blocked by required conditions
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
SDK Java Contract Open PRs / Re-check open PRs against main (push) Waiting to run
SDK Java Flyway Open PRs / Re-check open PRs against main (push) Waiting to run
Verify Landlock helper / Native Linux CLI boundary (push) Waiting to run
Verify Landlock helper / Native Windows settings and streams (push) Waiting to run
Verify Landlock helper / arm64 (push) Waiting to run
Verify Landlock helper / x64 (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
* feat(mods): observe turn start and completion Mods written for Claude Code follow the session's turns. Token Weather reads $.session.usage() in a turn.complete hook, summary Mods return a line from turn.complete, and guards end a turn with $.turn.abort. Qwen Code skipped those hooks at load, and $.turn.abort threw. turn.start now fires once UserPromptSubmit admits a prompt, before its first model request, with the prompt and a new turn id. The request does not wait for the hooks; the turn's tool middleware and its end queue behind them. turn.complete fires once when the turn ends, with the last response's visible text, the duration, whether it answered, was aborted or failed, and the token counts of the turn's own model responses. A different text a turn.complete hook returns is shown beneath the answer, as a Mod transcript line, and the record keeps the answer. $.turn.abort ends the running turn and its tools as a cancel does, without the cancel notice or restoring the prompt. The host, which owns the turn, judges the id. In a headless run the turn ends with the error result "A Mod ended the turn." and exit code 130. Turn events cover the prompts a person submits, in headless runs and in the Ink and OpenTUI terminals. Core reports admission through a new onPromptAdmitted option of sendMessageStream, so a blocked prompt starts no turn and the events do not depend on classic hooks. * fix(mods): address the turn events review - The Responses API counts reasoning inside output_tokens, so turn.complete usage counted it twice for openai-responses. The session and the uiTelemetry token fallback now share one rule, outputIncludesThoughts. - In OpenTUI, an Esc and a $.turn.abort on the same turn, in either order, lost the Esc's settle: the queued prompt did not go back to the composer, and the composer's cancel stayed pending, so later Esc presses did nothing. An interrupt now settles as a cancel even when a Mod ended the turn too. - A prompt or an answer past the worker's payload limit made the request fail, so every Mod missed that event with only a debug line. The session now keeps the head and tail of such a text within 128 KiB as JSON, as tool results are cut for the headless and ACP transports, and the event carries isTextTruncated or isAnswerTruncated. - turn.start hooks ran under the session's signal, so Esc did not end a slow one and the turn's tool calls waited behind it. They now also end with the turn, as in Claude Code; turn.complete keeps the session's signal. - The 4,096-character note and the terminal's 2,000-character log line are cut by code point, so a surrogate pair is never split. - The bundled-worker script test expects the turn fields of the load reply. * fix(cli): end an interrupted shared-loop tool batch with its result On the shared agent loop, an interrupt that landed while a tool ran made runNonInteractive reject with the TurnInterruptedError instead of ending with the interrupted result and exit code 130. The 'cancelled' case returned routeAbort()'s promise without awaiting it, so its throw skipped the run's catch. A stream-json session then got no result for that turn, and in a -p run a Mod's $.turn.abort from a tool.call hook ended the process with "An unexpected critical error occurred". The case awaits routeAbort() now, as every other call site does. A test runs the interrupt on both loops. * fix(mods): follow turns on the shared agent loop and OpenTUI retries main now runs headless rounds on the shared agent loop behind experimental.unifiedAgentLoop (#15369), and OpenTUI retries a failed request (#15283). The merge left the turn events wired to the current loop only. - On the shared loop, the main loop's model events reach the turn through a new onEvent scope hook, as they do on the current loop. Drain items stay out of it. - A tool that ends the person's Goal turn ends the Mod turn before a queued Goal continuation runs, as on the current loop. That path had no test on either loop. - An OpenTUI retry starts a turn with no typed prompt, like Ink's, though it reuses the failed turn's submit options. The headless Mod turn tests now run on both loops, with new cases for a Mod ending its turn from a tool.call hook and for a Goal continuation. * fix(mods): report run-budget stops as errors and reject no-op turn aborts - A headless run budget (--max-wall-time / --max-tool-calls) aborts the run's own controller, so turn.complete reported reason 'aborted' for it. It now reports 'error', as routeAbort already treats it. - $.turn.abort marked the turn as ending before the host cancelled anything. Ink's quiet cancel returns early once the stream is idle, so a call landing between the idle commit and the turn's completion resolved, and the turn that answered reported 'aborted'. The host's abort callback now returns whether it ended the turn; when it did not, the call rejects as "already ending" and the turn keeps its reason. - The Mods guide says a turn.start hook that aborts its own turn is cancelled with it. - Drop a duplicate `failed = true` in the OpenTUI turn's catch. * fix(mods): classify every Ink turn ending the Mods hear - A turn cancelled without an Esc, such as a Goal preemption, now ends as aborted. Its stream returned UserCancelled with no ending recorded, so turn.complete heard an answer. - A tool continuation whose delivery fails no longer records an error. That callback also reads a flag a concurrent /btw failure can set; the continuation's own failure already marks the turn through its events. - A failed request records its ending before the delivery callback runs. - A slash command whose prompt reaches the model waits for the Mods to start, as a typed prompt does, and is refused the same way when they failed to start. It used to run with no turn events. New Ink tests cover a cancel without an Esc, cancelled tools, tools that cannot go on, a failed continuation, the slash command wait, and that a Mod's end leaves the self-paced loop's wakeups alone. * fix(mods): end the running turn when the session closes - Closing the session, as /quit does, ends a turn still running as aborted before session.end, within the same budget. A turn whose end was queued when the close began still runs it. Both used to be dropped once the close shut the queue. - In headless text mode a turn.complete note waits for stdout to drain, so it lands beneath the answer. - In a terminal a turn.complete text keeps its own 4,096 characters instead of the 2,000 a $.ui.log line gets. Session tests now pin the turn.start guard after the queue runs, the session token limit as an error, the last model of a fallback, and no longer wait out the stderr flush timeout. * fix(cli): let a structured-output run end its Mod turn The three `return emitStructuredSuccess()` paths returned the promise unawaited from inside the run's try/finally, so the finally closed the Mod session before the result's completion ran and turn.complete was dropped. They are awaited now, as routeAbort is. New headless tests, on both loops, cover a structured-output answer and the continuation of an interrupted turn, and check the second $.turn.abort's return outside the stream, where the run's catch cannot swallow it. * test(core): cover a continuation send and an unanswered turn abort - A send that continues itself reports its prompt once. - A $.turn.abort the host never answers is settled when its dispatch ends, and the next dispatch still runs. * docs(mods): correct which turns $.session.turns() counts A retry and a headless continuation fire turn events but add no user turn to the transcript, so they are not the turns $.session.turns() counts. The guide and the design also say a turn still running when the session closes ends as aborted, and the validation lists name the new tests. * refactor(telemetry): share the reasoning convention with cost estimates The billable output estimate main added in #15464 repeats the list of auth types whose output count already includes reasoning. It now uses outputIncludesThoughts, the helper turn usage and the token total use, so the three read one rule. * fix(mods): finish turn ends on close and count turn usage as the session does Closing the session: - A close no longer cancels a turn.complete that is already running. Turn ends run under a signal of their own, which the close aborts only once their budget has passed, so the hook finishes and its note still shows. A hook that ran past the cancel's 250 ms grace used to have its worker terminated, and session.end failed with it. - Every end the close finds queued runs, in order, before the end of the turn the close cuts short. Only the last ended turn was kept, so in a terminal an earlier turn's end could be lost once the next turn had started. - A turn ends once, even when the host reports its end during the close. Turn usage: - `usage.model` names the model of the turn's own prompt. Each host passes the prompt id its requests carry, so a concurrent /btw that answers last on another model no longer names the turn's model. - Output tokens follow the rule #15464 gave the session's billable output: a total in the band both conventions allow decides whether reasoning is already in the output, the provider's convention otherwise. `outputTokenCount` now holds it for telemetry and the Mod session alike. `see()` also stops copying the stream once the turn has ended, or when no turn.complete hook will read it. * fix(mods): end Ink's turn.start hooks for a cancel in any round Ink gives each tool round its own controller, and the turn.start dispatch ran under the first round's. A cancel in a later round, by Esc or by the Mod, left a slow turn.start running for up to its budget, with turn.complete queued behind it, and a hook that called `$.turn.abort` went on after the call. The Mod turn now holds a controller of its own that every aborted ending cancels, as OpenTUI and headless runs already do with the turn's single signal. Ink also passes the turn's prompt id. The repeated "Mods failed to initialize" handling becomes one helper. * docs(mods): describe turn ends at close and the new usage rules The guide and the design say that a close lets a running or queued turn.complete finish within the end budget, and the validation lists name the new tests. * fix(mods): align turn endings across hosts and sanitize turn usage - OpenTUI ends a turn whose tools were all declined as `aborted`, as Ink does, through a new `onToolsCancelled` live prompt option. - Ink no longer lets a `$.turn.abort` on a turn the person already cancelled act as a second Esc, which aborted the detached work (such as a running side question) the first cancel spared. - Turn usage takes each provider count as a whole, non-negative number, so a hostile count no longer fails the `turn.complete` result check and drops the hook's note. * fix(mods): end loop-detected turns before close and keep Ink prompt text Headless returned emitLoopDetectedResult() without awaiting it inside the run's try/finally, so the finally closed the Mods first and the turn ended as aborted instead of error. All five sites now await it. Ink built turn.start text only from humanInput or submittedPrompt, so a prompt whose composer provenance was lost (a restored prompt stash, an edited restored submission) reached the Mods as "". It now falls back to the query it sends; a retry still carries "".
117 lines
3.6 KiB
JavaScript
117 lines
3.6 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { afterEach, expect, it, vi } from 'vitest';
|
|
import { mkdtemp, writeFile, rm, realpath } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { Worker } from 'node:worker_threads';
|
|
|
|
const state = vi.hoisted(() => ({ directory: '', build: undefined }));
|
|
vi.mock('esbuild', async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
default: {
|
|
build(options) {
|
|
if (!options.entryPoints['mod-worker'])
|
|
return Promise.resolve({ metafile: {} });
|
|
state.build = actual.build({
|
|
...options,
|
|
entryPoints: { 'mod-worker': options.entryPoints['mod-worker'] },
|
|
outdir: state.directory,
|
|
});
|
|
return state.build;
|
|
},
|
|
},
|
|
};
|
|
});
|
|
// Import the real build configuration without cleaning the shared dist directory.
|
|
vi.mock('node:fs', async (importOriginal) => ({
|
|
...(await importOriginal()),
|
|
rmSync: vi.fn(),
|
|
}));
|
|
|
|
let worker;
|
|
afterEach(async () => {
|
|
await worker?.terminate();
|
|
worker = undefined;
|
|
if (state.directory)
|
|
await rm(state.directory, { recursive: true, force: true });
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('runs a relocated bundled worker with TypeScript and relative imports', async () => {
|
|
state.directory = await realpath(
|
|
await mkdtemp(path.join(os.tmpdir(), 'qwen-mod-worker-bundle-')),
|
|
);
|
|
vi.stubEnv('DEV', 'false');
|
|
vi.resetModules();
|
|
await import('../../esbuild.config.js');
|
|
await state.build;
|
|
await writeFile(
|
|
path.join(state.directory, 'package.json'),
|
|
'{"type":"module"}',
|
|
);
|
|
await writeFile(
|
|
path.join(state.directory, 'helper.ts'),
|
|
'export const greeting: string = "bundled";',
|
|
);
|
|
worker = new Worker(path.join(state.directory, 'mod-worker.js'), {
|
|
workerData: new SharedArrayBuffer(4),
|
|
});
|
|
let sequence = 0;
|
|
const dispatch = (request) =>
|
|
new Promise((resolve, reject) => {
|
|
const root = ++sequence;
|
|
const onError = (error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
const onMessage = (reply) => {
|
|
if (reply.root !== root || reply.type === 'log') return;
|
|
if (reply.phase !== 0)
|
|
return onError(new Error('Unexpected Mod phase'));
|
|
cleanup();
|
|
if (reply.type === 'error') reject(new Error(reply.message));
|
|
else resolve(JSON.parse(reply.json));
|
|
};
|
|
const cleanup = () => {
|
|
worker.off('error', onError);
|
|
worker.off('message', onMessage);
|
|
};
|
|
worker.on('error', onError);
|
|
worker.on('message', onMessage);
|
|
worker.postMessage({ ...request, root, phase: 0 });
|
|
});
|
|
const commands = await dispatch({
|
|
type: 'load',
|
|
cwd: state.directory,
|
|
plugins: [
|
|
{
|
|
id: 'fixture',
|
|
name: 'fixture',
|
|
options: { value: 'option' },
|
|
source: {
|
|
root: state.directory,
|
|
entry: path.join(state.directory, 'entry.ts'),
|
|
source: `import { greeting } from './helper.js'; export function register(on, options) {
|
|
on('session.start', async ($, e, next) => { await $.command.register({ name: 'hello', description: 'Bundled' }); return next(e); });
|
|
on('command.run', {command:'hello'}, () => ({text: greeting + ':' + options.value + ':' + typeof process}));
|
|
}`,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(commands).toEqual({
|
|
tools: false,
|
|
commands: [{ name: 'hello', description: 'Bundled', plugin: 'fixture' }],
|
|
turnStart: false,
|
|
turnComplete: [],
|
|
});
|
|
expect(
|
|
await dispatch({ type: 'command', command: 'hello', args: '' }),
|
|
).toEqual({ text: 'bundled:option:undefined' });
|
|
});
|