add integration test and --experimental-hooks

This commit is contained in:
DennisYu07 2026-03-02 19:05:28 -08:00
parent 981d1aed3e
commit 7b0929d00c
22 changed files with 1105 additions and 1614 deletions

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Parallel hooks executed with mixed results.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Parallel hooks executed with mixed results - one succeeded, one failed.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Sequential hook 1 executed first.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The first hook blocked the request. Second hook was not executed.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Sequential hooks executed with output passthrough.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Output from first hook was passed to second hook successfully.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Final response with additional context from hook.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have received the final context from the stop hook and included it in my response.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Stopping with warning from hook.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The stop hook had an error but I completed my response.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Stopping now. Reason: Hook specified stop reason.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed with custom stop reason from hook.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The stop hook timed out but stopping anyway.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The stop hook timed out but I completed my response.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Received last assistant message in hook.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Final system message from stop hook has been processed.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The hook has added additional context to your request.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":120,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have received additional context from the hook and will use it in my response.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Empty prompt received and handled.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":50,"totalTokenCount":70}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I received an empty prompt. How can I help you?","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Request denied due to critical error.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The hook had a blocking error and your request was blocked.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"There was a warning but the request continues.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The hook had a non-blocking error but I continued processing your request.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The command was not found but continuing.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The hook command was not found but I continued processing your request.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I've received your modified request. I'll respond to the modified version.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":120,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I received your modified prompt and will process it accordingly.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The request timed out but will continue.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The hook timed out but I continued processing your request.","thought":true}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":100,"totalTokenCount":120}}]}

View file

@ -1,325 +0,0 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('hooks', () => {
it('should execute Stop hook when response finishes', async () => {
const rig = new TestRig();
await rig.setup('should execute Stop hook when response finishes', {
settings: {
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'echo "STOP_HOOK_EXECUTED" > stop_hook_result.txt',
name: 'test-stop-hook',
},
],
},
],
},
trusted: true,
},
});
const prompt = `Say "hello" and that's it.`;
const result = await rig.run(prompt);
// Wait for telemetry to be ready (hook should have executed)
await rig.waitForTelemetryReady();
// Check that the Stop hook executed by looking for the output file
try {
const hookOutput = rig.readFile('stop_hook_result.txt');
expect(hookOutput).toContain('STOP_HOOK_EXECUTED');
} catch {
// Hook file might not exist - check telemetry for hook execution
// Stop hook is a command hook, it may not appear in tool logs
// but the test should at least complete without errors
}
// Validate model output
validateModelOutput(result, 'hello', 'Stop hook test');
});
it('should execute UserPromptSubmit hook when user submits prompt', async () => {
const rig = new TestRig();
await rig.setup(
'should execute UserPromptSubmit hook when user submits prompt',
{
settings: {
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command:
'echo "USER_PROMPT_SUBMITTED: $QWEN_HOOK_PROMPT" > prompt_hook_result.txt',
name: 'test-prompt-submit-hook',
},
],
},
],
},
trusted: true,
},
},
);
const prompt = `Just say "received" and nothing else.`;
const result = await rig.run(prompt);
// Wait for telemetry
await rig.waitForTelemetryReady();
// Check that the UserPromptSubmit hook executed
try {
const hookOutput = rig.readFile('prompt_hook_result.txt');
expect(hookOutput).toContain('USER_PROMPT_SUBMITTED');
} catch {
// Hook file might not exist - that's okay, the test verifies the CLI runs
}
// Validate model output
validateModelOutput(result, 'received', 'UserPromptSubmit hook test');
});
it('should execute both Stop and UserPromptSubmit hooks', async () => {
const rig = new TestRig();
await rig.setup('should execute both Stop and UserPromptSubmit hooks', {
settings: {
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: 'echo "stop_executed" > both_stop.txt',
name: 'stop-hook',
},
],
},
],
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command: 'echo "prompt_submitted" > both_prompt.txt',
name: 'prompt-hook',
},
],
},
],
},
trusted: true,
},
});
const prompt = `Say "testing both hooks".`;
const result = await rig.run(prompt);
// Wait for telemetry
await rig.waitForTelemetryReady();
// Check both hooks executed
try {
const stopOutput = rig.readFile('both_stop.txt');
expect(stopOutput).toContain('stop_executed');
} catch {
/* empty */
}
try {
const promptOutput = rig.readFile('both_prompt.txt');
expect(promptOutput).toContain('prompt_submitted');
} catch {
/* empty */
}
validateModelOutput(result, 'testing both hooks', 'Both hooks test');
});
it('should support sequential hook execution for Stop event', async () => {
const rig = new TestRig();
await rig.setup('should support sequential hook execution for Stop event', {
settings: {
hooks: {
Stop: [
{
sequential: true,
hooks: [
{
type: 'command',
command: 'echo "first" > seq1.txt',
name: 'seq-hook-1',
},
{
type: 'command',
command: 'echo "second" > seq2.txt',
name: 'seq-hook-2',
},
],
},
],
},
trusted: true,
},
});
const prompt = `Say "sequential test".`;
const result = await rig.run(prompt);
// Wait for telemetry
await rig.waitForTelemetryReady();
// Check that both sequential hooks executed
try {
const firstOutput = rig.readFile('seq1.txt');
expect(firstOutput).toContain('first');
} catch {
/* empty */
}
try {
const secondOutput = rig.readFile('seq2.txt');
expect(secondOutput).toContain('second');
} catch {
/* empty */
}
validateModelOutput(
result,
'sequential test',
'Sequential Stop hooks test',
);
});
it('should support matcher for Stop hook', async () => {
const rig = new TestRig();
await rig.setup('should support matcher for Stop hook', {
settings: {
hooks: {
Stop: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: 'echo "matched_stop" > matcher_stop.txt',
name: 'matcher-stop-hook',
},
],
},
],
},
trusted: true,
},
});
const prompt = `Create a file "matcher_test.txt" with content "hello".`;
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('write_file');
if (!foundToolCall) {
printDebugInfo(rig, result);
}
expect(foundToolCall).toBeTruthy();
validateModelOutput(result, 'matcher_test.txt', 'Matcher Stop hook test');
const fileContent = rig.readFile('matcher_test.txt');
expect(fileContent).toContain('hello');
});
it('should allow Stop hook to add additional context to response', async () => {
const rig = new TestRig();
await rig.setup(
'should allow Stop hook to add additional context to response',
{
settings: {
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command:
'echo "{\\"continue\\": true, \\"hookSpecificOutput\\": {\\"additionalContext\\": \\"Custom context from hook\\"}}}"',
name: 'context-hook',
},
],
},
],
},
trusted: true,
},
},
);
const prompt = `Say "test complete".`;
const result = await rig.run(prompt);
// Wait for telemetry
await rig.waitForTelemetryReady();
// The hook can add context to the response
// Check that the model produced output
validateModelOutput(result, 'test complete', 'Stop hook with context test');
});
it('should allow UserPromptSubmit hook to add system message', async () => {
const rig = new TestRig();
await rig.setup(
'should allow UserPromptSubmit hook to add system message',
{
settings: {
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: 'command',
command:
'echo "{\\"continue\\": true, \\"systemMessage\\": \\"You are being tested.\\"}}}"',
name: 'system-msg-hook',
},
],
},
],
},
trusted: true,
},
},
);
const prompt = `What is 2+2?`;
const result = await rig.run(prompt);
// Wait for telemetry
await rig.waitForTelemetryReady();
// The hook can add a system message that influences the response
validateModelOutput(
result,
'4',
'UserPromptSubmit with system message test',
);
});
});

View file

@ -145,6 +145,7 @@ export class TestRig {
testName?: string;
_lastRunStdout?: string;
_interactiveOutput = '';
_fakeResponsesPath?: string;
constructor() {
this.bundlePath = join(__dirname, '..', 'dist/cli.js');
@ -160,13 +161,21 @@ export class TestRig {
setup(
testName: string,
options: { settings?: Record<string, unknown> } = {},
options: {
settings?: Record<string, unknown>;
fakeResponsesPath?: string;
} = {},
) {
this.testName = testName;
const sanitizedName = sanitizeTestName(testName);
this.testDir = join(env['INTEGRATION_TEST_FILE_DIR']!, sanitizedName);
mkdirSync(this.testDir, { recursive: true });
// Store fake responses path for use in run()
if (options.fakeResponsesPath) {
this._fakeResponsesPath = options.fakeResponsesPath;
}
// Create a settings file to point the CLI to the local collector
const qwenDir = join(this.testDir, '.qwen');
mkdirSync(qwenDir, { recursive: true });
@ -190,6 +199,16 @@ export class TestRig {
);
}
/**
* Creates a script file in the test directory and returns its path.
* Useful for creating hook scripts that need to be executed.
*/
createScript(fileName: string, content: string): string {
const filePath = join(this.testDir!, fileName);
writeFileSync(filePath, content, { mode: 0o755 });
return filePath;
}
createFile(fileName: string, content: string) {
const filePath = join(this.testDir!, fileName);
writeFileSync(filePath, content);
@ -256,10 +275,16 @@ export class TestRig {
commandArgs.push(...args);
// Set up environment with fake responses path if configured
const childEnv = { ...process.env };
if (this._fakeResponsesPath) {
childEnv['QWEN_FAKE_RESPONSES_PATH'] = this._fakeResponsesPath;
}
const child = spawn(command, commandArgs, {
cwd: this.testDir!,
stdio: 'pipe',
env: process.env,
env: childEnv,
});
let stdout = '';

View file

@ -125,6 +125,7 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalLsp: boolean | undefined;
experimentalHooks: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
@ -338,6 +339,12 @@ export async function parseArguments(): Promise<CliArgs> {
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('experimental-hooks', {
type: 'boolean',
description:
'Enable experimental hooks feature for lifecycle event customization',
default: false,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
@ -1027,6 +1034,8 @@ export async function loadCliConfig(
format: outputSettingsFormat,
},
hooks: settings.hooks,
enableHooks:
argv.experimentalHooks === true || settings.hooks?.enabled === true,
channel: argv.channel,
// Precedence: explicit CLI flag > settings file > default(true).
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will

View file

@ -1187,6 +1187,16 @@ const SETTINGS_SCHEMA = {
'Hook configurations for extending CLI behavior at various lifecycle points.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Hooks',
category: 'Advanced',
requiresRestart: false,
default: false,
description:
'Enable the hooks feature. When enabled, hooks defined in UserPromptSubmit and Stop will be executed.',
showInDialog: false,
},
disabled: {
type: 'array',
label: 'Disabled Hooks',

View file

@ -497,6 +497,7 @@ describe('gemini.tsx main function kitty protocol', () => {
authType: undefined,
maxSessionTurns: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,

View file

@ -688,7 +688,7 @@ export class Config {
enabledExtensionOverrides: this.overrideExtensions,
isWorkspaceTrusted: this.isTrustedFolder(),
});
this.enableHooks = params.enableHooks ?? true;
this.enableHooks = params.enableHooks ?? false;
this.hooks = params.hooks;
}