Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
16 changes: 11 additions & 5 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import * as path from 'node:path';
import { Session } from './Session.js';
import type { Content } from '@google/genai';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
AuthType,
SYSTEM_REMINDER_OPEN,
} 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 @@ -247,17 +251,19 @@ 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` }],
},
{ role: 'user', parts: [{ text: 'first' }] },
{ role: 'model', parts: [{ text: 'first reply' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);

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('rejects unreachable user turns', () => {
Expand Down
16 changes: 2 additions & 14 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
getPlanModeSystemReminder,
getSubagentSystemReminder,
getArenaSystemReminder,
STARTUP_CONTEXT_MODEL_ACK,
getStartupContextLength,
evaluatePermissionFlow,
needsConfirmation,
isPlanModeBlocked,
Expand Down Expand Up @@ -273,7 +273,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 @@ -295,18 +295,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 Down
22 changes: 10 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,7 @@ 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 } from '@qwen-code/qwen-code-core';

// ---------------------------------------------------------------------------
// Helpers
Expand All @@ -32,11 +33,8 @@ 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...`);
}

function userItem(id: number, text = `prompt ${id}`): HistoryItem {
Expand Down Expand Up @@ -114,16 +112,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 @@ -134,14 +132,14 @@ 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);
});
});

Expand Down
24 changes: 4 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,7 @@

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

/**
Expand Down Expand Up @@ -36,34 +36,18 @@ function isUserTextContent(content: Content): boolean {
return content.parts.some((part) => 'text' in part && part.text);
Comment thread
tanzhenxin marked this conversation as resolved.
Resolved
}

/**
* 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
);
}

/**
* 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 @@ -94,7 +78,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 @@ -550,7 +550,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 @@ -325,7 +325,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 @@ -407,9 +409,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
18 changes: 10 additions & 8 deletions packages/core/src/agents/runtime/agent-headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '../../core/contentGenerator.js';
import { GeminiChat } from '../../core/geminiChat.js';
import { executeToolCall } from '../../core/nonInteractiveToolExecutor.js';
import { getInitialChatHistory } from '../../utils/environmentContext.js';
import type { ToolRegistry } from '../../tools/tool-registry.js';
import { type AnyDeclarativeTool } from '../../tools/tools.js';
import { ContextState, AgentHeadless } from './agent-headless.js';
Expand Down Expand Up @@ -79,15 +80,12 @@ vi.mock('../../core/contentGenerator.js', async (importOriginal) => {
};
});
vi.mock('../../utils/environmentContext.js', () => ({
SYSTEM_REMINDER_OPEN: '<system-reminder>',
getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]),
getInitialChatHistory: vi.fn(async (_config, extraHistory) => [
{
role: 'user',
parts: [{ text: 'Env Context' }],
},
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
parts: [{ text: '<system-reminder>\nEnv Context\n</system-reminder>' }],
},
...(extraHistory ?? []),
]),
Expand Down Expand Up @@ -464,11 +462,15 @@ describe('subagent.ts', () => {

// Check History (should include environment context)
const history = callArgs[2];
expect(getInitialChatHistory).toHaveBeenCalledWith(config, undefined, {
includeDeferredToolsReminder: false,
});
expect(history).toEqual([
{ role: 'user', parts: [{ text: 'Env Context' }] },
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
role: 'user',
parts: [
{ text: '<system-reminder>\nEnv Context\n</system-reminder>' },
],
},
]);
});
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,14 +1417,15 @@ export class Config {
.then(async () => {
// After background discovery completes, push the newly-registered
// MCP tools into the active GeminiChat so the next model request
// sees them. Interactive mode also calls setTools() via
// AppContainer's batch-flush effect — this trailing call is
// idempotent there, but it's the ONLY path that updates
// `chat.tools` for non-interactive runs (no AppContainer).
// sees both the updated declarations and added-tool reminder deltas.
// Interactive mode also calls setTools() via AppContainer's
// batch-flush effect — this trailing call is idempotent there, but
// it's the ONLY path that updates `chat.tools` for non-interactive
// runs (no AppContainer).
// Without this, `chat.tools` would be frozen at the built-in-only
// snapshot taken inside `geminiClient.initialize()` → `startChat()`,
// and `runNonInteractive` / stream-json / ACP would silently lose
// every MCP tool — a regression vs the legacy synchronous path.
// progressive MCP tools — a regression vs the legacy synchronous path.
try {
await this.geminiClient?.setTools();
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,35 @@ describe('AnthropicContentConverter', () => {
]);
});

it('preserves ordered multi-part startup reminder user content', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{
role: 'user',
parts: [
{ text: '<system-reminder>\ndeferred tools' },
{ text: '<system-reminder>\nstartup context' },
],
},
],
});

expect(messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: '<system-reminder>\ndeferred tools' },
{
type: 'text',
text: '<system-reminder>\nstartup context',
cache_control: { type: 'ephemeral' },
},
],
},
]);
});

it('converts assistant thought parts into Anthropic thinking blocks', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
Expand Down
Loading
Loading