Files
qwen-code/scripts/tests/mod-worker-bundle.test.js
T
qqqysandShaojin Wen 9c96f5aa82 feat(mods): run prompt.submit hooks in headless runs (#15905)
* feat(mods): run prompt.submit hooks in headless runs

Mods' prompt.submit hooks can rewrite, add context to, or drop each headless prompt before UserPromptSubmit, @ processing and the transcript see it. Terminals keep skipping these hooks until they raise the event. Includes the design document for the four steps.

* test(mods): update bundled worker load response expectation

* fix(mods): preserve downstream prompt submit outcomes

* fix(mods): isolate prompt fallback from host continuations

* fix(mods): preserve latest local prompt outcome in recovery

* fix(mods): hear only a message's text and take any answer after next

A stream-json message with an image block reached the prompt.submit
hooks as its serialized blocks, so they were skipped as too long or saw
JSON noise, and a rewrite carried that JSON into humanInput and
turn.start. The hooks now hear its text blocks; a rewrite replaces them
and the other blocks follow it.

A hook's answer after its next changes nothing, but it was still
validated, so a hook that only watches and returns nothing, or spreads
the event back, failed and logged a skip on every prompt. Only a drop
there is now checked.

* fix(mods): keep prompt.submit to typed text and drop empty rewrites

- Show only the rewritten text in the transcript and UserPromptSubmit,
  not the serialized non-text blocks of a stream-json message.
- Skip prompt.submit for SDK messages that answer a tool call or carry
  tool blocks, and for messages without text.
- Drop a prompt the hooks rewrote to nothing instead of sending the
  model an empty prompt.

* fix(mods): send each prompt context once on its own

The Mods' prompt.submit context and UserPromptSubmit's additionalContext
share one hidden block, and the block was only skipped when the
conversation already held it whole. A Mod whose context changes every
turn made every block new, so an unchanged UserPromptSubmit context went
to the model again each turn.

The injected part now records the contexts it joins, and a context counts
as delivered when an earlier block holds it, so only the contexts the
conversation lacks are sent. A block without that record, as after
resume, still counts as one context.

* fix(mods): keep a message's blocks in the transcript after a rewrite

A stream-json message with blocks other than text is recorded with those
blocks serialized after its text. A prompt.submit rewrite recorded the
rewritten text alone, so the same kind of message showed two shapes in the
transcript. The rewritten prompt is now recorded with the blocks after it.

Adds the headless E2E test plan and its results.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-11-02 09:14:14 +00:00

120 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,
promptSubmit: [],
turnComplete: [],
render: [],
});
expect(
await dispatch({ type: 'command', command: 'hello', args: '' }),
).toEqual({ text: 'bundled:option:undefined' });
});