diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 6b3ae9df7ac..9548795802a 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -18139,6 +18139,7 @@ describe('Session', () => { await boundGoalHost!.startGoalTurn({ permit, continuationContext: 'check weather', + verifierFeedback: 'Need independent evidence', }); await vi.waitFor(() => { @@ -18153,6 +18154,16 @@ describe('Session', () => { 'Continue working on the active Goal.', ), }), + expect.objectContaining({ + text: expect.stringContaining( + 'Runtime continuation context: check weather', + ), + }), + expect.objectContaining({ + text: expect.stringContaining( + 'Verifier feedback: Need independent evidence', + ), + }), ]), }), expect.any(String), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 6d8ac89c101..b972c71c18d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -202,6 +202,7 @@ import { buildBackgroundEntryLabel, collectSessionTurnState, computeInitialTurnFromHistory as computeInitialTurnFromHistoryCore, + buildGoalContinuationParts, } from '@qwen-code/qwen-code-core'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; import { CHANNEL_PROMPT_META_KEY } from '@qwen-code/channel-base'; @@ -556,23 +557,6 @@ function sameGoalPermit( ); } -function buildGoalContinuationParts(turn: AcpGoalTurn): Part[] { - return [ - { - text: [ - 'Continue working on the active Goal.', - 'Use get_goal for the authoritative objective and evidence state.', - "Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it.", - 'If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal.', - `Runtime continuation context: ${turn.continuationContext}`, - ...(turn.verifierFeedback - ? [`Verifier feedback: ${turn.verifierFeedback}`] - : []), - ].join('\n'), - }, - ]; -} - async function claimGoalTurn( runtime: GoalRuntime, turnKey: string, diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 26861e464e5..7d73e66cc2c 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -696,6 +696,9 @@ describe('runNonInteractive', () => { const [parts, , , options] = mockGeminiClient.sendMessageStream.mock.calls[0]!; expect(parts[0]?.text).toContain('Continue working on the active Goal.'); + expect(parts[0]?.text).toContain( + 'Runtime continuation context: existing goal', + ); expect(options).toMatchObject({ type: SendMessageType.Goal, goalOrigin: 'runtime', @@ -712,6 +715,36 @@ describe('runNonInteractive', () => { expect(options.goalSignal).toBeInstanceOf(AbortSignal); }); + it('includes verifier feedback in a scheduled Goal continuation', async () => { + setupMetricsMock(); + mockGetCommands.mockReturnValue([goalCommand]); + await prepareGoalState('paused'); + mockFinishedGoalWorker(); + vi.mocked(mockConfig.bindGoalTurnHost).mockImplementation((host) => + goalRuntime.bindHost({ + startGoalTurn: (input) => + host.startGoalTurn({ + ...input, + verifierFeedback: 'Need independent evidence', + }), + preemptGoalTurn: (reason) => host.preemptGoalTurn(reason), + }), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + '/goal resume', + 'goal-runtime-feedback', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledOnce(); + const [parts] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + expect(parts[0]?.text).toContain( + 'Verifier feedback: Need independent evidence', + ); + }); + it('keeps the exact Goal permit through a ToolResult continuation', async () => { setupMetricsMock(); mockGetCommands.mockReturnValue([goalCommand]); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 6dc99ef43b1..58df29e77cc 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -72,6 +72,7 @@ import { endInteractionSpan, getErrorType, getActiveInteractionSpan, + buildGoalContinuationParts, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -232,23 +233,6 @@ function sameGoalPermit( ); } -function buildGoalContinuationParts(turn: HeadlessGoalTurn): Part[] { - return [ - { - text: [ - 'Continue working on the active Goal.', - 'Use get_goal for the authoritative objective and evidence state.', - "Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it.", - 'If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal.', - `Runtime continuation context: ${turn.continuationContext}`, - ...(turn.verifierFeedback - ? [`Verifier feedback: ${turn.verifierFeedback}`] - : []), - ].join('\n'), - }, - ]; -} - function projectLegacyActiveGoal(snapshot: GoalSnapshotV2): ActiveGoal | null { const goal = snapshot.goal; if (goal?.status !== 'active') return null; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index eaaadd2dbeb..685c54f322d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -75,6 +75,7 @@ import { finalizeToolResponses, endInteractionSpan, getActiveInteractionSpan, + renderGoalContinuationPrompt, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -3557,17 +3558,10 @@ export const useGeminiStream = ( submitType === SendMessageType.Goal ? queuedGoal ? { - queryToSend: [ - 'Continue working on the active Goal.', - 'Use get_goal for the authoritative objective and evidence state.', - "Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it.", - 'If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal.', - 'This is a synthetic continuation turn. It contains no new real user input and cannot satisfy an objective condition that requires the user to send, confirm, choose, approve, or provide something.', - 'A phrase mentioned in the objective or this prompt is not evidence that the user supplied it.', - ...(queuedGoal.verifierFeedback - ? [`Verifier feedback: ${queuedGoal.verifierFeedback}`] - : []), - ].join('\n'), + queryToSend: renderGoalContinuationPrompt({ + variant: 'guarded-synthetic-turn', + verifierFeedback: queuedGoal.verifierFeedback, + }), shouldProceed: true, } : { queryToSend: null, shouldProceed: false } diff --git a/packages/core/src/goals/goal-continuation-prompt.test.ts b/packages/core/src/goals/goal-continuation-prompt.test.ts new file mode 100644 index 00000000000..3ebbcb892c7 --- /dev/null +++ b/packages/core/src/goals/goal-continuation-prompt.test.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + buildGoalContinuationParts, + renderGoalContinuationPrompt, +} from './goal-continuation-prompt.js'; + +// These expectations pin the exact bytes each host sent before the renderer +// existed. Any edit to a line must show up here as a diff, not slip through. +describe('renderGoalContinuationPrompt', () => { + it('renders the guarded synthetic turn without verifier feedback', () => { + expect( + renderGoalContinuationPrompt({ variant: 'guarded-synthetic-turn' }), + ).toBe( + `Continue working on the active Goal. +Use get_goal for the authoritative objective and evidence state. +Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it. +If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal. +This is a synthetic continuation turn. It contains no new real user input and cannot satisfy an objective condition that requires the user to send, confirm, choose, approve, or provide something. +A phrase mentioned in the objective or this prompt is not evidence that the user supplied it.`, + ); + }); + + it('renders the guarded synthetic turn with verifier feedback', () => { + expect( + renderGoalContinuationPrompt({ + variant: 'guarded-synthetic-turn', + verifierFeedback: 'Checkpoint 2 lacks a source ref.', + }), + ).toBe( + `Continue working on the active Goal. +Use get_goal for the authoritative objective and evidence state. +Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it. +If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal. +This is a synthetic continuation turn. It contains no new real user input and cannot satisfy an objective condition that requires the user to send, confirm, choose, approve, or provide something. +A phrase mentioned in the objective or this prompt is not evidence that the user supplied it. +Verifier feedback: Checkpoint 2 lacks a source ref.`, + ); + }); + + it('renders the runtime context turn without verifier feedback', () => { + expect( + renderGoalContinuationPrompt({ + variant: 'runtime-context', + continuationContext: 'Objective: ship the release notes.', + }), + ).toBe( + `Continue working on the active Goal. +Use get_goal for the authoritative objective and evidence state. +Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it. +If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal. +Runtime continuation context: Objective: ship the release notes.`, + ); + }); + + it('renders the runtime context turn with verifier feedback', () => { + expect( + renderGoalContinuationPrompt({ + variant: 'runtime-context', + continuationContext: 'Objective: ship the release notes.', + verifierFeedback: 'Checkpoint 2 lacks a source ref.', + }), + ).toBe( + `Continue working on the active Goal. +Use get_goal for the authoritative objective and evidence state. +Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it. +If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal. +Runtime continuation context: Objective: ship the release notes. +Verifier feedback: Checkpoint 2 lacks a source ref.`, + ); + }); + + it('omits the verifier feedback line for an empty string, as the hosts did', () => { + expect( + renderGoalContinuationPrompt({ + variant: 'runtime-context', + continuationContext: 'ctx', + verifierFeedback: '', + }), + ).toBe( + renderGoalContinuationPrompt({ + variant: 'runtime-context', + continuationContext: 'ctx', + }), + ); + }); +}); + +describe('buildGoalContinuationParts', () => { + it('wraps the runtime-context prompt in a single text part', () => { + expect( + buildGoalContinuationParts({ + continuationContext: 'Objective: ship the release notes.', + verifierFeedback: 'Checkpoint 2 lacks a source ref.', + }), + ).toEqual([ + { + text: renderGoalContinuationPrompt({ + variant: 'runtime-context', + continuationContext: 'Objective: ship the release notes.', + verifierFeedback: 'Checkpoint 2 lacks a source ref.', + }), + }, + ]); + }); +}); diff --git a/packages/core/src/goals/goal-continuation-prompt.ts b/packages/core/src/goals/goal-continuation-prompt.ts new file mode 100644 index 00000000000..820fab68ae1 --- /dev/null +++ b/packages/core/src/goals/goal-continuation-prompt.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; + +/** + * The prompt a host sends when `runtime.finishTurn` schedules another Goal + * turn. Every host renders it from here so that a new line -- or a new variant + * -- lands in one place instead of drifting across the hosts that assemble it. + */ + +export type GoalContinuationPromptInput = + | { + variant: 'guarded-synthetic-turn'; + verifierFeedback?: string; + } + | { + variant: 'runtime-context'; + continuationContext: string; + verifierFeedback?: string; + }; + +const SHARED_LINES = [ + 'Continue working on the active Goal.', + 'Use get_goal for the authoritative objective and evidence state.', + "Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it.", + 'If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal.', +]; + +const SYNTHETIC_TURN_GUARD_LINES = [ + 'This is a synthetic continuation turn. It contains no new real user input and cannot satisfy an objective condition that requires the user to send, confirm, choose, approve, or provide something.', + 'A phrase mentioned in the objective or this prompt is not evidence that the user supplied it.', +]; + +/** Renders the full continuation prompt text for one Goal turn. */ +export function renderGoalContinuationPrompt( + input: GoalContinuationPromptInput, +): string { + const lines = [...SHARED_LINES]; + + switch (input.variant) { + case 'guarded-synthetic-turn': + lines.push(...SYNTHETIC_TURN_GUARD_LINES); + break; + case 'runtime-context': + lines.push(`Runtime continuation context: ${input.continuationContext}`); + break; + default: { + const unreachable: never = input; + throw new Error( + `Unknown goal continuation variant: ${JSON.stringify(unreachable)}`, + ); + } + } + + if (input.verifierFeedback) { + lines.push(`Verifier feedback: ${input.verifierFeedback}`); + } + + return lines.join('\n'); +} + +/** Builds the sendable parts for a runtime-scheduled Goal continuation turn. */ +export function buildGoalContinuationParts(turn: { + continuationContext: string; + verifierFeedback?: string; +}): Part[] { + return [ + { + text: renderGoalContinuationPrompt({ + variant: 'runtime-context', + continuationContext: turn.continuationContext, + verifierFeedback: turn.verifierFeedback, + }), + }, + ]; +} diff --git a/packages/core/src/goals/index.ts b/packages/core/src/goals/index.ts index c2b24ac1c17..a64afd63af6 100644 --- a/packages/core/src/goals/index.ts +++ b/packages/core/src/goals/index.ts @@ -69,3 +69,8 @@ export * from './goal-checkpoint-verifier.js'; export * from './goal-verifier.js'; export * from './goal-runtime.js'; export { goalTurnContext } from './goal-turn-context.js'; +export { + buildGoalContinuationParts, + renderGoalContinuationPrompt, +} from './goal-continuation-prompt.js'; +export type { GoalContinuationPromptInput } from './goal-continuation-prompt.js';