qwen-code/scripts/tests/dev.test.js
Dragon 4ec9f63d0a
feat(core): add CodeModeOnly tool execution (#10607)
* feat(core): add CodeModeOnly tool execution

* fix(core): preserve CodeModeOnly execution semantics

* fix(core): drop the tool_search reminder in CodeModeOnly

CodeModeOnly hides `tool_search` and binds every deferred tool — schema
included — into the `exec` description, so progressive discovery has
nothing left to do. The session prelude still announced those tools as
"reachable via `tool_search`", pointing the model at a tool that is
never declared and re-billing the text on every cached prefix. Worse,
tools already callable as `tools.web_fetch(...)` looked gated behind a
lookup step that does not exist.

The subagent and fork-resume callers already opted out, but the
main-session prelude sites did not, so the gate now lives where the
prelude is built rather than in each caller.

* chore: refresh settings schema

* chore: refresh companion notices

* fix: register exec tool metadata

* fix(cli): localize exec tool display name

* fix(core): stop offering ToolSearch lookups in CodeModeOnly

CodeModeOnly hides tool_search and binds every deferred schema into the
exec description, so text that tells the model to look a tool up
describes a surface it cannot reach. Return an empty deferred summary
from the registry itself - this also covers the fork-resume reminder
that bypassed the earlier call-site gate - and point the image zoom
hint at tools.zoom_image.

* feat(core): teach the system prompt CodeModeOnly's tool surface

In CodeModeOnly the model can only call exec, so the tool guidance and the
worked examples described a surface it does not have: bare tool names it
cannot call, an instruction to issue several tool calls in one response, and
examples in a direct tool-call syntax that does not exist there.

Give "Using Your Tools" a code-mode branch that routes every tool through
tools.<name>, states which tools are direct controls instead, and replaces
multi-call parallelism with batching inside one exec program. Swap the four
model-family example sets for one shared exec set, since the syntax
differences they exist for do not apply. The mechanics of exec itself stay in
its tool description; the prompt carries only policy.

* test: include code mode host in standalone fixture

* fix(core): return code mode media as multimodal output

* feat(core): support image tool results in code mode

* fix(core): carry deferred tool schemas in the exec description

CodeModeOnly hides tool_search and never surfaces a nested call as a history
functionCall, so a signature collapsed to Record<string, unknown> could never
be filled in later: parameter names were reachable only by guessing, or by
reading them off validation errors one at a time. Deferred tools keep their
registry state; only the generated signature gains the schema.

* fix(node-repl): restore the bin exec bit after rebuild

npm chmods dist/index.js when it links the bin at install time, but the
build deletes that exact file and tsc re-emits it as 0644. After any
rebuild node_modules/.bin/node-repl-mcp therefore points at a
non-executable target and spawning it fails with EACCES (exit 126)
before the shebang is ever read.

OR the exec bits into the emitted mode instead of setting 0o755, so a
restrictive umask is preserved rather than widened.

* fix(cli): pin patched Ink version

* feat(cli): add collapsible tool call details

* fix(dev): use CLI tsconfig outside repository

* fix(cli): preserve expanded tool details after completion

* fix(cli): fail headless runs on model API errors

* fix(core): expose shell timeout bounds in code mode

* feat(core): add code mode timeout helpers

* feat(cli): expose code mode in settings

* fix(mcp): preserve structured results for model and exec consumers

* fix(core): preserve code mode output and truncate inline

* feat(core): port scoped context tools to code mode

* fix(build): synchronize pnpm lockfile and capability docs

* fix(core): recover from rejected Responses encrypted reasoning
2026-09-11 07:40:35 +00:00

292 lines
10 KiB
JavaScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const {
spawnMock,
platformMock,
existsSyncMock,
readFileSyncMock,
writeFileSyncMock,
} = vi.hoisted(() => ({
spawnMock: vi.fn(() => ({ on: vi.fn() })),
platformMock: vi.fn(() => 'darwin'),
existsSyncMock: vi.fn(() => false),
readFileSyncMock: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })),
writeFileSyncMock: vi.fn(),
}));
vi.mock('node:child_process', () => ({
spawn: spawnMock,
}));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
platform: platformMock,
tmpdir: vi.fn(() => '/tmp'),
};
});
vi.mock('node:fs', () => ({
writeFileSync: writeFileSyncMock,
mkdtempSync: vi.fn(() => '/tmp/qwen-dev-test'),
rmSync: vi.fn(),
existsSync: existsSyncMock,
symlinkSync: vi.fn(),
mkdirSync: vi.fn(),
readFileSync: readFileSyncMock,
}));
const normalizePath = (path) => String(path).replaceAll('\\', '/');
describe('scripts/dev.js launcher', () => {
const originalArgv = process.argv;
const execPathDescriptor = Object.getOwnPropertyDescriptor(
process,
'execPath',
);
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
process.argv = ['node', 'scripts/dev.js'];
});
afterEach(() => {
process.argv = originalArgv;
if (execPathDescriptor) {
Object.defineProperty(process, 'execPath', execPathDescriptor);
}
});
it('spawns Node without a shell on Windows when local tsx cli.mjs exists', async () => {
platformMock.mockReturnValue('win32');
existsSyncMock.mockImplementation((filePath) =>
normalizePath(filePath).endsWith('node_modules/tsx/dist/cli.mjs'),
);
Object.defineProperty(process, 'execPath', {
configurable: true,
value: 'C:\\Program Files\\nodejs\\node.exe',
});
process.argv = ['node', 'scripts/dev.js', '--help'];
await import('../dev.js?direct-node');
const [command, args, options] = spawnMock.mock.calls[0];
expect(command).toBe('C:\\Program Files\\nodejs\\node.exe');
expect(args.map(normalizePath)).toEqual([
expect.stringContaining('node_modules/tsx/dist/cli.mjs'),
'--tsconfig',
expect.stringContaining('packages/cli/tsconfig.json'),
expect.stringContaining('packages/cli/index.ts'),
'--help',
]);
expect(options).toEqual(expect.objectContaining({ shell: false }));
});
it('keeps shell fallback for Windows tsx.cmd resolution', async () => {
platformMock.mockReturnValue('win32');
existsSyncMock.mockImplementation((filePath) =>
normalizePath(filePath).endsWith('node_modules/.bin/tsx.cmd'),
);
await import('../dev.js?cmd-fallback');
const [command, args, options] = spawnMock.mock.calls[0];
expect(normalizePath(command)).toContain('tsx.cmd');
expect(args.map(normalizePath)).toEqual([
'--tsconfig',
expect.stringContaining('packages/cli/tsconfig.json'),
expect.stringContaining('packages/cli/index.ts'),
]);
expect(options).toEqual(expect.objectContaining({ shell: true }));
});
it('re-raises a child signal instead of exiting 0 — close(null, SIGKILL) is not success', async () => {
// `code ?? 0` read a signal-killed child as green. This launcher is a
// QWEN_CODE_CLI entry now: an OOM-killed review gate command must not come
// back as a passing exit.
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
try {
await import('../dev.js?signal-close');
const child = spawnMock.mock.results[0].value;
const close = child.on.mock.calls.find(([ev]) => ev === 'close')[1];
close(null, 'SIGKILL');
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGKILL');
expect(exitSpy).not.toHaveBeenCalledWith(0);
} finally {
exitSpy.mockRestore();
killSpy.mockRestore();
}
});
it('stamps QWEN_CODE_CLI with its own path, overriding an inherited one', async () => {
// A dev CLI started from inside another qwen session's shell inherits that
// session's QWEN_CODE_CLI. Honouring it points every `qwen …` subprocess of
// THIS session at the OUTER session's build — the exact version skew the
// variable exists to prevent, one level up and silent. Each entry stamps
// itself; nested sessions each call their own build.
const inherited = process.env.QWEN_CODE_CLI;
process.env.QWEN_CODE_CLI = '/somewhere/else/entirely/qwen';
try {
await import('../dev.js?stamps-own-cli');
const [, , options] = spawnMock.mock.calls[0];
expect(normalizePath(options.env.QWEN_CODE_CLI)).toMatch(
/scripts\/dev\.js$/,
);
} finally {
if (inherited === undefined) delete process.env.QWEN_CODE_CLI;
else process.env.QWEN_CODE_CLI = inherited;
}
});
it.skipIf(process.platform === 'win32')(
'keeps the dev entry executable for QWEN_CODE_CLI subprocesses',
async () => {
const fs = await vi.importActual('node:fs');
expect(() =>
fs.accessSync(new URL('../dev.js', import.meta.url), fs.constants.X_OK),
).not.toThrow();
},
);
it('resolves core subpaths to packages/core/src, not the exports map dist', async () => {
// Intercepting only the package root leaves a named subpath to Node's
// `exports` map, which resolves into packages/core/dist while the root
// loads packages/core/src — one dev process holding two instances of the
// same module. Config binds the debug session on the src copy, so the dist
// copy's REMOTE_INPUT logger reads an empty session and every
// debugLogger(...) call in RemoteInputWatcher silently no-ops.
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
const coreDir = join(repoRoot, 'packages', 'core');
const corePackageJson = await readFile(
join(coreDir, 'package.json'),
'utf-8',
);
const existsOnDisk = async (p) => {
try {
await stat(p);
return true;
} catch {
return false;
}
};
// Ground truth independent of the implementation's derivation rule.
const expectedSources = {
'@qwen-code/qwen-code-core': 'packages/core/index.ts',
'@qwen-code/qwen-code-core/debugLogger':
'packages/core/src/utils/debugLogger.ts',
'@qwen-code/qwen-code-core/storage':
'packages/core/src/config/storage.ts',
'@qwen-code/qwen-code-core/atomicFileWrite':
'packages/core/src/utils/atomicFileWrite.ts',
'@qwen-code/qwen-code-core/utils/debugLogger.js':
'packages/core/src/utils/debugLogger.ts',
};
// Every named subpath the exports map publishes, so the interception
// cannot silently fall behind as subpaths are added.
const namedSubpaths = [];
for (const [subpath, conditions] of Object.entries(
JSON.parse(corePackageJson).exports ?? {},
)) {
const distEntry = conditions?.import;
if (subpath === '.' || typeof distEntry !== 'string') continue;
if (!distEntry.startsWith('./dist/')) continue;
const sourcePath = join(
coreDir,
distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'),
);
if (await existsOnDisk(sourcePath)) {
namedSubpaths.push(`@qwen-code/qwen-code-core/${subpath.slice(2)}`);
}
}
expect(namedSubpaths.length).toBeGreaterThan(0);
const defaultRead = readFileSyncMock.getMockImplementation();
const defaultExists = existsSyncMock.getMockImplementation();
try {
readFileSyncMock.mockImplementation((filePath, ...rest) =>
normalizePath(filePath).endsWith('packages/core/package.json')
? corePackageJson
: defaultRead(filePath, ...rest),
);
// The launcher only probes the source files it is about to map, so answer
// from the real tree: a mapping whose source is missing must be dropped
// rather than emitted.
existsSyncMock.mockImplementation((filePath) => {
const normalized = normalizePath(filePath);
return (
normalized.includes('/packages/core/') &&
(Object.values(expectedSources).some((s) => normalized.endsWith(s)) ||
namedSubpaths.some((specifier) => {
const sub = specifier.slice('@qwen-code/qwen-code-core/'.length);
const distEntry =
JSON.parse(corePackageJson).exports[`./${sub}`]?.import;
return normalized.endsWith(
distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'),
);
}))
);
});
await import('../dev.js?subpath-source-map');
const loaderCall = writeFileSyncMock.mock.calls.find(([filePath]) =>
normalizePath(filePath).endsWith('loader.mjs'),
);
expect(loaderCall).toBeDefined();
// Execute the hook the launcher actually generates instead of asserting
// on its source text.
const loader = await import(
`data:text/javascript;base64,${Buffer.from(loaderCall[1]).toString('base64')}`
);
const nextResolve = (specifier) => ({
url: `fallthrough:${specifier}`,
format: 'module',
shortCircuit: false,
});
for (const [specifier, expected] of Object.entries(expectedSources)) {
const resolved = loader.resolve(specifier, {}, nextResolve);
expect(resolved.shortCircuit, specifier).toBe(true);
const resolvedPath = normalizePath(fileURLToPath(resolved.url));
expect(resolvedPath, specifier).toContain(expected);
expect(resolvedPath, specifier).not.toContain('/dist/');
}
// Completeness: no published named subpath may fall through to Node,
// which is the lane that reaches dist.
for (const specifier of namedSubpaths) {
const resolved = loader.resolve(specifier, {}, nextResolve);
expect(resolved.shortCircuit, specifier).toBe(true);
expect(resolved.url, specifier).not.toContain('fallthrough:');
expect(normalizePath(fileURLToPath(resolved.url)), specifier).toContain(
'/packages/core/src/',
);
}
// Unrelated specifiers still reach Node's resolver.
expect(loader.resolve('node:fs', {}, nextResolve).url).toBe(
'fallthrough:node:fs',
);
} finally {
readFileSyncMock.mockImplementation(defaultRead);
existsSyncMock.mockImplementation(defaultExists);
}
});
});