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
d3e933d
feat(core): move startup context to system reminders
tanzhenxin May 11, 2026
a78b61b
test(core): extend resume test stub with new ToolRegistry methods
tanzhenxin May 11, 2026
be00695
Merge remote-tracking branch 'origin/main' into codex/system-reminder…
tanzhenxin May 15, 2026
ee56818
fix(core): restore deferred tool prompt plumbing
tanzhenxin May 15, 2026
88ba23b
Merge remote-tracking branch 'origin/main' into codex/system-reminder…
tanzhenxin May 15, 2026
825b0a6
fix(core): announce progressive MCP tools via reminders
tanzhenxin May 15, 2026
e40aef7
fix(core): address review on startup-context system reminders
tanzhenxin May 17, 2026
6fd2b97
fix(core): repair startup-context rewind tests and ReDoS in xml utils
tanzhenxin May 17, 2026
2c0261a
fix(core): harden startup-context restore, legacy sessions, and tag e…
tanzhenxin May 17, 2026
ac11a8b
Merge remote-tracking branch 'origin/main' into codex/system-reminder…
tanzhenxin May 17, 2026
ac55d3e
Merge remote-tracking branch 'origin/main' into codex/system-reminder…
tanzhenxin Jun 1, 2026
105b91c
fix(core): pop orphaned turns whose reminder shares a Content with th…
tanzhenxin Jun 1, 2026
2f14a78
fix(cli): exclude system-reminder entries from rewind truncation
tanzhenxin Jun 1, 2026
2236f3a
fix(core): slice full startup prelude length when refreshing reminder
tanzhenxin Jun 1, 2026
336bbd5
fix(core): require system-reminder entries to end with the close tag
tanzhenxin Jun 3, 2026
d36efa6
Merge remote-tracking branch 'origin/main' into codex/system-reminder…
tanzhenxin Jun 3, 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
58 changes: 53 additions & 5 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
} from './Session.js';
import type { Content } from '@google/genai';
import type { ChatRecord, Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
AuthType,
SYSTEM_REMINDER_OPEN,
SYSTEM_REMINDER_CLOSE,
} from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
import type {
Expand Down Expand Up @@ -352,8 +357,14 @@ describe('Session', () => {

it('preserves startup context when rewinding to the first user turn', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'startup context' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{
role: 'user',
parts: [
{
text: `${SYSTEM_REMINDER_OPEN}\nstartup context\n${SYSTEM_REMINDER_CLOSE}`,
},
],
},
{ role: 'user', parts: [{ text: 'first' }] },
{ role: 'model', parts: [{ text: 'first reply' }] },
];
Expand All @@ -362,8 +373,45 @@ describe('Session', () => {

const result = session.rewindToTurn(0);

expect(result).toEqual({ targetTurnIndex: 0, apiTruncateIndex: 2 });
expect(mockChat.truncateHistory).toHaveBeenCalledWith(2);
expect(result).toEqual({ targetTurnIndex: 0, apiTruncateIndex: 1 });
expect(mockChat.truncateHistory).toHaveBeenCalledWith(1);
});

it('does not count a mid-history MCP added-tool reminder as a user turn', () => {
// drainPendingAddedMcpToolsReminder injects a pure <system-reminder>
// user entry mid-history. Counting it as a real turn would land the
// rewind one entry early, dropping the reminder plus a turn's context.
const history: Content[] = [
{
role: 'user',
parts: [
{
text: `${SYSTEM_REMINDER_OPEN}\nstartup context\n${SYSTEM_REMINDER_CLOSE}`,
},
],
},
{ role: 'user', parts: [{ text: 'first' }] },
{ role: 'model', parts: [{ text: 'first reply' }] },
{
role: 'user',
parts: [
{
text: `${SYSTEM_REMINDER_OPEN}\nNew tools available: foo\n${SYSTEM_REMINDER_CLOSE}`,
},
],
},
{ role: 'user', parts: [{ text: 'second' }] },
{ role: 'model', parts: [{ text: 'second reply' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);

const result = session.rewindToTurn(1);

// Keep startup + turn 1 + the MCP reminder (indices 0–3); truncate at
// the second prompt (index 4). Counting the reminder would return 3.
expect(result).toEqual({ targetTurnIndex: 1, apiTruncateIndex: 4 });
expect(mockChat.truncateHistory).toHaveBeenCalledWith(4);
});

it('rejects unreachable user turns', () => {
Expand Down
25 changes: 11 additions & 14 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ import {
MessageBusType,
getPlanModeSystemReminder,
getArenaSystemReminder,
STARTUP_CONTEXT_MODEL_ACK,
getStartupContextLength,
isSystemReminderContent,
evaluatePermissionFlow,
needsConfirmation,
isPlanModeBlocked,
Expand Down Expand Up @@ -448,7 +449,7 @@ export class Session implements SessionContext {
apiHistory: Content[],
targetTurnIndex: number,
): number {
const startIndex = this.#hasStartupContext(apiHistory) ? 2 : 0;
const startIndex = getStartupContextLength(apiHistory);

if (targetTurnIndex === 0) {
return startIndex;
Expand All @@ -470,18 +471,6 @@ export class Session implements SessionContext {
return -1;
}

#hasStartupContext(apiHistory: Content[]): boolean {
if (apiHistory.length < 2) return false;
const first = apiHistory[0];
const second = apiHistory[1];
if (first?.role !== 'user' || second?.role !== 'model') return false;
return (
second.parts?.some(
(part) => 'text' in part && part.text === STARTUP_CONTEXT_MODEL_ACK,
) ?? false
);
}

#isUserTextContent(content: Content): boolean {
if (content.role !== 'user') return false;
if (!content.parts || content.parts.length === 0) return false;
Expand All @@ -491,6 +480,14 @@ export class Session implements SessionContext {
);
if (hasFunctionResponse) return false;

// Exclude pure <system-reminder> entries (the startup prelude and the
// mid-history MCP added-tool reminders). They are structural, not real
// user prompts; counting them would shift the rewind truncation index and
// silently drop a real turn. A genuine user turn that merely has a
// per-turn reminder prepended still has a non-reminder prompt part, so it
// is NOT excluded.
if (isSystemReminderContent(content)) return false;
Comment thread
tanzhenxin marked this conversation as resolved.

return content.parts.some((part) => 'text' in part && part.text);
}

Expand Down
94 changes: 82 additions & 12 deletions packages/cli/src/ui/utils/historyMapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { describe, it, expect } from 'vitest';
import { computeApiTruncationIndex, isRealUserTurn } from './historyMapping.js';
import type { HistoryItem } from '../types.js';
import type { Content, Part } from '@google/genai';
import {
SYSTEM_REMINDER_OPEN,
SYSTEM_REMINDER_CLOSE,
} from '@qwen-code/qwen-code-core';

// ---------------------------------------------------------------------------
// Helpers
Expand All @@ -32,11 +36,10 @@ function functionResponseContent(): Content {
};
}

function startupPair(): [Content, Content] {
return [
userContent('Environment context...'),
modelContent('Got it. Thanks for the context!'),
];
function startupEntry(): Content {
return userContent(
`${SYSTEM_REMINDER_OPEN}\nEnvironment context...\n${SYSTEM_REMINDER_CLOSE}`,
);
}

function userItem(
Expand Down Expand Up @@ -123,16 +126,16 @@ describe('computeApiTruncationIndex', () => {
});
});

describe('with startup context pair', () => {
describe('with startup context entry', () => {
it('keeps startup context when rewinding to the first turn', () => {
const ui: HistoryItem[] = [userItem(1), geminiItem(2)];
const api: Content[] = [
...startupPair(),
startupEntry(),
userContent('prompt 1'),
modelContent('response 1'),
];
// Rewind to turn 1 keep startup pair (2 entries)
expect(computeApiTruncationIndex(ui, 1, api)).toBe(2);
// Rewind to turn 1 -> keep startup entry.
expect(computeApiTruncationIndex(ui, 1, api)).toBe(1);
});

it('keeps startup + first turn when rewinding to second turn', () => {
Expand All @@ -143,14 +146,81 @@ describe('computeApiTruncationIndex', () => {
geminiItem(4),
];
const api: Content[] = [
...startupPair(),
startupEntry(),
userContent('prompt 1'),
modelContent('response 1'),
userContent('prompt 3'),
modelContent('response 3'),
];
// startup(2) + turn1(2) = 4 entries to keep
expect(computeApiTruncationIndex(ui, 3, api)).toBe(4);
// startup(1) + turn1(2) = 3 entries to keep.
expect(computeApiTruncationIndex(ui, 3, api)).toBe(3);
});
});

describe('with mid-history system-reminder entries', () => {
const mcpReminder = (): Content =>
userContent(
`${SYSTEM_REMINDER_OPEN}\nNew tools available: foo\n${SYSTEM_REMINDER_CLOSE}`,
);

it('does not count an MCP added-tool reminder as a user prompt', () => {
// drainPendingAddedMcpToolsReminder injects a pure <system-reminder>
// user entry mid-history. It is role:'user' with text, so a naive count
// treats it as a real prompt and lands the truncation index one turn
// early, silently dropping a turn's context.
const ui: HistoryItem[] = [
userItem(1),
geminiItem(2),
userItem(3),
geminiItem(4),
userItem(5),
geminiItem(6),
];
const api: Content[] = [
startupEntry(),
userContent('prompt 1'),
modelContent('response 1'),
mcpReminder(), // must NOT count as a user turn
userContent('prompt 3'),
modelContent('response 3'),
userContent('prompt 5'),
modelContent('response 5'),
];
// Rewind to turn 5 (2 real turns before it). If the reminder counted,
// the walk would stop at its successor (idx 4) and drop turn 3's
// context; excluding it lands correctly at prompt 5 (idx 6).
expect(computeApiTruncationIndex(ui, 5, api)).toBe(6);
});

it('still counts a real turn that has a per-turn reminder prepended', () => {
// In plan mode the reminder is an extra part on the SAME Content as the
// prompt: parts = [<system-reminder>…, prompt]. That entry IS a real
// user turn (it has a non-reminder prompt part), so it must be counted —
// a parts[0]-only exclusion would wrongly skip it and miscount.
const planTurn = (id: number): Content => ({
role: 'user',
parts: [
{
text: `${SYSTEM_REMINDER_OPEN}\nPlan mode is active.\n${SYSTEM_REMINDER_CLOSE}`,
} as Part,
{ text: `prompt ${id}` } as Part,
],
});
const ui: HistoryItem[] = [
userItem(1),
geminiItem(2),
userItem(3),
geminiItem(4),
];
const api: Content[] = [
startupEntry(),
planTurn(1),
modelContent('response 1'),
planTurn(3),
modelContent('response 3'),
];
// Rewind to turn 3 → keep startup + turn 1 = 3 entries.
expect(computeApiTruncationIndex(ui, 3, api)).toBe(3);
});
});

Expand Down
35 changes: 15 additions & 20 deletions packages/cli/src/ui/utils/historyMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

import type { HistoryItem, HistoryItemUser } from '../types.js';
import type { Content } from '@google/genai';
import { STARTUP_CONTEXT_MODEL_ACK } from '@qwen-code/qwen-code-core';
import {
getStartupContextLength,
isSystemReminderContent,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './commandUtils.js';

/**
Expand Down Expand Up @@ -44,37 +47,29 @@ function isUserTextContent(content: Content): boolean {
);
if (hasFunctionResponse) return false;

return content.parts.some((part) => 'text' in part && part.text);
}
// Exclude pure <system-reminder> entries (the startup prelude and the
// mid-history MCP added-tool reminders). They are structural, not real user
// prompts; counting them here would shift the rewind truncation index and
// silently drop a real turn's context. A genuine user turn that merely has
// a per-turn reminder prepended still has a non-reminder prompt part, so it
// is NOT excluded.
if (isSystemReminderContent(content)) return false;

/**
* Detects whether the API history starts with the startup context pair
* (user env context + model acknowledgment).
*/
function hasStartupContext(apiHistory: Content[]): boolean {
if (apiHistory.length < 2) return false;
const first = apiHistory[0];
const second = apiHistory[1];
if (first?.role !== 'user' || second?.role !== 'model') return false;
return (
second.parts?.some(
(part) => 'text' in part && part.text === STARTUP_CONTEXT_MODEL_ACK,
) ?? false
);
return content.parts.some((part) => 'text' in part && part.text);
Comment thread
tanzhenxin marked this conversation as resolved.
}

/**
* Computes the number of API Content[] entries to keep when rewinding
* to a specific user turn in the UI history.
*
* The API history may include:
* - A startup context pair: [user(env), model(ack)] at the beginning
* - A startup context entry at the beginning
* - User text prompts (corresponding to UI user turns)
* - Model responses (with optional functionCall parts)
* - Tool result entries: user(functionResponse) + model(response)
*
* This function counts user text Content entries (skipping tool results
* and the startup context pair) to find the API boundary corresponding
* and the startup context entry) to find the API boundary corresponding
* to the target UI user turn.
*
* Note: In IDE mode, additional user Content entries may be injected for
Expand Down Expand Up @@ -105,7 +100,7 @@ export function computeApiTruncationIndex(
}

// Determine the starting index in the API history (skip startup context)
const startIndex = hasStartupContext(apiHistory) ? 2 : 0;
const startIndex = getStartupContextLength(apiHistory);

if (uiUserTurnCount === 0) {
// Rewinding to the first user turn: keep only startup context (if any)
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/agents/background-agent-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ describe('BackgroundAgentResumeService', () => {
getAllTools: vi.fn().mockReturnValue([]),
getAllToolNames: vi.fn().mockReturnValue([]),
stop: vi.fn().mockResolvedValue(undefined),
warmAll: vi.fn().mockResolvedValue(undefined),
getDeferredToolSummary: vi.fn().mockReturnValue([]),
isDeferredToolRevealed: vi.fn().mockReturnValue(false),
getMcpServerInstructions: vi.fn().mockReturnValue(new Map()),
};
const monitorRegistry = {
setAgentNotificationCallback: vi.fn(),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,9 @@ export class BackgroundAgentResumeService {
...(recovery.forkBootstrap?.runtimeHistory ?? []),
]
: [
...(await getInitialChatHistory(bgConfig as Config)),
...(await getInitialChatHistory(bgConfig as Config, undefined, {
includeDeferredToolsReminder: false,
})),
...recovery.history,
];
const promptMessages = [...operation.continuationMessages];
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/agents/runtime/agent-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,7 @@ describe('AgentCore.prepareTools', () => {
// toolConfig entirely — must inherit DEFERRED tools too. Otherwise a
// subagent configured with `tools: ['*']` against a registry that
// includes MCP / lsp / cron_* tools would silently lose them once
// ToolSearch was introduced (the main chat sees them via the
// "Deferred Tools" prompt + ToolSearch flow, but subagents don't get
// either of those scaffolds).
// ToolSearch was introduced.
function buildAgentForTools(
toolConfig: ToolConfig | undefined,
fnDeclarations: FunctionDeclaration[],
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ export class AgentCore {
this.promptConfig.initialMessages.length > 0;
const envHistory = hasInitialMessages
? []
: await getInitialChatHistory(this.runtimeContext);
: await getInitialChatHistory(this.runtimeContext, undefined, {
includeDeferredToolsReminder: false,
});

const startHistory = [
...envHistory,
Expand Down Expand Up @@ -409,9 +411,8 @@ export class AgentCore {
) {
// Subagents inherit the full tool surface — including deferred tools
// (MCP, low-frequency built-ins). Subagents are one-shot and don't
// have the same "save tokens" lifecycle as the main chat, and they
// don't see the "Deferred Tools" section of the system prompt, so
// hiding schemas would silently break existing `tools: ['*']` configs.
// have the same "save tokens" lifecycle as the main chat, so hiding
// schemas would silently break existing `tools: ['*']` configs.
toolsList.push(
...toolRegistry
.getFunctionDeclarations({ includeDeferred: true })
Expand Down
Loading
Loading