Skip to content
Open
108 changes: 107 additions & 1 deletion packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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 +708,7 @@ describe('CoreToolScheduler', () => {
onToolCallsUpdate?: ReturnType<typeof vi.fn>;
memoryMonitor?: { scheduleCheck: () => void };
toolOutputBatchBudget?: number;
getGeminiClient?: () => unknown;
}) {
const ensureTool = vi.fn(
async (name: string) =>
Expand Down Expand Up @@ -761,7 +763,8 @@ describe('CoreToolScheduler', () => {
getToolRegistry: () => mockToolRegistry,
getCwd: () => '/repo',
getUseModelRouter: () => false,
getGeminiClient: () => null,
getGeminiClient: options.getGeminiClient ?? (() => null),
getPlanFilePath: () => '/tmp/plans/test-session-id.md',
getChatRecordingService: () => undefined,
getMemoryPressureMonitor: () => options.memoryMonitor,
getMessageBus: vi.fn().mockReturnValue(options.messageBus),
Expand Down Expand Up @@ -880,6 +883,109 @@ 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 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 }),
});

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 /tmp/plans/test-session-id.md',
);
// 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('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
38 changes: 38 additions & 0 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ 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 {
collectAvailableSkillEntries,
renderAvailableSkillsBlock,
Expand Down Expand Up @@ -4338,6 +4339,43 @@ 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();
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,
`[Plan approved and saved to ${planPath}. The plan text was ` +
`removed from the conversation after approval; read that ` +
`file if you need to consult it again.]`,

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 message claims "Plan approved and saved to <path>" but savePlanBestEffort in ExitPlanModeToolInvocation.execute swallows all filesystem errors (disk full, permissions). When save fails silently, the plan text is redacted from the only remaining in-memory copy and replaced with a pointer to a file that does not exist.

Failure scenario: Filesystem runs out of space. User approves the plan. savePlan throws ENOSPC, savePlanBestEffort logs a warning and returns. The redaction block still fires (because llmContent starts with "User approved."). The plan text in history is replaced with a pointer to a non-existent file. If the model later tries to read_file at that path, the file is not there.

Suggested fix: Have savePlanBestEffort return a boolean indicating success, and gate the redaction on that flag — or change the replacement string to not assert the file was saved when it was not.

— 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.

Fixed in c054057. The scheduler now reads the plan file before redacting, and redactApprovedPlanFromHistory gained an expectedPlan gate — the rewrite only happens when the in-history plan equals the on-disk content byte-for-byte. A missing, unreadable, or stale file (earlier plan overwritten scenario included) skips the redaction with a warning, so the pointer can never claim a save that failed. New regression test: approved exit with a non-existent plan file → plan text stays in history.

中文:已在 c054057 修复。调度器改写前先读 plan 文件,redactApprovedPlanFromHistory 新增 expectedPlan 门——仅当历史中的 plan 与磁盘内容逐字节一致才改写;文件缺失/不可读/内容陈旧则跳过并告警,指针永不谎称保存成功。新增回归测试:批准但 plan 文件不存在 → 历史保留原文。

);
} catch (redactErr) {
debugLogger.warn(
`Failed to redact approved plan from history for ${callId}: ${
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
92 changes: 92 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11359,6 +11359,98 @@ describe('GeminiChat', async () => {
});
});

describe('redactApprovedPlanFromHistory', () => {
// After an approved exit_plan_mode the full plan text would otherwise
// stay in history as the model's own tool-call argument and get
// regurgitated into later responses (#6237). These tests pin the
// targeted history rewrite the tool scheduler performs post-approval.
Comment on lines +11537 to +11541

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] No test exercises canonicalPlanToolName with a legacy tool-name alias — every test here uses name: 'exit_plan_mode' directly, so the ToolNamesMigration lookup path is unverified. — Failure scenario: if ToolNamesMigration gains an exit_plan_mode alias and canonicalPlanToolName has a bug (wrong lookup direction, or the Record<string, string> cast silently drops entries), a --resumed session with history recorded under the legacy name would silently skip redaction — plan text leaks back into the model's context despite being approved and saved. Today this cannot fire (no exit_plan_mode alias exists), but the scheduler's own tests pin the same contract with it.each(Object.entries(ToolNamesMigration)) — the pattern is established and cheap to mirror.

it('resolves legacy tool-name aliases when matching exit_plan_mode calls', () => {
  const history: Content[] = [
    {
      role: 'model',
      parts: [{
        functionCall: {
          id: 'call-a',
          name: 'exit_plan_mode_legacy',
          args: { plan: PLAN },
        },
      }],
    },
    {
      role: 'user',
      parts: [{
        functionResponse: {
          id: 'call-a',
          name: 'exit_plan_mode_legacy',
          response: { output: 'User approved. You can now start coding.' },
        },
      }],
    },
  ];
  const out = redactApprovedPlansInHistory(history, PLAN, PLAN_PATH);
  expect(out).not.toBeNull();
  expect(out![0]!.parts![0]!.functionCall!.args!['plan']).toBe(
    approvedPlanRedactionText(PLAN_PATH),
  );
});

— 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.

Agreed it's a real gap, but per wenshao's round-5 guidance this PR lands only the two-line logging change and defers the remaining test pins to a follow-up — I'll bundle a legacy-alias case for canonicalPlanToolName (both surfaces) into that follow-up test PR alongside the scheduler savedPlan gate pin and the setStatusInternal ordering probe.

中文

确实是缺口,但按 wenshao 第 5 轮的意见,本 PR 只落两行日志改动、其余测试钉全部推 follow-up——canonicalPlanToolName 的 legacy 别名用例(两个面)会和调度器 savedPlan 闸门钉、setStatusInternal 顺序探针一起放进那个 follow-up 测试 PR。


const REPLACEMENT = '[Plan approved and saved to /tmp/p.md]';

function chatWith(history: Content[]): GeminiChat {
return new GeminiChat({} as unknown as Config, {}, history);
}

it('rewrites only the plan arg of the matching exit_plan_mode call', () => {
const chat = chatWith([
{ role: 'user', parts: [{ text: 'plan it' }] },
{
role: 'model',
parts: [
{ text: 'My plan follows.' },
{
functionCall: {
id: 'call-plan',
name: 'exit_plan_mode',
args: { plan: 'SECRET BIG PLAN', originalRequest: 'plan it' },
},
},
],
},
]);

expect(chat.redactApprovedPlanFromHistory('call-plan', REPLACEMENT)).toBe(
true,
);
Comment on lines +11567 to +11569

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 expectedPlan mismatch branch of redactApprovedPlanFromHistory — the if (expectedPlan !== undefined && plan !== expectedPlan) return false guard — has no test at any level: every unit call here omits the third argument, and the scheduler tests only cover a missing file (the read throws), not a file whose content differs from the in-history plan. — Failure scenario: if a refactor inverts the comparison to plan === expectedPlan, the scheduler would redact the plan and point the model at a file holding a different plan, violating the "never lie" invariant the code comments describe, and no test would fail.

Fix: add a unit test, e.g. expect(chat.redactApprovedPlanFromHistory('call-plan', REPLACEMENT, 'a different plan')).toBe(false), and assert the plan arg is left unchanged.

— 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 — unit test drives the expectedPlan mismatch branch directly: a stale/different third argument returns false and leaves the plan arg unchanged, then the same call with the matching plan still rewrites (guards both the comparison direction and the never-lie invariant). 中文:已补单测——不一致的 expectedPlan 返回 false 且历史不变,随后一致的 plan 仍可改写;比较方向反转会被立刻抓住。


const entry = chat.getHistory()[1]!;
const fnCall = entry.parts![1]!.functionCall!;
expect(fnCall.args!['plan']).toBe(REPLACEMENT);
expect(fnCall.args!['originalRequest']).toBe('plan it');
expect(fnCall.id).toBe('call-plan');
expect(entry.parts![0]).toEqual({ text: 'My plan follows.' });
expect(JSON.stringify(chat.getHistory())).not.toContain(
'SECRET BIG PLAN',
);
});

it('returns false when no matching call id or tool name exists', () => {
const chat = chatWith([
{
role: 'model',
parts: [
{
functionCall: {
id: 'call-other',
name: 'write_file',
args: { plan: 'not a plan tool' },
},
},
],
},
]);
expect(chat.redactApprovedPlanFromHistory('call-plan', REPLACEMENT)).toBe(
false,
);
expect(
chat.redactApprovedPlanFromHistory('call-other', REPLACEMENT),
).toBe(false);
expect(chat.getHistory()[0]!.parts![0]!.functionCall!.args!['plan']).toBe(
'not a plan tool',
);
});

it('returns false when the matching call has no string plan arg', () => {
const chat = chatWith([
{
role: 'model',
parts: [
{
functionCall: {
id: 'call-plan',
name: 'exit_plan_mode',
args: {},
},
},
],
},
]);
expect(chat.redactApprovedPlanFromHistory('call-plan', REPLACEMENT)).toBe(
false,
);
});
});

describe('redactStructuredOutputArgsForRecording', () => {
// The chat-recording JSONL persists assistant turns to disk and re-feeds
// them on `--continue` / `--resume`. For `--json-schema` runs the
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3520,6 +3520,53 @@ export class GeminiChat {
this.clearPendingPartialState();
}

/**
* Replaces the `plan` argument of an `exit_plan_mode` `functionCall` in
* history with a short reference, keeping every other part and argument
* intact.
*
* The full plan text a model submits to `exit_plan_mode` stays in history
* as its own tool-call arguments; on long conversations models
* occasionally regurgitate chunks of that blob in later responses
* (#6237). Once the plan is approved it is persisted to disk by
* `Config.savePlan`, so the in-context copy can be swapped for a pointer
* without losing information. Rejected plans are left untouched — the
* model needs the text to revise them.
*
* The entry is replaced immutably at the same index; the partial-push
* markers compare by index and role, so this cannot desync them.
*
* @returns true when a matching functionCall was found and rewritten.
*/
redactApprovedPlanFromHistory(callId: string, replacement: string): boolean {
for (let i = this.history.length - 1; i >= 0; i--) {
const entry = this.history[i];
if (entry?.role !== 'model' || !entry.parts) continue;
const partIdx = entry.parts.findIndex(
(part) =>
part.functionCall?.id === callId &&
part.functionCall.name === ToolNames.EXIT_PLAN_MODE,
);
if (partIdx === -1) continue;
const part = entry.parts[partIdx]!;
const functionCall = part.functionCall!;
if (typeof (functionCall.args ?? {})['plan'] !== 'string') {
return false;
}
const newParts = [...entry.parts];
newParts[partIdx] = {
...part,
functionCall: {
...functionCall,
args: { ...functionCall.args, plan: replacement },
},
};
this.history[i] = { ...entry, parts: newParts };
return true;
}
return false;
}

setHistory(history: Content[]): void {
this.history = history;
// History replacement (compression, /clear, --resume reload) wipes
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/tools/exitPlanMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ const exitPlanModeToolSchemaData: FunctionDeclaration = {
},
};

/**
* `llmContent` prefixes that mark a successful plan-mode exit (user or
* leader approval). The tool scheduler keys its post-execution history
* sanitization (#6237) off these, so they must stay in lockstep with the
* success returns in `ExitPlanModeToolInvocation.execute` /
* `executePlanRequiredTeammate` below.
*/
export const PLAN_EXIT_APPROVED_LLM_CONTENT_PREFIXES = [
'User approved.',
'Leader approved.',
] as const;

interface ExitApprovalSnapshot {
plan: string;
approvalModeRevision: number;
Expand Down
Loading