mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-19 12:43:39 +00:00
* feat(core): resolve model limits and modalities from a models.dev catalog
Bundle a trimmed models.dev snapshot (12 first-party providers, 237
models) and refresh it in the background once a day into
~/.qwen/model-registry.json with ETag revalidation, so a model that
appears on models.dev gets its context window, output limit, and input
modalities without a CLI release. tokenLimit()/knownTokenLimit()/
hasExplicitOutputLimit() consult the catalog before the regex tables;
defaultModalities() merges both so the catalog can only add modalities.
QWEN_CODE_MODELS_DEV=off restores the regex-only behavior (default in the
unit test setups), QWEN_CODE_MODELS_DEV_REFRESH=off disables the network
refresh, QWEN_CODE_MODELS_DEV_URL points at a mirror. `npm run
generate:model-catalog` regenerates the committed snapshot.
Implements PR2 and PR3 of the design in #9851. Refs #8558.
* feat(core): add model.customCatalog for offline model metadata
Offline and proxy-restricted users cannot reach models.dev, so let them
point `model.customCatalog` at a URL or local file whose entries are
merged over the bundled catalog per model and field. Both the trimmed
`{ models: { id: entry } }` shape and models.dev-style payloads are
accepted. A file is re-read synchronously at every start so the first
model resolution already sees it; a URL is cached like models.dev and
is still fetched when QWEN_CODE_MODELS_DEV_REFRESH=off. The cache
records its source, so a changed or removed setting drops the overlay.
Also sort snapshot keys with a plain comparator instead of localeCompare
so regeneration is deterministic across machines.
* docs: add a verification brief for the models.dev catalog
CI covers the mechanism, but every unit test stubs fetch and the test
setups run with the catalog off, so neither the data values nor the real
runtime behavior are verified anywhere. This brief is the handoff for a
machine that can run the CLI against live endpoints: an on/off A/B with
measured reference numbers, the refresh and offline paths, and the two
values that may still be wrong (Anthropic 1M context, qwen3.8-max PDF).
* fix(core): only catalog models the endpoint question has one answer for
Two projection bugs, both found by reviewing the magnitude of the changes
the catalog was making rather than their direction.
The provider whitelist filtered by vendor but never by model type, so the
catalog also carried embedding, text-to-speech, image, video, OCR and
translation models whose limits mean something else entirely:
gemini-embedding-001 reported an output limit of 1 and veo-3.1-generate a
context of 480, and those numbers outranked the family fallbacks that keep
such ids harmless today. Require tool calling and text output, which drops
34 ids and no real chat model.
Conflict resolution picked the vendor's numbers when several providers
served one id, but a context window is a property of the endpoint, not the
weights. DashScope caps GLM-5 output at 16,384 where Z.ai allows 131,072;
both are correct for their own endpoint. Worse, kimi-k2.6 has no matching
output pattern today, so it is treated as unknown and the user's max_tokens
is respected, and the catalog would have clamped it to 262,144. Drop such
ids instead of guessing: 220 of 237 ids have no conflict, and the 11 that
remain keep exactly the answer they get today.
Snapshot goes from 237 models to 192, 29,824 to 24,685 bytes.
* fix(core): validate catalog defaults and reject alias conflicts
* fix(core): narrow catalog defaults to verified behavior
* fix(core): preserve curated output limits over catalog defaults
* fix(core): harden models.dev catalog refresh and keying
- Reject a 200 response that projects to zero models so a gateway error
body or an upstream field rename cannot shadow the bundled snapshot,
and treat an already-empty cache as absent so a 304 cannot re-stamp it.
- Omit catalog candidates whose key is not a normalize() fixed point:
deepseek-v3 was unreachable by its own name while its dated alias
inherited a 163,840 output limit. Snapshot drops that entry (194).
- Exclude QWEN_CODE_MODELS_DEV_URL from project .env files: the refresh
writes the global cache every project reads.
- Reject false-valued modalities when parsing catalog caches.
- Document the fixed-point keying rule and the accurate REFRESH=off
semantics in the settings table.
* fix(core): preserve explicit budgets for catalog-only models
* fix(core): harden catalog cache reads and correct qwen3-coder-plus window
- loadModelCatalog drops cache keys no normalized spelling can reach and
treats a cache that is empty after validation as absent, so a stale or
poisoned cache can no longer displace the bundled snapshot.
- Correct qwen3-coder-plus to its vendor-declared 1,000,000-token window
(models.dev's alibaba buckets approximate it as 1,048,576), applied in
the loader so a refreshed cache cannot reintroduce the approximation.
- Pin the cross-origin refresh guard with a non-empty cache fixture, the
normalized-id catalog lookup for modalities, the env-budget clamp
precondition for glm-4.7, and control keys plus reload/snapshot arms
for the project-scoped catalog URL exclusion.
- Align the QWEN_CODE_MODELS_DEV_REFRESH docs and JSDoc with the
newer-than-bundled selection rule.
* fix(core): project catalog modalities across providers and test against the real catalog
Review round on PR #11959:
- trimModelsDevCatalog: token limits stay gated on the first-party
allowlist with disagreement-drop; input modalities describe the
weights, so they now union across every provider that serves the
model. First-party vendors outside the allowlist (issue #8558's
thinkingmachines/inkling) project image/pdf support again, while
their endpoint limits stay untrusted.
- modalityDefaults.test.ts: drop the file-scoped catalog mock that let
three assertions pin the opposite of shipped behavior; run against
the real bundled snapshot in an isolated QWEN_HOME and correct the
qwen3.8-max / glm-5v-turbo / glm-5.3-flash expectations. The
mock-based merge tests move to modalityDefaults.catalog.test.ts.
- vscode-ide-companion: add the missing QWEN_CODE_MODELS_DEV=off test
default via test-setup.ts so a host's ~/.qwen/model-registry.json
cache cannot flip its contextLimit assertions.
* revert(vscode-ide-companion): drop out-of-footprint catalog test wiring
Deterministic verification rejected the setupFiles addition to
packages/vscode-ide-companion/vitest.config.ts: per-package vitest
configs are test-config verification machinery, and this PR's
footprint does not include that package. Revert the wiring and remove
the now-orphaned test-setup.ts.
The underlying hazard (R4-2: acpModelInfo contextLimit assertions
resolve against the host's ~/.qwen/model-registry.json cache) is real
and reproduced on this head — a poisoned cache flips exactly the two
witness tests red — but every fix lives outside this PR's footprint,
so it is deferred to the follow-up queue with an open question for
maintainers.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix: close model catalog environment gaps
* fix(core): validate refreshed model metadata
* fix(core): reject incomplete model catalogs
* fix(core): keep catalog defaults agent-safe
* fix(core): preserve DeepSeek vision boundary
* fix(core): harden the catalog refresh fetch and pin the catalog fixture clock
Six review findings on the refresh/generator path:
- Bound the download: the refresh now reads the 200 body with a
200KB ceiling (the generator's budget) instead of buffering an
unbounded payload, rejects a present-but-non-JSON Content-Type one
step earlier than JSON.parse, and cancels the body on the HTTP-error
path so the socket is released.
- Clock skew: a cache stamped by a faster clock (negative or unreadable
age) now refetches instead of serving indefinitely; the re-stamp uses
the local clock so the skew self-heals.
- The refresh failure log goes through the canonical getErrorMessage,
so error.cause survives into the debug channel.
- The 'serves it immediately' test pins its clock ahead of the bundled
snapshot's committed fetchedAt, decoupling it from the runner's wall
clock (the snapshot is regenerated by whichever host runs the
generator).
- The generator refuses a non-OK response and an empty projection
instead of overwriting the committed snapshot with either.
Each new test is red under its corresponding revert (byte cap, content
-type check, skew guard); the clock-pin witness mutates the snapshot's
fetchedAt to 9999 and stays green.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(core): drop mirror providers from catalog limits and keep parse fidelity
Seven more review findings on the catalog path:
- MODELS_DEV_PROVIDERS no longer includes modelscope. Measured against
the live payload: its only contributions were four dated -2507 qwen3
snapshots recorded under the bare ids plus vetoing z.ai's first-party
glm-4.6 numbers into a conflict drop. volcengine stays — it is the
first-party endpoint for the doubao/seed family (dropping it costs 16
keys). Snapshot regenerated: 203 entries, glm-4.6 restored to the
first-party figures.
- parseModelCatalog no longer lets one bad modalities member take a
whole entry down: unknown or mistyped modality keys are filtered and
the entry keeps its limits; context/output stay strict. The parser
also skips an own-property __proto__ key instead of invoking the
prototype setter (the refresh path can legitimately write one).
- A declared text-only modalities list now survives projection as {}
on entries that ship limits, instead of being indistinguishable from
"upstream said nothing" — inert under today's union, recorded so a
future narrowing can tell the two apart. A bare {} never creates an
entry on its own, so conflict-dropped and limit-less models still
drop.
- The committed snapshot now has a parse-and-budget gate: every shipped
entry must survive parseModelCatalog and stay within the 200KB budget
(corrupting one entry turns the new test red).
- The gpt-4o resolver expectation's comment now says it pins the
catalog-off regex answer; the catalog-on production value already has
its own twin test.
- The verification README's evidence pointers no longer reference a
gitignored .qwen path; they name the committed suites that pin the
same numbers.
Modality list is now single-sourced as CATALOG_MODALITIES exported from
model-catalog.ts. Each behavior change is red under its revert:
restoring modelscope re-drops glm-4.6, restoring whole-entry modality
rejection or the __proto__ assignment reds the parse test, restoring
the empty-modalities drop reds the projection expectations.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(core): narrow catalog modality keys with a type predicate
Object.entries widens the key to string, and includes() on the widened
readonly string[] does not narrow it back, so the indexed write into
InputModalities was an implicit any under noImplicitAny. That failed the
packages/core build with TS7053 and cascaded into every required check.
Extract the membership test into an isCatalogModality predicate so the
write is type-safe. Type-level only, no behavior change: the existing
"drops malformed entries while parsing" coverage still pins that an
unrecognised modality key degrades that one field instead of discarding
the entry.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmunuawz4h7
* fix(core): keep catalog refresh usable and process-consistent
* fix(core): invalidate incompatible model catalog projections
* fix(core): stamp canonical models.dev URL as bundled catalog provenance
The generator passed its input locator straight through as the provenance
stamp, so generating from a local payload copy recorded a machine-specific
path (e.g. /tmp/11959-models-dev.json) in the committed snapshot. Stamp
MODELS_DEV_URL for local-path inputs (the payload is models.dev data
regardless of where the machine stored it), keep URL inputs stamping
themselves, regenerate the snapshot, and pin the artifact's source field
with a test.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuofqisz0a
* fix(core): share the provider-coverage guard and key DeepSeek narrowing on the family root
Round-9 review findings on 4516bd759d:
- R9-1: the snapshot generator only checked "at least one entry
projected", while the runtime refresh treats a provider-incomplete
payload as fatal. A truncated download or a mirror missing one vendor
would silently replace the committed 203-entry snapshot. Extract the
coverage check into assertCatalogPayloadComplete() and call it from
both refreshRemote and scripts/generate-model-catalog.ts.
- R9-2: the #10270 DeepSeek text-only narrowing keyed on the hyphenated
prefix `deepseek-`, but normalize() folds bare-model records onto the
family root (`deepseek-v3` -> `deepseek`), which passes
isModelCatalogKey — so a bare record kept an overstated image claim.
Key the guard on the family: /^deepseek(?:-|$)/.
Both pins are mutation-verified: reverting either change turns its new
assertion red.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmuolga6k0g
* fix(core): keep four more catalog windows at their declared value
models.dev buckets the vendor-declared window of kimi-k3 and
qwen3-coder-plus up to 1,048,576, and MiniMax-M2.5 / GLM-4.7 up to
204,800, while the curated table in tokenLimits.ts and the provider
presets both declare the real value. The catalog wins for input limits,
so for kimi-k3 the compaction thresholds computed from the inflated
window fired above the window the vendor accepts
(computeThresholds(1048576).hard = 1,025,576 > 1,000,000).
The correction list already had the shape for qwen3-coder-plus; extend
it to the complete set of ids where the catalog contradicts both a
curated row and a preset, and drive the application from one table.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmupe138l16
* fix(cli): freeze all three model-catalog keys against project config
A project `.env` or `settings.json` `env` block could set
QWEN_CODE_MODELS_DEV and QWEN_CODE_MODELS_DEV_REFRESH even though
QWEN_CODE_MODELS_DEV_URL was already excluded. On the `qwen serve` fast
path both keys are applied before runQwenServeImpl snapshots and freezes
process.env into daemonRuntimeBaseEnv, which every workspace session
child inherits and which setRuntimeEnvIfUnset cannot repair per
workspace — so one repository's config decided catalog behaviour for
every other workspace the daemon hosts, and the rejection was silent
because the keys are not loader keys. On the reload path a repository's
`on` force-wrote over an operator's exported `off`, re-enabling the
outbound refresh in an air-gapped environment.
Add both keys beside the URL key in PROJECT_ENV_HARDCODED_EXCLUSIONS
and document the project-file restriction on their settings.md rows.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmupe138l16
---------
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Autofix Bot <autofix@qwen-code.dev>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
1076 lines
33 KiB
TypeScript
1076 lines
33 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
import { rm } from 'node:fs/promises';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { env } from 'node:process';
|
|
import fs from 'node:fs';
|
|
import { EOL } from 'node:os';
|
|
import * as pty from '@lydell/node-pty';
|
|
import stripAnsi from 'strip-ansi';
|
|
import { vi } from 'vitest';
|
|
import {
|
|
startFakeOpenAIServer,
|
|
type FakeOpenAIServerOptions,
|
|
type FakeOpenAIToolCall,
|
|
} from './fake-openai-server.js';
|
|
import {
|
|
e2eRendererEnv,
|
|
pickE2eRenderer,
|
|
resolveE2eCliCommand,
|
|
} from './renderer-matrix.js';
|
|
import { E2E_MEMORY_SETTINGS_DEFAULTS } from './e2e-memory-defaults.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
function sanitizeTestName(name: string) {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, '-')
|
|
.replace(/-+/g, '-');
|
|
}
|
|
|
|
// Helper to create detailed error messages
|
|
export function createToolCallErrorMessage(
|
|
expectedTools: string | string[],
|
|
// Callers build this by mapping `toolRequest.name` over the parsed
|
|
// telemetry, where the name is optional. This is a failure message, so a
|
|
// missing entry should print as `undefined` rather than force every call
|
|
// site to filter first.
|
|
foundTools: Array<string | undefined>,
|
|
result: string,
|
|
) {
|
|
const expectedStr = Array.isArray(expectedTools)
|
|
? expectedTools.join(' or ')
|
|
: expectedTools;
|
|
return (
|
|
`Expected to find ${expectedStr} tool call(s). ` +
|
|
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
|
|
`Output preview: ${result ? result.substring(0, 200) + '...' : 'no output'}`
|
|
);
|
|
}
|
|
|
|
// Helper to print debug information when tests fail
|
|
export function printDebugInfo(
|
|
rig: TestRig,
|
|
result: string,
|
|
context: Record<string, unknown> = {},
|
|
) {
|
|
console.error('Test failed - Debug info:');
|
|
console.error('Result length:', result.length);
|
|
console.error('Result (first 500 chars):', result.substring(0, 500));
|
|
console.error(
|
|
'Result (last 500 chars):',
|
|
result.substring(result.length - 500),
|
|
);
|
|
|
|
// Print any additional context provided
|
|
Object.entries(context).forEach(([key, value]) => {
|
|
console.error(`${key}:`, value);
|
|
});
|
|
|
|
// Check what tools were actually called
|
|
const allTools = rig.readToolLogs();
|
|
console.error(
|
|
'All tool calls found:',
|
|
allTools.map((t) => t.toolRequest.name),
|
|
);
|
|
|
|
return allTools;
|
|
}
|
|
|
|
// Helper to validate model output and warn about unexpected content
|
|
export function validateModelOutput(
|
|
result: string,
|
|
expectedContent: string | (string | RegExp)[] | null = null,
|
|
testName = '',
|
|
) {
|
|
// First, check if there's any output at all (this should fail the test if missing)
|
|
if (!result || result.trim().length === 0) {
|
|
throw new Error('Expected LLM to return some output');
|
|
}
|
|
|
|
// If expectedContent is provided, check for it and warn if missing
|
|
if (expectedContent) {
|
|
const contents = Array.isArray(expectedContent)
|
|
? expectedContent
|
|
: [expectedContent];
|
|
const missingContent = contents.filter((content) => {
|
|
if (typeof content === 'string') {
|
|
return !result.toLowerCase().includes(content.toLowerCase());
|
|
} else if (content instanceof RegExp) {
|
|
return !content.test(result);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (missingContent.length > 0) {
|
|
console.warn(
|
|
`Warning: LLM did not include expected content in response: ${missingContent.join(
|
|
', ',
|
|
)}.`,
|
|
'This is not ideal but not a test failure.',
|
|
);
|
|
console.warn(
|
|
'The tool was called successfully, which is the main requirement.',
|
|
);
|
|
return false;
|
|
} else if (process.env['VERBOSE'] === 'true') {
|
|
console.log(`${testName}: Model output validated successfully.`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Simulates typing a string one character at a time to avoid paste detection.
|
|
export async function type(ptyProcess: pty.IPty, text: string) {
|
|
const delay = 5;
|
|
for (const char of text) {
|
|
ptyProcess.write(char);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
const SANDBOX_MODE = process.env['QWEN_SANDBOX']?.toLowerCase().trim();
|
|
|
|
export const IS_CONTAINER_SANDBOX =
|
|
SANDBOX_MODE === 'docker' || SANDBOX_MODE === 'podman';
|
|
|
|
export const CONTAINER_SANDBOX_NO_PROXY =
|
|
'127.0.0.1,localhost,host.docker.internal';
|
|
|
|
export function fakeServerHostOptions(): FakeOpenAIServerOptions | undefined {
|
|
return IS_CONTAINER_SANDBOX
|
|
? { listenHost: '0.0.0.0', baseUrlHost: 'host.docker.internal' }
|
|
: undefined;
|
|
}
|
|
|
|
// Sets NO_PROXY so a containerized CLI reaches the host-side fake server, and
|
|
// returns a restorer for the previous values. No-op outside a container sandbox.
|
|
export function applyContainerSandboxNoProxy(): () => void {
|
|
if (!IS_CONTAINER_SANDBOX) {
|
|
return () => {};
|
|
}
|
|
const savedNoProxy = process.env['NO_PROXY'];
|
|
const savedNoProxyLower = process.env['no_proxy'];
|
|
process.env['NO_PROXY'] = CONTAINER_SANDBOX_NO_PROXY;
|
|
process.env['no_proxy'] = CONTAINER_SANDBOX_NO_PROXY;
|
|
return () => {
|
|
if (savedNoProxy === undefined) {
|
|
delete process.env['NO_PROXY'];
|
|
} else {
|
|
process.env['NO_PROXY'] = savedNoProxy;
|
|
}
|
|
if (savedNoProxyLower === undefined) {
|
|
delete process.env['no_proxy'];
|
|
} else {
|
|
process.env['no_proxy'] = savedNoProxyLower;
|
|
}
|
|
};
|
|
}
|
|
|
|
interface ParsedLog {
|
|
attributes?: {
|
|
'event.name'?: string;
|
|
function_name?: string;
|
|
function_args?: string;
|
|
success?: boolean;
|
|
duration_ms?: number;
|
|
status?: string;
|
|
'error.message'?: string;
|
|
// Telemetry carries far more attributes than the tool-call subset named
|
|
// above; callers reach them by key (`attributes['request_text']`). Every
|
|
// value is `unknown` because nothing validates the payload shape.
|
|
[key: string]: unknown;
|
|
};
|
|
scopeMetrics?: {
|
|
metrics: {
|
|
descriptor: {
|
|
name: string;
|
|
};
|
|
}[];
|
|
}[];
|
|
}
|
|
|
|
export class TestRig {
|
|
bundlePath: string;
|
|
testDir: string | null;
|
|
testName?: string;
|
|
_lastRunStdout?: string;
|
|
_interactiveOutput = '';
|
|
private readonly interactiveProcesses: pty.IPty[] = [];
|
|
|
|
constructor() {
|
|
this.bundlePath = join(__dirname, '..', 'dist/cli.js');
|
|
this.testDir = null;
|
|
}
|
|
|
|
// Get timeout based on environment
|
|
getDefaultTimeout() {
|
|
if (env['CI']) return 60000; // 1 minute in CI
|
|
if (env['QWEN_SANDBOX']) return 30000; // 30s in containers
|
|
return 15000; // 15s locally
|
|
}
|
|
|
|
async setup(
|
|
testName: string,
|
|
options: { settings?: Record<string, unknown> } = {},
|
|
) {
|
|
this.testName = testName;
|
|
const sanitizedName = sanitizeTestName(testName);
|
|
this.testDir = join(
|
|
env['INTEGRATION_TEST_FILE_DIR']!,
|
|
`${sanitizedName}-${process.pid}`,
|
|
);
|
|
// Same-name cases in one worker share this directory, and
|
|
// cleanup() below keeps it whenever KEEP_OUTPUT is set — which CI always
|
|
// sets. Reset it so a case never inherits the previous one's files; see the
|
|
// SDK helper, where exactly that made a suite pass locally and fail in CI.
|
|
await rm(this.testDir, { recursive: true, force: true });
|
|
mkdirSync(this.testDir, { recursive: true });
|
|
|
|
// Create a settings file to point the CLI to the local collector
|
|
const qwenDir = join(this.testDir, '.qwen');
|
|
mkdirSync(qwenDir, { recursive: true });
|
|
// In sandbox mode, use an absolute path for telemetry inside the container
|
|
// The container mounts the test directory at the same path as the host
|
|
const telemetryPath = join(this.testDir, 'telemetry.log'); // Always use test directory for telemetry
|
|
|
|
const optionsSettings = options.settings ?? {};
|
|
const memorySettings =
|
|
typeof optionsSettings['memory'] === 'object' &&
|
|
optionsSettings['memory'] !== null
|
|
? (optionsSettings['memory'] as Record<string, unknown>)
|
|
: {};
|
|
|
|
const settings = {
|
|
telemetry: {
|
|
enabled: true,
|
|
target: 'local',
|
|
otlpEndpoint: '',
|
|
outfile: telemetryPath,
|
|
},
|
|
sandbox: env.QWEN_SANDBOX !== 'false' ? env.QWEN_SANDBOX : false,
|
|
...options.settings, // Allow tests to override/add settings
|
|
// Per-key merge: a suite opting back into one flag keeps the other off.
|
|
memory: { ...E2E_MEMORY_SETTINGS_DEFAULTS, ...memorySettings },
|
|
};
|
|
writeFileSync(
|
|
join(qwenDir, 'settings.json'),
|
|
JSON.stringify(settings, null, 2),
|
|
);
|
|
}
|
|
|
|
createFile(fileName: string, content: string) {
|
|
const filePath = join(this.testDir!, fileName);
|
|
writeFileSync(filePath, content);
|
|
return filePath;
|
|
}
|
|
|
|
mkdir(dir: string) {
|
|
mkdirSync(join(this.testDir!, dir), { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* The command and args to use to invoke Qwen Code CLI. Allows us to switch
|
|
* between using the bundled gemini.js (the default) and using the installed
|
|
* 'qwen' (used to verify npm bundles).
|
|
*/
|
|
private _getCommandAndArgs(
|
|
extraInitialArgs: string[] = [],
|
|
options: { chatRecording?: boolean } = {},
|
|
): {
|
|
command: string;
|
|
initialArgs: string[];
|
|
} {
|
|
const isNpmReleaseTest =
|
|
process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true';
|
|
const command = isNpmReleaseTest ? 'qwen' : 'node';
|
|
// Runs leave no conversation behind by default. A test that drives
|
|
// `--continue` has to opt back in: without a recorded session there is
|
|
// nothing to resume, and the resumed run then looks indistinguishable
|
|
// from a fresh one.
|
|
const recordingArgs = options.chatRecording ? [] : ['--no-chat-recording'];
|
|
const initialArgs = isNpmReleaseTest
|
|
? [...recordingArgs, ...extraInitialArgs]
|
|
: [this.bundlePath, ...recordingArgs, ...extraInitialArgs];
|
|
return { command, initialArgs };
|
|
}
|
|
|
|
run(
|
|
promptOrOptions:
|
|
| string
|
|
| { prompt?: string; stdin?: string; stdinDoesNotEnd?: boolean },
|
|
...args: string[]
|
|
): Promise<string> {
|
|
const { command, initialArgs } = this._getCommandAndArgs(['--yolo']);
|
|
const commandArgs = [...initialArgs];
|
|
const execOptions: {
|
|
cwd: string;
|
|
encoding: 'utf-8';
|
|
input?: string;
|
|
} = {
|
|
cwd: this.testDir!,
|
|
encoding: 'utf-8',
|
|
};
|
|
|
|
if (typeof promptOrOptions === 'string') {
|
|
commandArgs.push('--prompt', promptOrOptions);
|
|
} else if (
|
|
typeof promptOrOptions === 'object' &&
|
|
promptOrOptions !== null
|
|
) {
|
|
if (promptOrOptions.prompt) {
|
|
commandArgs.push('--prompt', promptOrOptions.prompt);
|
|
}
|
|
if (promptOrOptions.stdin) {
|
|
execOptions.input = promptOrOptions.stdin;
|
|
}
|
|
}
|
|
|
|
commandArgs.push(...args);
|
|
|
|
const child = spawn(command, commandArgs, {
|
|
cwd: this.testDir!,
|
|
stdio: 'pipe',
|
|
env: process.env,
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
// Handle stdin if provided
|
|
if (execOptions.input) {
|
|
child.stdin!.write(execOptions.input);
|
|
}
|
|
|
|
const keepStdinOpen =
|
|
typeof promptOrOptions === 'object' &&
|
|
promptOrOptions !== null &&
|
|
promptOrOptions.stdinDoesNotEnd === true;
|
|
if (!keepStdinOpen) {
|
|
child.stdin!.end();
|
|
}
|
|
|
|
child.stdout!.on('data', (data: Buffer) => {
|
|
stdout += data;
|
|
if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') {
|
|
process.stdout.write(data);
|
|
}
|
|
});
|
|
|
|
child.stderr!.on('data', (data: Buffer) => {
|
|
stderr += data;
|
|
if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') {
|
|
process.stderr.write(data);
|
|
}
|
|
});
|
|
|
|
const promise = new Promise<string>((resolve, reject) => {
|
|
child.on('close', (code: number) => {
|
|
if (code === 0) {
|
|
// Store the raw stdout for Podman telemetry parsing
|
|
this._lastRunStdout = stdout;
|
|
|
|
// Filter out telemetry output when running with Podman
|
|
// Podman seems to output telemetry to stdout even when writing to file
|
|
let result = stdout;
|
|
if (env['QWEN_SANDBOX'] === 'podman') {
|
|
// Remove telemetry JSON objects from output
|
|
// They are multi-line JSON objects that start with { and contain telemetry fields
|
|
const lines = result.split(EOL);
|
|
const filteredLines = [];
|
|
let inTelemetryObject = false;
|
|
let braceDepth = 0;
|
|
|
|
for (const line of lines) {
|
|
if (!inTelemetryObject && line.trim() === '{') {
|
|
// Check if this might be start of telemetry object
|
|
inTelemetryObject = true;
|
|
braceDepth = 1;
|
|
} else if (inTelemetryObject) {
|
|
// Count braces to track nesting
|
|
for (const char of line) {
|
|
if (char === '{') braceDepth++;
|
|
else if (char === '}') braceDepth--;
|
|
}
|
|
|
|
// Check if we've closed all braces
|
|
if (braceDepth === 0) {
|
|
inTelemetryObject = false;
|
|
// Skip this line (the closing brace)
|
|
continue;
|
|
}
|
|
} else {
|
|
// Not in telemetry object, keep the line
|
|
filteredLines.push(line);
|
|
}
|
|
}
|
|
|
|
result = filteredLines.join('\n');
|
|
}
|
|
|
|
// Check if this is a JSON output test - if so, don't include stderr
|
|
// as it would corrupt the JSON
|
|
const isJsonOutput =
|
|
commandArgs.includes('--output-format') &&
|
|
(commandArgs.includes('json') ||
|
|
commandArgs.includes('stream-json'));
|
|
|
|
// If we have stderr output and it's not a JSON test, include that also
|
|
if (stderr && !isJsonOutput) {
|
|
result += `\n\nStdErr:\n${stderr}`;
|
|
}
|
|
|
|
resolve(result);
|
|
} else {
|
|
// Check if this is a JSON output test - for JSON errors, the error is in stdout
|
|
const isJsonOutputOnError =
|
|
commandArgs.includes('--output-format') &&
|
|
(commandArgs.includes('json') ||
|
|
commandArgs.includes('stream-json'));
|
|
|
|
// For JSON output tests, include stdout in the error message
|
|
// as the error JSON is written to stdout
|
|
if (isJsonOutputOnError && stdout) {
|
|
reject(
|
|
new Error(
|
|
`Process exited with code ${code}:\nStdout:\n${stdout}\n\nStderr:\n${stderr}`,
|
|
),
|
|
);
|
|
} else {
|
|
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
return promise;
|
|
}
|
|
|
|
runCommand(
|
|
args: string[],
|
|
options: { stdin?: string } = {},
|
|
): Promise<string> {
|
|
const { command, initialArgs } = this._getCommandAndArgs();
|
|
const commandArgs = [...initialArgs, ...args];
|
|
|
|
const child = spawn(command, commandArgs, {
|
|
cwd: this.testDir!,
|
|
stdio: 'pipe',
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
if (options.stdin) {
|
|
child.stdin!.write(options.stdin);
|
|
child.stdin!.end();
|
|
}
|
|
|
|
child.stdout!.on('data', (data: Buffer) => {
|
|
stdout += data;
|
|
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
|
process.stdout.write(data);
|
|
}
|
|
});
|
|
|
|
child.stderr!.on('data', (data: Buffer) => {
|
|
stderr += data;
|
|
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
|
process.stderr.write(data);
|
|
}
|
|
});
|
|
|
|
const promise = new Promise<string>((resolve, reject) => {
|
|
child.on('close', (code: number) => {
|
|
if (code === 0) {
|
|
this._lastRunStdout = stdout;
|
|
let result = stdout;
|
|
if (stderr) {
|
|
result += `\n\nStdErr:\n${stderr}`;
|
|
}
|
|
resolve(result);
|
|
} else {
|
|
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
return promise;
|
|
}
|
|
|
|
readFile(fileName: string) {
|
|
const filePath = join(this.testDir!, fileName);
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') {
|
|
console.log(`--- FILE: ${filePath} ---`);
|
|
console.log(content);
|
|
console.log(`--- END FILE: ${filePath} ---`);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
async cleanup() {
|
|
// A session a test never closed keeps its CLI child forwarding PTY bytes
|
|
// into this worker's stdout; after vitest tears the worker down those
|
|
// writes EPIPE and fail an otherwise all-green run (#10969).
|
|
for (const ptyProcess of this.interactiveProcesses.splice(0)) {
|
|
try {
|
|
ptyProcess.kill();
|
|
} catch {
|
|
// Process may have already exited
|
|
}
|
|
}
|
|
|
|
// Clean up test directory
|
|
if (this.testDir && !env['KEEP_OUTPUT']) {
|
|
try {
|
|
await rm(this.testDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
// Ignore cleanup errors
|
|
if (env['VERBOSE'] === 'true') {
|
|
console.warn('Cleanup warning:', (error as Error).message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async waitForTelemetryReady() {
|
|
// Telemetry is always written to the test directory
|
|
const logFilePath = join(this.testDir!, 'telemetry.log');
|
|
|
|
// Wait for telemetry file to exist and have content
|
|
await this.poll(
|
|
() => {
|
|
if (!fs.existsSync(logFilePath)) return false;
|
|
try {
|
|
const content = readFileSync(logFilePath, 'utf-8');
|
|
// Check if file has meaningful content (at least one complete JSON object)
|
|
return content.includes('"event.name"');
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
2000, // 2 seconds max - reduced since telemetry should flush on exit now
|
|
100, // check every 100ms
|
|
);
|
|
}
|
|
|
|
async waitForTelemetryEvent(eventName: string, timeout?: number) {
|
|
if (!timeout) {
|
|
timeout = this.getDefaultTimeout();
|
|
}
|
|
|
|
await this.waitForTelemetryReady();
|
|
|
|
return this.poll(
|
|
() => {
|
|
const logs = this._readAndParseTelemetryLog();
|
|
return logs.some(
|
|
(logData) =>
|
|
logData.attributes &&
|
|
logData.attributes['event.name'] === `qwen-code.${eventName}`,
|
|
);
|
|
},
|
|
timeout,
|
|
100,
|
|
);
|
|
}
|
|
|
|
async waitForToolCall(toolName: string, timeout?: number) {
|
|
// Use environment-specific timeout
|
|
if (!timeout) {
|
|
timeout = this.getDefaultTimeout();
|
|
}
|
|
|
|
// Wait for telemetry to be ready before polling for tool calls
|
|
await this.waitForTelemetryReady();
|
|
|
|
return this.poll(
|
|
() => {
|
|
const toolLogs = this.readToolLogs();
|
|
return toolLogs.some((log) => log.toolRequest.name === toolName);
|
|
},
|
|
timeout,
|
|
100,
|
|
);
|
|
}
|
|
|
|
async waitForAnyToolCall(toolNames: string[], timeout?: number) {
|
|
// Use environment-specific timeout
|
|
if (!timeout) {
|
|
timeout = this.getDefaultTimeout();
|
|
}
|
|
|
|
// Wait for telemetry to be ready before polling for tool calls
|
|
await this.waitForTelemetryReady();
|
|
|
|
return this.poll(
|
|
() => {
|
|
const toolLogs = this.readToolLogs();
|
|
return toolNames.some((name) =>
|
|
toolLogs.some((log) => log.toolRequest.name === name),
|
|
);
|
|
},
|
|
timeout,
|
|
100,
|
|
);
|
|
}
|
|
|
|
async poll(
|
|
predicate: () => boolean,
|
|
timeout: number,
|
|
interval: number,
|
|
): Promise<boolean> {
|
|
const startTime = Date.now();
|
|
let attempts = 0;
|
|
let lastError: unknown;
|
|
while (Date.now() - startTime < timeout) {
|
|
attempts++;
|
|
try {
|
|
const result = predicate();
|
|
if (env['VERBOSE'] === 'true' && attempts % 5 === 0) {
|
|
console.log(
|
|
`Poll attempt ${attempts}: ${result ? 'success' : 'waiting...'}`,
|
|
);
|
|
}
|
|
if (result) {
|
|
lastError = undefined;
|
|
return true;
|
|
}
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (env['VERBOSE'] === 'true') {
|
|
console.log(`Poll attempt ${attempts}: predicate threw: ${err}`);
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
}
|
|
if (lastError) {
|
|
console.log(
|
|
`Poll timed out after ${attempts} attempts. Last error:`,
|
|
lastError,
|
|
);
|
|
} else if (env['VERBOSE'] === 'true') {
|
|
console.log(`Poll timed out after ${attempts} attempts`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_parseToolLogsFromStdout(stdout: string) {
|
|
const logs: {
|
|
timestamp: number;
|
|
toolRequest: {
|
|
name: string;
|
|
args: string;
|
|
success: boolean;
|
|
duration_ms: number;
|
|
status?: string;
|
|
error?: string;
|
|
};
|
|
}[] = [];
|
|
|
|
// The console output from Podman is JavaScript object notation, not JSON
|
|
// Look for tool call events in the output
|
|
// Updated regex to handle tool names with hyphens and underscores
|
|
const toolCallPattern =
|
|
/body:\s*'Tool call:\s*([\w-]+)\..*?Success:\s*(\w+)\..*?Duration:\s*(\d+)ms\.'/g;
|
|
const matches = [...stdout.matchAll(toolCallPattern)];
|
|
|
|
for (const match of matches) {
|
|
const toolName = match[1];
|
|
const success = match[2] === 'true';
|
|
const duration = parseInt(match[3], 10);
|
|
|
|
// Try to find function_args nearby
|
|
const matchIndex = match.index || 0;
|
|
const contextStart = Math.max(0, matchIndex - 500);
|
|
const contextEnd = Math.min(stdout.length, matchIndex + 500);
|
|
const context = stdout.substring(contextStart, contextEnd);
|
|
|
|
// Look for function_args in the context
|
|
let args = '{}';
|
|
const argsMatch = context.match(/function_args:\s*'([^']+)'/);
|
|
if (argsMatch) {
|
|
args = argsMatch[1];
|
|
}
|
|
|
|
// Also try to find function_name to double-check
|
|
// Updated regex to handle tool names with hyphens and underscores
|
|
const nameMatch = context.match(/function_name:\s*'([\w-]+)'/);
|
|
const actualToolName = nameMatch ? nameMatch[1] : toolName;
|
|
|
|
logs.push({
|
|
timestamp: Date.now(),
|
|
toolRequest: {
|
|
name: actualToolName,
|
|
args: args,
|
|
success: success,
|
|
duration_ms: duration,
|
|
},
|
|
});
|
|
}
|
|
|
|
// If no matches found with the simple pattern, try the JSON parsing approach
|
|
// in case the format changes
|
|
if (logs.length === 0) {
|
|
const lines = stdout.split(EOL);
|
|
let currentObject = '';
|
|
let inObject = false;
|
|
let braceDepth = 0;
|
|
|
|
for (const line of lines) {
|
|
if (!inObject && line.trim() === '{') {
|
|
inObject = true;
|
|
braceDepth = 1;
|
|
currentObject = line + '\n';
|
|
} else if (inObject) {
|
|
currentObject += line + '\n';
|
|
|
|
// Count braces
|
|
for (const char of line) {
|
|
if (char === '{') braceDepth++;
|
|
else if (char === '}') braceDepth--;
|
|
}
|
|
|
|
// If we've closed all braces, try to parse the object
|
|
if (braceDepth === 0) {
|
|
inObject = false;
|
|
try {
|
|
const obj = JSON.parse(currentObject);
|
|
|
|
// Check for tool call in different formats
|
|
if (
|
|
obj.body &&
|
|
obj.body.includes('Tool call:') &&
|
|
obj.attributes
|
|
) {
|
|
const bodyMatch = obj.body.match(/Tool call: (\w+)\./);
|
|
if (bodyMatch) {
|
|
logs.push({
|
|
timestamp: obj.timestamp || Date.now(),
|
|
toolRequest: {
|
|
name: bodyMatch[1],
|
|
args: obj.attributes.function_args || '{}',
|
|
success: obj.attributes.success !== false,
|
|
duration_ms: obj.attributes.duration_ms || 0,
|
|
},
|
|
});
|
|
}
|
|
} else if (
|
|
obj.attributes &&
|
|
obj.attributes['event.name'] === 'qwen-code.tool_call'
|
|
) {
|
|
logs.push({
|
|
timestamp: obj.attributes['event.timestamp'],
|
|
toolRequest: {
|
|
name: obj.attributes.function_name,
|
|
args: obj.attributes.function_args,
|
|
success: obj.attributes.success,
|
|
duration_ms: obj.attributes.duration_ms,
|
|
},
|
|
});
|
|
}
|
|
} catch {
|
|
// Not valid JSON
|
|
}
|
|
currentObject = '';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return logs;
|
|
}
|
|
|
|
private _readAndParseTelemetryLog(): ParsedLog[] {
|
|
// Telemetry is always written to the test directory
|
|
const logFilePath = join(this.testDir!, 'telemetry.log');
|
|
|
|
if (!logFilePath || !fs.existsSync(logFilePath)) {
|
|
return [];
|
|
}
|
|
|
|
const content = readFileSync(logFilePath, 'utf-8');
|
|
|
|
// Split the content into individual JSON objects
|
|
// They are separated by "}\n{"
|
|
const jsonObjects = content
|
|
.split(/}\n{/)
|
|
.map((obj, index, array) => {
|
|
// Add back the braces we removed during split
|
|
if (index > 0) obj = '{' + obj;
|
|
if (index < array.length - 1) obj = obj + '}';
|
|
return obj.trim();
|
|
})
|
|
.filter((obj) => obj);
|
|
|
|
const logs: ParsedLog[] = [];
|
|
|
|
for (const jsonStr of jsonObjects) {
|
|
try {
|
|
const logData = JSON.parse(jsonStr);
|
|
logs.push(logData);
|
|
} catch (e) {
|
|
// Skip objects that aren't valid JSON
|
|
if (env.VERBOSE === 'true') {
|
|
console.error('Failed to parse telemetry object:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return logs;
|
|
}
|
|
|
|
readToolLogs() {
|
|
// For Podman, first check if telemetry file exists and has content
|
|
// If not, fall back to parsing from stdout
|
|
if (env['QWEN_SANDBOX'] === 'podman') {
|
|
// Try reading from file first
|
|
const logFilePath = join(this.testDir!, 'telemetry.log');
|
|
|
|
if (fs.existsSync(logFilePath)) {
|
|
try {
|
|
const content = readFileSync(logFilePath, 'utf-8');
|
|
if (content && content.includes('"event.name"')) {
|
|
// File has content, use normal file parsing
|
|
// Continue to the normal file parsing logic below
|
|
} else if (this._lastRunStdout) {
|
|
// File exists but is empty or doesn't have events, parse from stdout
|
|
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
|
}
|
|
} catch {
|
|
// Error reading file, fall back to stdout
|
|
if (this._lastRunStdout) {
|
|
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
|
}
|
|
}
|
|
} else if (this._lastRunStdout) {
|
|
// No file exists, parse from stdout
|
|
return this._parseToolLogsFromStdout(this._lastRunStdout);
|
|
}
|
|
}
|
|
|
|
const parsedLogs = this._readAndParseTelemetryLog();
|
|
// Every field is optional because it is copied straight out of the
|
|
// telemetry attributes, which nothing validates. The stdout fallback above
|
|
// reconstructs the same fields from a regex and can promise them; this
|
|
// branch cannot, and claiming otherwise just moved the `undefined` past
|
|
// the type checker into the assertions.
|
|
const logs: {
|
|
toolRequest: {
|
|
name?: string;
|
|
args?: string;
|
|
success?: boolean;
|
|
duration_ms?: number;
|
|
status?: string;
|
|
error?: string;
|
|
};
|
|
}[] = [];
|
|
|
|
for (const logData of parsedLogs) {
|
|
// Look for tool call logs
|
|
if (
|
|
logData.attributes &&
|
|
logData.attributes['event.name'] === 'qwen-code.tool_call'
|
|
) {
|
|
const toolName = logData.attributes.function_name;
|
|
logs.push({
|
|
toolRequest: {
|
|
name: toolName,
|
|
args: logData.attributes.function_args,
|
|
success: logData.attributes.success,
|
|
duration_ms: logData.attributes.duration_ms,
|
|
status: logData.attributes.status,
|
|
error: logData.attributes['error.message'],
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return logs;
|
|
}
|
|
|
|
// Returns the parsed log, not a bare record: callers want `.attributes`,
|
|
// and `Record<string, unknown>` hid that the value already has a shape.
|
|
readLastApiRequest(): ParsedLog | null {
|
|
const logs = this._readAndParseTelemetryLog();
|
|
const apiRequests = logs.filter(
|
|
(logData) =>
|
|
logData.attributes &&
|
|
logData.attributes['event.name'] === 'qwen-code.api_request',
|
|
);
|
|
return apiRequests.pop() || null;
|
|
}
|
|
|
|
// Unlike waitForTelemetryEvent's boolean poll, this exposes the latest
|
|
// matching payload so integration tests can assert event semantics.
|
|
readTelemetryEvent(eventName: string): ParsedLog | null {
|
|
const logs = this._readAndParseTelemetryLog();
|
|
const events = logs.filter(
|
|
(logData) =>
|
|
logData.attributes &&
|
|
logData.attributes['event.name'] === `qwen-code.${eventName}`,
|
|
);
|
|
return events.pop() || null;
|
|
}
|
|
|
|
readMetric(metricName: string): Record<string, unknown> | null {
|
|
const logs = this._readAndParseTelemetryLog();
|
|
for (const logData of logs) {
|
|
if (logData.scopeMetrics) {
|
|
for (const scopeMetric of logData.scopeMetrics) {
|
|
for (const metric of scopeMetric.metrics) {
|
|
if (metric.descriptor.name === `qwen-code.${metricName}`) {
|
|
return metric;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async waitForText(text: string, timeout?: number): Promise<boolean> {
|
|
if (!timeout) {
|
|
timeout = this.getDefaultTimeout();
|
|
}
|
|
return this.poll(
|
|
() =>
|
|
stripAnsi(this._interactiveOutput)
|
|
.toLowerCase()
|
|
.includes(text.toLowerCase()),
|
|
timeout,
|
|
200,
|
|
);
|
|
}
|
|
|
|
runInteractive(...args: string[]): {
|
|
ptyProcess: pty.IPty;
|
|
promise: Promise<{ exitCode: number; signal?: number; output: string }>;
|
|
} {
|
|
return this.runInteractiveWith({}, ...args);
|
|
}
|
|
|
|
/**
|
|
* `runInteractive` with the launch knobs it does not expose. Currently just
|
|
* `chatRecording`, which a `--continue` test needs so the first session is
|
|
* on disk for the second one to resume.
|
|
*/
|
|
runInteractiveWith(
|
|
options: { chatRecording?: boolean },
|
|
...args: string[]
|
|
): {
|
|
ptyProcess: pty.IPty;
|
|
promise: Promise<{ exitCode: number; signal?: number; output: string }>;
|
|
} {
|
|
const renderer = pickE2eRenderer();
|
|
const { command: cliCommand, initialArgs } = this._getCommandAndArgs(
|
|
['--yolo'],
|
|
options,
|
|
);
|
|
// The renderer matrix swaps node for bun only when driving the repo
|
|
// bundle directly; installed-release runs keep their own launcher and
|
|
// still get the pinned QWEN_TUI_RENDERER below.
|
|
const isNpmRelease =
|
|
process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true';
|
|
const command = isNpmRelease ? cliCommand : resolveE2eCliCommand(renderer);
|
|
const commandArgs = [...initialArgs, ...args];
|
|
|
|
this._interactiveOutput = ''; // Reset output for the new run
|
|
|
|
const ptyProcess = pty.spawn(command, commandArgs, {
|
|
name: 'xterm-color',
|
|
cols: 80,
|
|
rows: 30,
|
|
cwd: this.testDir!,
|
|
env: {
|
|
...process.env,
|
|
...e2eRendererEnv(renderer),
|
|
} as { [key: string]: string },
|
|
});
|
|
this.interactiveProcesses.push(ptyProcess);
|
|
|
|
ptyProcess.onData((data) => {
|
|
this._interactiveOutput += data;
|
|
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
|
|
process.stdout.write(data);
|
|
}
|
|
});
|
|
|
|
const promise = new Promise<{
|
|
exitCode: number;
|
|
signal?: number;
|
|
output: string;
|
|
}>((resolve) => {
|
|
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
resolve({ exitCode, signal, output: this._interactiveOutput });
|
|
});
|
|
});
|
|
|
|
return { ptyProcess, promise };
|
|
}
|
|
}
|
|
|
|
export async function runForcedToolCallScenario(options: {
|
|
rig: TestRig;
|
|
toolCall: FakeOpenAIToolCall;
|
|
prompt: string;
|
|
finalResponse: string;
|
|
}): Promise<Array<Record<string, unknown>>> {
|
|
const { rig, toolCall, prompt, finalResponse } = options;
|
|
let streamingRequestIndex = 0;
|
|
const fakeServer = await startFakeOpenAIServer(({ body }) => {
|
|
if (body['stream'] !== true) {
|
|
return { content: '{"selected_memories":[]}' };
|
|
}
|
|
if (streamingRequestIndex++ === 0) {
|
|
return { toolCalls: [toolCall] };
|
|
}
|
|
return { content: finalResponse };
|
|
}, fakeServerHostOptions());
|
|
|
|
const noProxy = IS_CONTAINER_SANDBOX
|
|
? CONTAINER_SANDBOX_NO_PROXY
|
|
: '127.0.0.1,localhost';
|
|
vi.stubEnv('OPENAI_API_KEY', 'fake-key');
|
|
vi.stubEnv('OPENAI_BASE_URL', fakeServer.baseUrl);
|
|
vi.stubEnv('OPENAI_MODEL', 'fake-model');
|
|
vi.stubEnv('QWEN_MODEL', 'fake-model');
|
|
vi.stubEnv('QWEN_HOME', join(rig.testDir!, '.qwen-home'));
|
|
vi.stubEnv('QWEN_RUNTIME_DIR', join(rig.testDir!, '.qwen-home'));
|
|
vi.stubEnv('QWEN_CODE_MODELS_DEV_REFRESH', 'off');
|
|
vi.stubEnv('NO_PROXY', noProxy);
|
|
vi.stubEnv('no_proxy', noProxy);
|
|
|
|
try {
|
|
// Explicit CLI flags outrank a developer's ~/.qwen/settings.json
|
|
// (settings.model.name beats the OPENAI_MODEL env var and can silently
|
|
// route the run to a real model endpoint instead of the fake server).
|
|
await rig.run(
|
|
prompt,
|
|
'--auth-type',
|
|
'openai',
|
|
'--model',
|
|
'fake-model',
|
|
'--openai-base-url',
|
|
fakeServer.baseUrl,
|
|
'--openai-api-key',
|
|
'fake-key',
|
|
);
|
|
return fakeServer.requests
|
|
.filter(({ body }) => body['stream'] === true)
|
|
.map(({ body }) => body);
|
|
} finally {
|
|
await fakeServer.close();
|
|
}
|
|
}
|