mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-11 19:46:21 +00:00
* 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
292 lines
10 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|