Skip to content
Merged
214 changes: 213 additions & 1 deletion packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
ToolErrorType,
} from '../index.js';
import * as path from 'node:path';
import * as os from 'node:os';
import * as fsSync from 'node:fs';
import { pathToFileURL } from 'node:url';
import { SkillTool } from '../tools/skill.js';
import { StructuredToolError } from '../tools/priorReadEnforcement.js';
Expand All @@ -53,6 +55,7 @@ import {
MOCK_TOOL_GET_DEFAULT_PERMISSION,
MOCK_TOOL_GET_CONFIRMATION_DETAILS,
} from '../test-utils/mock-tool.js';
import { GeminiChat } from './geminiChat.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { HookExecutionResponse } from '../confirmation-bus/types.js';
import { type NotificationType } from '../hooks/types.js';
Expand Down Expand Up @@ -707,6 +710,8 @@ describe('CoreToolScheduler', () => {
onToolCallsUpdate?: ReturnType<typeof vi.fn>;
memoryMonitor?: { scheduleCheck: () => void };
toolOutputBatchBudget?: number;
getGeminiClient?: () => unknown;
getPlanFilePath?: () => string;
}) {
const ensureTool = vi.fn(
async (name: string) =>
Expand Down Expand Up @@ -761,7 +766,9 @@ describe('CoreToolScheduler', () => {
getToolRegistry: () => mockToolRegistry,
getCwd: () => '/repo',
getUseModelRouter: () => false,
getGeminiClient: () => null,
getGeminiClient: options.getGeminiClient ?? (() => null),
getPlanFilePath:
options.getPlanFilePath ?? (() => '/tmp/plans/test-session-id.md'),
getChatRecordingService: () => undefined,
getMemoryPressureMonitor: () => options.memoryMonitor,
getMessageBus: vi.fn().mockReturnValue(options.messageBus),
Expand Down Expand Up @@ -880,6 +887,211 @@ describe('CoreToolScheduler', () => {
expect(execute).toHaveBeenCalledWith({ plan: 'Original plan' });
});

function createChatWithPlanCall(callId: string, plan: string): GeminiChat {
return new GeminiChat({} as unknown as Config, {}, [
{ role: 'user', parts: [{ text: 'please plan this' }] },
{
role: 'model',
parts: [
{ text: 'Here is my plan.' },
{
functionCall: {
id: callId,
name: ToolNames.EXIT_PLAN_MODE,
args: { plan, originalRequest: 'please plan this' },
},
},
],
},
]);
}

it('redacts the plan argument from history after an approved exit_plan_mode', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES includes 'Leader approved.', but no test exercises that prefix — every approval-path test (including this one) uses 'User approved.' as the llmContent. — Failure scenario: if the leader prefix string changes (e.g. to 'Leader has approved.') or is dropped from the constant, the scheduler's .some(prefix => …) match stops firing for leader/teammate-approved exits, so the plan stays in history unredacted for that path — silently re-introducing the plan-history leak this PR fixes — and no test would catch it.

Fix: add one test mirroring this one but with llmContent: 'Leader approved. You can now start coding…', asserting the plan arg is redacted.

— qwen3.8-max-preview via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 566dba2 — a scheduler-path test mirroring the user-approved one but with llmContent: 'Leader approved. …', asserting the plan is redacted to the file pointer. Dropping or renaming the leader prefix in PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES now fails a test. 中文:已补 leader 前缀的调度器测试;该前缀被改名/删除会直接跑挂测试。

const bigPlan = '## Plan\n\n1. huge section\n2. code blocks\n3. tables';
const planFile = path.join(
os.tmpdir(),
`qwen-plan-${process.pid}-${Math.random().toString(16).slice(2)}.md`,
);
fsSync.writeFileSync(planFile, bigPlan, 'utf-8');
const chat = createChatWithPlanCall('plan-call-1', bigPlan);
const tool = new MockTool({
name: ToolNames.EXIT_PLAN_MODE,
execute: vi.fn().mockResolvedValue({
llmContent:
'User approved. You can now start coding. Start with updating your todo list if applicable.',
returnDisplay: {
type: 'plan_summary',
message: 'User approved.',
plan: bigPlan,
},
}),
});
const onAllToolCallsComplete = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]),
approvalMode: ApprovalMode.YOLO,
onAllToolCallsComplete,
getGeminiClient: () => ({ getChat: () => chat }),
getPlanFilePath: () => planFile,
});

await scheduler.schedule(
[
{
callId: 'plan-call-1',
name: ToolNames.EXIT_PLAN_MODE,
args: { plan: bigPlan },
isClientInitiated: false,
prompt_id: 'prompt-plan-approved',
},
],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());

const history = chat.getHistory();
const fnCall = history[1]!.parts![1]!.functionCall!;
expect(fnCall.args!['plan']).not.toContain('huge section');
expect(fnCall.args!['plan']).toContain(
`Plan approved and saved to ${planFile}`,
);
fsSync.unlinkSync(planFile);
// Sibling parts and other args survive untouched.
expect(history[1]!.parts![0]).toEqual({ text: 'Here is my plan.' });
expect(fnCall.args!['originalRequest']).toBe('please plan this');
});

it('redacts the plan for a leader-approved (teammate) exit_plan_mode', async () => {
const bigPlan = '## Plan\n\nleader path fixture';
const planFile = path.join(
os.tmpdir(),
`qwen-plan-leader-${process.pid}-${Math.random().toString(16).slice(2)}.md`,
);
fsSync.writeFileSync(planFile, bigPlan, 'utf-8');
const chat = createChatWithPlanCall('plan-call-4', bigPlan);
const tool = new MockTool({
name: ToolNames.EXIT_PLAN_MODE,
execute: vi.fn().mockResolvedValue({
llmContent:
'Leader approved. You can now start coding. Start with updating your todo list if applicable.',
returnDisplay: {
type: 'plan_summary',
message: 'Leader approved.',
plan: bigPlan,
},
}),
});
const onAllToolCallsComplete = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]),
approvalMode: ApprovalMode.YOLO,
onAllToolCallsComplete,
getGeminiClient: () => ({ getChat: () => chat }),
getPlanFilePath: () => planFile,
});

await scheduler.schedule(
[
{
callId: 'plan-call-4',
name: ToolNames.EXIT_PLAN_MODE,
args: { plan: bigPlan },
isClientInitiated: false,
prompt_id: 'prompt-plan-leader-approved',
},
],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());

const fnCall = chat.getHistory()[1]!.parts![1]!.functionCall!;
expect(fnCall.args!['plan']).not.toContain('leader path fixture');
expect(fnCall.args!['plan']).toContain(
`Plan approved and saved to ${planFile}`,
);
fsSync.unlinkSync(planFile);
});

it('keeps the plan argument when the plan file was never written (save failed)', async () => {
const bigPlan = '## Plan\n\nnothing on disk backs this';
const chat = createChatWithPlanCall('plan-call-3', bigPlan);
const tool = new MockTool({
name: ToolNames.EXIT_PLAN_MODE,
execute: vi.fn().mockResolvedValue({
llmContent:
'User approved. You can now start coding. Start with updating your todo list if applicable.',
returnDisplay: {
type: 'plan_summary',
message: 'User approved.',
plan: bigPlan,
},
}),
});
const onAllToolCallsComplete = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]),
approvalMode: ApprovalMode.YOLO,
onAllToolCallsComplete,
getGeminiClient: () => ({ getChat: () => chat }),
getPlanFilePath: () =>
path.join(os.tmpdir(), 'qwen-plan-that-does-not-exist.md'),
});

await scheduler.schedule(
[
{
callId: 'plan-call-3',
name: ToolNames.EXIT_PLAN_MODE,
args: { plan: bigPlan },
isClientInitiated: false,
prompt_id: 'prompt-plan-save-failed',
},
],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());

// Never point the model at a file that is not there — the plan stays.
const fnCall = chat.getHistory()[1]!.parts![1]!.functionCall!;
expect(fnCall.args!['plan']).toBe(bigPlan);
});

it('keeps the plan argument in history when exit_plan_mode is not approved', async () => {
const bigPlan = '## Plan\n\nkeep me for revision';
const chat = createChatWithPlanCall('plan-call-2', bigPlan);
const tool = new MockTool({
name: ToolNames.EXIT_PLAN_MODE,
execute: vi.fn().mockResolvedValue({
llmContent: 'Plan execution was not approved. Remaining in plan mode.',
returnDisplay: 'Plan execution was not approved.',
}),
});
const onAllToolCallsComplete = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName: new Map([[ToolNames.EXIT_PLAN_MODE, tool]]),
approvalMode: ApprovalMode.YOLO,
onAllToolCallsComplete,
getGeminiClient: () => ({ getChat: () => chat }),
});

await scheduler.schedule(
[
{
callId: 'plan-call-2',
name: ToolNames.EXIT_PLAN_MODE,
args: { plan: bigPlan },
isClientInitiated: false,
prompt_id: 'prompt-plan-rejected',
},
],
new AbortController().signal,
);
await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());

const fnCall = chat.getHistory()[1]!.parts![1]!.functionCall!;
expect(fnCall.args!['plan']).toBe(bigPlan);
});

it('does not let AUTO_EDIT approve an interaction-required info tool', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'executed',
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ import type {
} from '@google/genai';
import { fileURLToPath } from 'node:url';
import { ToolNames, ToolNamesMigration } from '../tools/tool-names.js';
import { PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES } from '../tools/exitPlanMode.js';
import { approvedPlanRedactionText } from './geminiChat.js';
import * as fsSync from 'node:fs';
import {
collectAvailableSkillEntries,
renderAvailableSkillsBlock,
Expand Down Expand Up @@ -4338,6 +4341,49 @@ export class CoreToolScheduler {
: {}),
...(artifacts.length > 0 ? { artifacts } : {}),
};
// After an APPROVED exit_plan_mode, swap the large `plan` argument
// still sitting in the model turn's functionCall for a pointer to the
// saved plan file. The blob otherwise stays in the model's attention
// window and gets regurgitated into later responses (#6237). Keyed off
// the approval llmContent prefixes (not just tool success) so
// rejected/no-action results keep their plan text for revision. Must
// run BEFORE setStatusInternal: completion callbacks may submit the
// continuation turn synchronously, and it should already see the
// sanitized history.
if (
canonicalName === ToolNames.EXIT_PLAN_MODE &&
typeof toolResult.llmContent === 'string' &&
PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES.some((prefix) =>
(toolResult.llmContent as string).startsWith(prefix),
)
) {
try {
const planPath = this.config.getPlanFilePath();
// Gate on the on-disk plan actually matching the in-history
// text: savePlanBestEffort swallows filesystem errors, and the
// pointer must never claim a save that failed or reference a
// file holding a different plan. A missing/unreadable file
// throws here and skips the redaction entirely.
const savedPlan = fsSync.readFileSync(planPath, 'utf-8');
this.config
.getGeminiClient?.()
?.getChat()
.redactApprovedPlanFromHistory(
Comment on lines +4632 to +4643

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The redaction rewrites the in-memory GeminiChat.history array, but the chat-recording JSONL (append-only) already captured the full plan text when recordAssistantTurn fired during the model's original response — before tool execution and before this redaction. A --continue / --resume of the same session rehydrates the full plan text from the JSONL, re-introducing the very leak (#6237) this fix addresses in the current session.

Failure scenario: User enters plan mode, model produces a large plan, user approves. In the current session the redaction works correctly. User later runs qwen --continue on the same session — the JSONL is replayed into history with the original full plan text. On a long follow-up conversation, the model regurgitates plan chunks again.

Suggested fix: Apply redactApprovedPlanFromHistory at JSONL load time in the --resume path (scan for approved exit_plan_mode calls and replace their plan args), or add a recording-level redaction alongside the in-memory one.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in c054057, at load time as suggested — but at the choke point rather than per resume path: GeminiChat.setHistory and the constructor now run a history-wide pass (redactApprovedPlansInHistory, pure and unit-tested) that rewrites approved exit_plan_mode calls, so every rehydration route (--resume, --continue, ACP session load) is covered by the same two entry points. It applies the same never-lie rule as the write side: only calls whose plan matches the current on-disk plan file are rewritten. The pointer text moved into a shared approvedPlanRedactionText helper so the write and load surfaces cannot drift. One stated bound: with several approved plans in one session the file holds only the last, so earlier calls rehydrate unredacted (safe — the file never mismatches the pointer). Wiring pinned by tests with and without the plan file present, plus a constructor-path test.

中文:已在 c054057 按建议在加载侧实现——但收在 choke point 而非逐个 resume 路径:GeminiChat.setHistory 与构造器统一跑历史级净化(纯函数 redactApprovedPlansInHistory),覆盖 --resume/--continue/ACP 加载全部路径;与写侧同一「永不说谎」规则(仅当 plan 与当前磁盘文件一致才改写);指针文案抽到共享 approvedPlanRedactionText 防两面漂移。已声明的边界:一个会话多次批准时文件只存最后一份,较早的调用恢复时不改写(安全,只是不最小化)。接线由有/无 plan 文件两组测试 + 构造器路径测试固定。

callId,
approvedPlanRedactionText(planPath),
savedPlan,
);
} catch (redactErr) {
debugLogger.warn(
`Skipping approved-plan redaction for ${callId} (plan file ` +
`unavailable?): ${
redactErr instanceof Error
? redactErr.message
: String(redactErr)
}`,
);
}
}
this.setStatusInternal(callId, 'success', successResponse);
safeSetStatus(span, { code: SpanStatusCode.OK });
// Mirrors setToolSpanFailure/setToolSpanCancelled — every tool span
Expand Down
Loading
Loading