Skip to content
Merged
Show file tree
Hide file tree
Changes from 59 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
522b074
feat(cli): add native advisor tool
Aug 19, 2026
535f645
fix(core): harden native advisor consults
Aug 19, 2026
4676c5c
fix(core): run native advisor through forked agent
Aug 20, 2026
c13fc64
fix(cli): validate advisor startup model
Aug 20, 2026
c3cb456
fix(core): tolerate advisor JSON text responses
Aug 20, 2026
9567978
fix(cli): preserve advisor continuation order
Aug 21, 2026
2f390fb
test(advisor): streamline coverage
Aug 21, 2026
9269ca2
fix(cli): sync advisor display translations
Aug 21, 2026
4df7b7f
fix(cli): add advisor description to i18n baseline
Aug 21, 2026
f142a2e
fix(web-shell): translate advisor tool name
Aug 21, 2026
65b9e55
refactor(advisor): keep changes feature-scoped
Aug 24, 2026
8c8153e
fix(advisor): follow forked agent module move
Aug 24, 2026
7057ebe
fix(advisor): address blocking review findings
Aug 25, 2026
1147cd6
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 25, 2026
b236ca9
fix(advisor): address review suggestions
Aug 25, 2026
8792ef7
fix(advisor): address second review findings
Aug 25, 2026
8e9cf84
test(cli): align advisor settings expectations
Aug 25, 2026
5b1398e
fix(advisor): address third review findings
Aug 25, 2026
930056d
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 25, 2026
2e9498a
fix(advisor): align native tool with minimal scope
Aug 25, 2026
ed2e82c
fix(advisor): ignore workspace model settings
Aug 25, 2026
bae39b6
fix(core): register advisor permission aliases
Aug 25, 2026
2def1a8
fix(cli): avoid disabling stale advisor selection
Aug 26, 2026
23bd25e
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 26, 2026
b31faea
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 26, 2026
309a94f
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 26, 2026
3db972e
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 26, 2026
8911c6f
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 26, 2026
ce52b3b
fix(cli): address advisor review regressions
Aug 26, 2026
9aed08f
test(core): update telemetry swap mock config
Aug 26, 2026
4245950
fix(cli): align advisor picker fast model eligibility
Aug 26, 2026
fc763a5
fix(cli): reject unavailable advisor picker selections
Aug 27, 2026
1d031e7
fix(advisor): preserve manual command semantics
Aug 27, 2026
a573743
fix(cli): show advisor focus hint
Aug 27, 2026
d59335a
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 27, 2026
9a61fef
fix(advisor): stabilize structured review output
Aug 27, 2026
7c6a413
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 27, 2026
3652ae3
fix(advisor): reject non-object structured results
Aug 27, 2026
4801448
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 27, 2026
4a0ebdd
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 27, 2026
f50133a
fix(cli): allow runtime advisor models
Aug 27, 2026
a2a814b
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 27, 2026
3a1e43c
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 27, 2026
bb8ebd1
fix(cli): allow uncaptured runtime advisor model
Aug 28, 2026
b963636
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 28, 2026
184c1f4
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 28, 2026
24fa93c
test: align advisor tests with llm rename
Aug 28, 2026
32f44aa
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 28, 2026
d536a68
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 29, 2026
cda083b
fix(advisor): enforce independent model availability
Aug 29, 2026
fe10336
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Aug 29, 2026
1910bd4
fix(advisor): report disabled tool selection
Aug 29, 2026
4b61aaf
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Sep 1, 2026
9ce81f9
fix(advisor): tolerate extra review fields
Sep 1, 2026
3d276bd
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
Sep 1, 2026
14d7c5b
fix(advisor): preserve model routes and cache reuse after main sync
yiliang114 Sep 25, 2026
08fc82e
fix(advisor): sync settings coverage and retire hidden presentation a…
yiliang114 Sep 25, 2026
8824c5c
Merge branch 'main' into codex/advisor-native-tool
yiliang114 Sep 25, 2026
f10c2d9
fix(advisor): preserve schema results and honor deferred tool registr…
yiliang114 Sep 25, 2026
13ffa6e
fix(web-shell): keep the Advisor presentation alias
yiliang114 Sep 25, 2026
ebb4a30
fix(serve): keep serving the Advisor setting to the Web Shell
yiliang114 Sep 25, 2026
5c96f29
fix(serve): refuse the Advisor workspace write the merge strips
yiliang114 Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 343 additions & 0 deletions integration-tests/cli/advisor-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import {
CONTAINER_SANDBOX_NO_PROXY,
fakeServerHostOptions,
IS_CONTAINER_SANDBOX,
TestRig,
} from '../test-helper.js';
import {
fakeToolCall,
startFakeOpenAIServer,
type FakeOpenAIServer,
} from '../fake-openai-server.js';

type JsonObject = Record<string, unknown>;

let rig: TestRig | undefined;
let server: FakeOpenAIServer | undefined;

afterEach(async () => {
vi.unstubAllEnvs();
await server?.close();
await rig?.cleanup();
server = undefined;
rig = undefined;
});

function messages(body: JsonObject): JsonObject[] {
return Array.isArray(body['messages'])
? body['messages'].filter(
(value): value is JsonObject =>
typeof value === 'object' && value !== null,
)
: [];
}

function contentText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.map((part) =>
typeof part === 'object' &&
part !== null &&
typeof (part as JsonObject)['text'] === 'string'
? String((part as JsonObject)['text'])
: '',
)
.join('\n');
}

function requestText(body: JsonObject): string {
return messages(body)
.map((message) => contentText(message['content']))
.join('\n');
}

function toolNames(body: JsonObject): string[] {
return Array.isArray(body['tools'])
? body['tools'].flatMap((tool) => {
if (typeof tool !== 'object' || tool === null) return [];
const fn = (tool as JsonObject)['function'];
if (typeof fn !== 'object' || fn === null) return [];
const name = (fn as JsonObject)['name'];
return typeof name === 'string' ? [name] : [];
})
: [];
}

function configureEnv(testDir: string, baseUrl: string): void {
const noProxy = IS_CONTAINER_SANDBOX
? CONTAINER_SANDBOX_NO_PROXY
: '127.0.0.1,localhost';
vi.stubEnv('HOME', testDir);
vi.stubEnv('QWEN_HOME', join(testDir, '.qwen'));
vi.stubEnv('QWEN_RUNTIME_DIR', join(testDir, '.qwen'));
vi.stubEnv('OPENAI_API_KEY', 'fake-key');
vi.stubEnv('OPENAI_BASE_URL', baseUrl);
vi.stubEnv('OPENAI_MODEL', 'executor-model');
vi.stubEnv('QWEN_MODEL', 'executor-model');
for (const name of [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
]) {
vi.stubEnv(name, '');
}
vi.stubEnv('NO_PROXY', noProxy);
vi.stubEnv('no_proxy', noProxy);
}

async function setupRig(baseUrl: string): Promise<TestRig> {
const nextRig = new TestRig();
await nextRig.setup('native advisor tool', {
settings: {
modelProviders: {
openai: [
{
id: 'executor-model',
name: 'Executor Model',
baseUrl,
envKey: 'OPENAI_API_KEY',
},
{
id: 'advisor-model',
name: 'Advisor Model',
baseUrl,
envKey: 'OPENAI_API_KEY',
},
],
},
security: { auth: { selectedType: 'openai' } },
advisorModel: 'advisor-model',
ui: { enableFollowupSuggestions: false },
},
});
configureEnv(nextRig.testDir!, baseUrl);
return nextRig;
}

async function runPrompt(prompt: string, advisor = 'advisor-model') {
const sessionId = crypto.randomUUID();
const input = [
{
type: 'control_request',
request_id: 'initialize-advisor-test',
request: { subtype: 'initialize' },
},
{
type: 'user',
session_id: sessionId,
message: { role: 'user', content: prompt },
parent_tool_use_id: null,
},
]
.map((message) => JSON.stringify(message))
.join('\n');
return rig!.run(
{ stdin: input },
'--auth-type',
'openai',
'--model',
'executor-model',
'--advisor',
advisor,
'--openai-base-url',
server!.baseUrl,
'--openai-api-key',
'fake-key',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
);
}

describe('native Advisor tool', () => {
it('forwards the transcript to a no-tools model and reinjects its review', async () => {
let evidenceFile = '';
server = await startFakeOpenAIServer(({ body }) => {
if (body['model'] === 'advisor-model') {
return {
content: JSON.stringify({
verdict: 'The approach is sound.',
risks: 'None found.',
missingEvidence: 'None found.',
recommendation: 'Finish the task.',
}),
};
}
if (body['stream'] !== true) {
return { content: '{"selected_memories":[]}' };
}
const text = requestText(body);
if (text.includes('The approach is sound.')) {
return { content: 'Final answer after Advisor feedback.' };
}
if (text.includes('wire-level evidence')) {
return {
content: 'I found the evidence and will ask for a second opinion.',
toolCalls: [fakeToolCall('advisor', {}, 'advisor-call')],
};
}
if (text.includes('Review the task and finish.')) {
return {
content: 'I will inspect the evidence first.',
toolCalls: [
fakeToolCall('read_file', { file_path: evidenceFile }, 'read-call'),
],
};
}
return { content: 'Prior answer with unique context.' };
}, fakeServerHostOptions());

rig = await setupRig(server.baseUrl);
evidenceFile = rig.createFile('evidence.txt', 'wire-level evidence');
const sessionId = crypto.randomUUID();
const input = [
{
type: 'control_request',
request_id: 'initialize-advisor-test',
request: { subtype: 'initialize' },
},
{
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Remember this prior request.' },
parent_tool_use_id: null,
},
{
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Review the task and finish.' },
parent_tool_use_id: null,
},
]
.map((message) => JSON.stringify(message))
.join('\n');

const output = await rig.run(
{ stdin: input },
'--auth-type',
'openai',
'--model',
'executor-model',
'--advisor',
'advisor-model',
'--openai-base-url',
server.baseUrl,
'--openai-api-key',
'fake-key',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
);

expect(output).toContain('Final answer after Advisor feedback.');
const requests = server.requests.map((request) => request.body);
const executorRequests = requests.filter(
(body) => body['stream'] === true && body['model'] === 'executor-model',
);
const advisorRequests = requests.filter(
(body) => body['model'] === 'advisor-model',
);
expect(
executorRequests.some((request) =>
toolNames(request).includes('advisor'),
),
).toBe(true);
expect(advisorRequests).toHaveLength(1);
expect(toolNames(advisorRequests[0]!)).toEqual(['respond_in_schema']);

const advisorText = requestText(advisorRequests[0]!);
const evidenceText = messages(advisorRequests[0]!)
.map((message) => contentText(message['content']))
.find((text) => text.startsWith('{'));
expect(evidenceText).toBeDefined();
const evidence = JSON.parse(evidenceText!) as JsonObject;
expect(advisorText).toContain('independent senior advisor');
expect(JSON.stringify(evidence['executorSystemInstruction'])).toContain(
'You are Qwen Code',
);
expect(JSON.stringify(evidence['executorToolDeclarations'])).toContain(
'read_file',
);
const transcript = JSON.stringify(evidence['transcript']);
expect(transcript).toContain('Remember this prior request.');
expect(transcript).toContain('Prior answer with unique context.');
expect(transcript).toContain('wire-level evidence');
expect(transcript).toContain('I will inspect the evidence first.');
expect(transcript).toContain(
'I found the evidence and will ask for a second opinion.',
);
});

it('lets the executor continue after an Advisor failure', async () => {
server = await startFakeOpenAIServer(({ body }) => {
if (body['model'] === 'advisor-model') {
return { content: 'invalid advisor output' };
}
if (body['stream'] !== true) {
return { content: '{"selected_memories":[]}' };
}
const text = requestText(body);
if (text.includes('Advisor returned invalid structured output.')) {
Comment thread
ZijianZhang989 marked this conversation as resolved.
Comment thread
ZijianZhang989 marked this conversation as resolved.
return { content: 'Executor continued without Advisor.' };
}
return {
content: 'I will ask Advisor.',
toolCalls: [fakeToolCall('advisor', {}, 'advisor-failure-call')],
};
}, fakeServerHostOptions());
rig = await setupRig(server.baseUrl);

const output = await runPrompt('Test Advisor failure handling.');

expect(output).toContain('Executor continued without Advisor.');
expect(
server.requests.filter(
(request) => request.body['model'] === 'advisor-model',
),
).toHaveLength(1);
});

it('does not expose or request Advisor when the session override is off', async () => {
server = await startFakeOpenAIServer(
({ body }) => ({
content:
body['stream'] === true
? 'Advisor is not available in this request.'
: '{"selected_memories":[]}',
}),
fakeServerHostOptions(),
);
rig = await setupRig(server.baseUrl);

const output = await runPrompt('Check the available tools.', 'off');

expect(output).toContain('Advisor is not available in this request.');
const executorRequests = server.requests.filter(
(request) => request.body['model'] === 'executor-model',
);
expect(
executorRequests.every(
(request) => !toolNames(request.body).includes('advisor'),
),
).toBe(true);
expect(
server.requests.some(
(request) => request.body['model'] === 'advisor-model',
),
).toBe(false);
});
});
Loading
Loading