Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ce6688c
feat(goal): start approved Web Shell proposals after their owning turn
qqqys Sep 8, 2026
da052a8
Merge branch 'main' into feat/web-shell-goal-proposals
qwen-code-dev-bot Sep 8, 2026
3f3a091
Merge branch 'main' into feat/web-shell-goal-proposals
wenshao Sep 8, 2026
df0122b
Merge remote-tracking branch 'upstream/main' into feat/web-shell-goal…
qqqys Sep 9, 2026
ea2dce7
fix(goal): close proposal lifecycle gaps
qqqys Sep 9, 2026
866b366
Merge remote-tracking branch 'upstream/main' into feat/web-shell-goal…
qqqys Sep 9, 2026
40e2ddf
fix(goal): address settlement review gaps
qqqys Sep 9, 2026
a8bfbfb
Merge remote-tracking branch 'upstream/main' into codex/pr-11360-r3-2…
qqqys Sep 9, 2026
bddbff8
Merge remote-tracking branch 'upstream/main' into codex/pr-11360-r3-2…
qqqys Sep 9, 2026
8893cf0
fix(goal): block proposal settlement when a guard continuation yields…
qwen-code-ci-bot Sep 10, 2026
3a34729
Merge remote-tracking branch 'origin/main' into feat/web-shell-goal-p…
qwen-code-ci-bot Sep 10, 2026
1248cfd
test(cli): deflake the ab-drive run-dir sweep counts against foreign …
qwen-code-ci-bot Sep 10, 2026
aeadac2
fix(goal): revoke proposal approval when entering Plan mode
qqqys Sep 10, 2026
622b7b4
Merge remote-tracking branch 'upstream/main' into codex/pr11360-conve…
qqqys Sep 10, 2026
56f115f
Merge remote-tracking branch 'upstream/main' into codex/pr11360-conve…
qqqys Sep 10, 2026
60e2c85
fix(goal): give ACP proposal settlement one owner
qqqys Sep 10, 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
4 changes: 2 additions & 2 deletions docs/users/features/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ For an audit, completion means covering the agreed scenarios and reporting evide

If a success criterion, command, input path, or essential decision cannot be established, the skill returns a draft marked "Needs clarification" with `<TODO: …>` items. It does not offer that draft for approval or print a runnable `/goal set` or `/goal edit` command. Nonessential defaults are marked `[ASSUMPTION]`; they do not stand in for missing success criteria.

Once the objective is ready, an interactive terminal session can show the `propose_goal` approval dialog described below. Web Shell and other ACP clients, headless runs, sessions with the tool disabled, and sessions with an active Goal receive a command to run manually instead. The hand-off says that the draft has not been applied. The skill never starts the work itself, and nothing is set without your approval.
Once the objective is ready, an interactive terminal or Web Shell session can show the `propose_goal` approval dialog described below. Clients without Goal proposal support, headless runs, sessions with the tool disabled, and sessions with an active Goal receive a command to run manually instead. The hand-off says that the draft has not been applied. The skill never starts the work itself, and nothing is set without your approval.

Pass an existing objective to tighten it: `/goal-draft all tests pass and the lint is clean`. For an active Goal, an explicit request to tighten it produces `/goal edit`; a replacement uses `/goal set`. If the intended operation is unclear, the skill includes that choice in its single round of questions.

### Approve a Goal the model proposes

In an interactive terminal session the model has a `propose_goal` tool. When `/goal-draft` finishes, or when you ask for an outcome that spans several turns, it can propose the objective instead of printing a `/goal set …` line for you to copy. The proposal appears as an approval dialog showing the full objective. Approving it sets the Goal exactly as `/goal set` would, the moment the current turn ends (the model acknowledges and stops; the first Goal turn then starts on its own), and declining sets nothing — the model sees only that the tool call was not allowed, and its instructions tell it not to ask why and not to propose the same objective again. The approval is bound to the turn that asked for it: if that turn is cancelled or otherwise never reaches its end, the approval is dropped rather than applied under a later message or an automated turn. No permission rule or approval mode (including YOLO) skips this dialog, and the tool refuses while another Goal is active, in plan mode, and in untrusted folders; subagents are never offered it. It is not available in headless runs, nor yet in Web Shell or other ACP-driven sessions (they do not pass through the turn boundary that applies the approval); there the printed `/goal set` line remains the hand-off.
In an interactive terminal or a Web Shell turn with an attached client, the model has a `propose_goal` tool. When `/goal-draft` finishes, or when you ask for an outcome that spans several turns, it can propose the objective instead of printing a `/goal set …` line for you to copy. The proposal appears as an approval dialog showing the full objective. Approving it sets the Goal exactly as `/goal set` would, the moment the current turn ends (the model acknowledges and stops; the first Goal turn then starts on its own), and declining sets nothing — the model sees only that the tool call was not allowed, and its instructions tell it not to ask why and not to propose the same objective again. The approval is bound to the turn that asked for it: if that turn is cancelled or otherwise never reaches its end, the approval is dropped rather than applied under a later message or an automated turn. No permission rule or approval mode (including YOLO) skips this dialog, and the tool refuses while another Goal is active, in plan mode, and in untrusted folders; subagents are never offered it. Web Shell uses its existing Allow/Reject permission panel. A stopped Goal can be replaced only if it still matches the version shown for approval; changing it invalidates the proposal. Headless runs, Web Shell channel deliveries and automatic turns, and ACP clients without the required approval and turn-lifecycle support retain the printed `/goal set` hand-off.

Turn it off with `goals.modelProposed: "disabled"` in your user settings. Because the setting decides whether the model may ask you to start an autonomous loop, it is honored only from user and system scope; a workspace `.qwen/settings.json` value is ignored with a warning.

Expand Down
53 changes: 53 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,11 @@ describe('createAcpSessionBridge', () => {
v: CHANNEL_STARTUP_PROFILE_VERSION,
},
});
expect(
handle.agent.initializeCalls[0]?.clientCapabilities?._meta,
).toEqual({
'qwen.goalProposals': true,
});

// No snapshot has arrived yet, so the session is "unknown": busy rather
// than idle, and graded `partial` — the channel did negotiate, it just
Expand Down Expand Up @@ -15862,6 +15867,51 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('only grants Goal proposal approval to an attached prompt originator', async () => {
const handle = makeChannel();
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({
workspaceCwd: WS_A,
});
const request = {
sessionId: session.sessionId,
prompt: [{ type: 'text' as const, text: 'draft a goal' }],
_meta: { 'qwen.goalProposalApproval': true },
};
await bridge.sendPrompt(session.sessionId, request);
expect(
handle.agent.promptCalls[0]?._meta?.['qwen.goalProposalApproval'],
).toBeUndefined();
await bridge.sendPrompt(session.sessionId, request, undefined, {
clientId: session.clientId,
});
expect(
handle.agent.promptCalls[1]?._meta?.['qwen.goalProposalApproval'],
).toBe(true);
await bridge.sendPrompt(session.sessionId, request, undefined, {
clientId: session.clientId,
channelPrompt: true,
});
expect(
handle.agent.promptCalls[2]?._meta?.['qwen.goalProposalApproval'],
).toBeUndefined();
await bridge.sendPrompt(session.sessionId, request, undefined, {
clientId: session.clientId,
restoreAskUserQuestion: true,
});
expect(
handle.agent.promptCalls[3]?._meta?.['qwen.goalProposalApproval'],
).toBeUndefined();
await bridge.sendPrompt(session.sessionId, request, undefined, {
clientId: session.clientId,
continue: true,
});
expect(
handle.agent.promptCalls[4]?._meta?.['qwen.goalProposalApproval'],
).toBeUndefined();
await bridge.shutdown();
});

it('strips both spoofed retry and continue meta keys from one prompt', async () => {
const handle = makeChannel();
const bridge = makeBridge({ channelFactory: async () => handle.channel });
Expand Down Expand Up @@ -37894,9 +37944,11 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa

it('promotes messages after their client detaches without reapplying the cap', async () => {
const prompts: string[] = [];
const goalApprovalMeta: unknown[] = [];
const releases: Array<() => void> = [];
const handle = makeChannel({
promptImpl: async (req) => {
goalApprovalMeta.push(req._meta?.['qwen.goalProposalApproval']);
prompts.push(
(req.prompt[0] as { text?: string } | undefined)?.text ?? '',
);
Expand Down Expand Up @@ -37943,6 +37995,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
releases[0]!();
await first;
await vi.waitFor(() => expect(prompts).toEqual(['first', 'follow up']));
expect(goalApprovalMeta).toEqual([true, undefined]);
expect(bridge.getMidTurnMessages(session.sessionId)).toEqual({
messages: [],
settledMessageIds: [],
Expand Down
11 changes: 11 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5056,6 +5056,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
privateParentCapability,
},
clientCapabilities: {
_meta: { 'qwen.goalProposals': true },
fs: {
readTextFile: delegateReadTextFileToClient,
writeTextFile: true,
Expand Down Expand Up @@ -10537,6 +10538,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// authorization and re-arms it through the trusted
// `channelPrompt` context flag below.
delete meta[CHANNEL_PROMPT_META_KEY];
delete meta['qwen.goalProposalApproval'];
if (
originatorClientId !== undefined &&
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
entry.clientIds.has(originatorClientId) &&
!context?.channelPrompt &&
!isContinue &&
!isRestoreAskUserQuestion
) {
meta['qwen.goalProposalApproval'] = true;
}
if (isRetry) {
meta[DAEMON_RETRY_META_KEY] = true;
}
Expand Down
7 changes: 4 additions & 3 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,9 +956,10 @@ export interface BridgeClientRequestContext {
promptId?: string;
/**
* Internal originator for a daemon-owned mid-turn message promoted into the
* normal prompt FIFO. It was authenticated when the message was enqueued,
* so promotion must not revalidate it after that client has detached.
* Transport routes never populate this field from request input.
* normal prompt FIFO. It was authenticated when the message was enqueued, so
* promotion may still deliver it after that client detaches; turn capabilities
* that require a live client must re-check attachment. Transport routes never
* populate this field from request input.
*/
promotedMidTurn?: { originatorClientId?: string };
/**
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2435,6 +2435,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
'qwen.daemon.modelPrompt': 'trusted model-only prompt',
'qwen.daemon.promptDisplayText': 'trusted display text',
'qwen.channel.prompt': true,
'qwen.goalProposalApproval': true,
'qwen.daemon.channelDelivery': {
deliveryId: 'delivery-trusted',
target: { channelName: 'dingtalk', type: 'user', id: 'user-1' },
Expand All @@ -2450,6 +2451,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
keep: true,
'qwen.daemon.promptDisplayText': 'trusted display text',
'qwen.channel.prompt': true,
'qwen.goalProposalApproval': true,
'qwen.daemon.channelDelivery': {
deliveryId: 'delivery-trusted',
target: { channelName: 'dingtalk', type: 'user', id: 'user-1' },
Expand Down Expand Up @@ -2588,6 +2590,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
_meta: {
keep: true,
'qwen.channel.prompt': true,
'qwen.goalProposalApproval': true,
'qwen.daemon.channelDelivery': {
deliveryId: 'delivery-forged',
target: { channelName: 'dingtalk', type: 'user', id: 'user-1' },
Expand Down Expand Up @@ -3966,6 +3969,56 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
},
);

it.each([
[true, undefined, true],
[false, undefined, false],
[true, 'channel', true],
])(
'gates Goal proposals on client support %s and session source %s',
async (supported, sourceType, expected) => {
const innerConfig = await setupSessionMocks('goal-proposal-session');
const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;
try {
await agent.initialize({
clientCapabilities: { _meta: { 'qwen.goalProposals': supported } },
});
await agent.newSession({
cwd: '/tmp',
mcpServers: [],
...(sourceType
? { _meta: { [SESSION_SOURCE_META_KEY]: { sourceType } } }
: {}),
});
if (expected) {
expect(innerConfig.setGoalProposalHostSupported).toHaveBeenCalledWith(
true,
);
expect(
innerConfig.setGoalProposalHostSupported.mock
.invocationCallOrder[0],
).toBeLessThan(innerConfig.initialize.mock.invocationCallOrder[0]);
} else {
expect(
innerConfig.setGoalProposalHostSupported,
).not.toHaveBeenCalled();
}
} finally {
mockConnectionState.resolve();
await agentPromise;
}
},
);

it('profiles newSession stages under the daemon trace context', async () => {
const parentContext = { trace: 'parent' };
mockExtractDaemonTraceContext.mockReturnValue(parentContext);
Expand Down Expand Up @@ -4462,6 +4515,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
setSessionWriterReclaimPolicy: vi.fn(),
setSessionWriterTakeoverPolicy: vi.fn(),
setSessionSource: vi.fn(),
setGoalProposalHostSupported: vi.fn(),
setSessionSourceServiceFactory: vi.fn(),
registerSessionSourceTool: vi.fn().mockResolvedValue(undefined),
getSessionSourceService: vi.fn(),
Expand Down Expand Up @@ -24378,6 +24432,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
setSessionWriterReclaimPolicy: vi.fn(),
setSessionWriterTakeoverPolicy: vi.fn(),
setSessionSource: vi.fn(),
setGoalProposalHostSupported: vi.fn(),
setSessionSourceServiceFactory: vi.fn(),
registerSessionSourceTool: vi.fn().mockResolvedValue(undefined),
getSessionSourceService: vi.fn(),
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6304,12 +6304,20 @@ class QwenAgent implements Agent {
const suppliedModelPrompt = meta[DAEMON_MODEL_PROMPT_META_KEY];
const suppliedPromptDisplayText = meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
const suppliedChannelPrompt = meta[CHANNEL_PROMPT_META_KEY];
const suppliedGoalProposalApproval = meta['qwen.goalProposalApproval'];
const suppliedChannelDelivery = meta[DAEMON_CHANNEL_DELIVERY_META_KEY];
delete meta[INVOCATION_CONTEXT_META_KEY];
delete meta[DAEMON_MODEL_PROMPT_META_KEY];
delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY];
delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
delete meta[CHANNEL_PROMPT_META_KEY];
delete meta['qwen.goalProposalApproval'];
if (
this.privateParentState === 'trusted' &&
suppliedGoalProposalApproval === true
) {
meta['qwen.goalProposalApproval'] = true;
}
delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY];
// The user-facing display projection is caller-controlled metadata; honor
// it only for trusted parents (the daemon bridge re-injects the trusted
Expand Down Expand Up @@ -14317,6 +14325,9 @@ class QwenAgent implements Agent {
if (sessionSource) {
config.setSessionSource(sessionSource.sourceType, sessionSource.sourceId);
}
if (this.clientCapabilities?._meta?.['qwen.goalProposals'] === true) {
config.setGoalProposalHostSupported(true);
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
}
if (chatRecording !== false) {
this.initializingConfigs.add(config);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ describe('Session review-worktree lease sweep', () => {
}),
setSubSessionSpawner: vi.fn(),
getSubSessionSpawner: vi.fn(),
getGoalProposalHostSupported: vi.fn().mockReturnValue(false),
// The Session constructor and Session.prompt both reach for the
// canonical Goal runtime. A real Config throws this exact error when
// Goal persistence is off, and both call sites are written to fall
Expand Down
Loading
Loading