diff --git a/.gitignore b/.gitignore
index cb894b60070..202070e7330 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,3 +132,4 @@ tmp/
# Auto-generated computer-use marker can also appear under nested packages.
**/.qwen/computer-use/
.playwright-mcp/
+pnpm-lock.yaml
diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts
index f5f269ab0b6..40e29eb4125 100644
--- a/integration-tests/cli/qwen-serve-routes.test.ts
+++ b/integration-tests/cli/qwen-serve-routes.test.ts
@@ -366,6 +366,7 @@ describe('qwen serve — capabilities envelope', () => {
'permission_mediation',
'non_blocking_prompt',
'session_language',
+ 'session_runtime_context',
'session_rewind',
'workspace_hooks',
'session_hooks',
diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts
index 72aa0e0712f..94155db8a61 100644
--- a/packages/acp-bridge/src/bridge.ts
+++ b/packages/acp-bridge/src/bridge.ts
@@ -5977,6 +5977,35 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
},
+ async setSessionRuntimeContext(sessionId, entries, context) {
+ const entry = byId.get(sessionId);
+ if (!entry) throw new SessionNotFoundError(sessionId);
+ const info = channelInfoForEntry(entry);
+ if (!info || info.isDying) throw new SessionNotFoundError(sessionId);
+ resolveTrustedClientId(entry, context?.clientId);
+
+ const response = (await Promise.race([
+ withTimeout(
+ entry.connection.extMethod(
+ SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext,
+ { sessionId, entries },
+ ),
+ initTimeoutMs,
+ SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext,
+ ),
+ getTransportClosedReject(entry),
+ ])) as {
+ keys: string[];
+ rejected?: Array<{ key: string; reason: string }>;
+ };
+
+ return {
+ sessionId,
+ keys: response.keys,
+ rejected: response.rejected ?? [],
+ };
+ },
+
async generateSessionRecap(sessionId, _context) {
// Thin pass-through to `qwen/control/session/
// recap` — the ACP child runs `generateSessionRecap` against the
diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts
index b47f6949752..71c283c6330 100644
--- a/packages/acp-bridge/src/bridgeTypes.ts
+++ b/packages/acp-bridge/src/bridgeTypes.ts
@@ -995,6 +995,21 @@ export interface AcpSessionBridge {
persisted: boolean;
}>;
+ /**
+ * Set, update, or remove runtime context entries on a live session.
+ * Entries are injected as per-turn blocks on the
+ * next model call. Passing an empty string for a value removes that key.
+ */
+ setSessionRuntimeContext(
+ sessionId: string,
+ entries: Record,
+ context?: BridgeClientRequestContext,
+ ): Promise<{
+ sessionId: string;
+ keys: string[];
+ rejected: Array<{ key: string; reason: string }>;
+ }>;
+
/**
* Generate a one-sentence "where did I leave off" recap of a live
* session. Forwards through `qwen/control/session/recap`, which
diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts
index e923c627c5a..76269ab743e 100644
--- a/packages/acp-bridge/src/status.ts
+++ b/packages/acp-bridge/src/status.ts
@@ -154,6 +154,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
sessionGoalClear: 'qwen/control/session/goal/clear',
workspaceMcpRuntimeAdd: 'qwen/control/workspace/mcp/runtime-add',
workspaceMcpRuntimeRemove: 'qwen/control/workspace/mcp/runtime-remove',
+ sessionRuntimeContext: 'qwen/control/session/runtime_context',
workspaceReload: 'qwen/control/workspace/reload',
workspaceExtensionsRefresh: 'qwen/control/workspace/extensions/refresh',
/**
diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts
index cd82042534a..503bc2f15d6 100644
--- a/packages/cli/src/acp-integration/acpAgent.test.ts
+++ b/packages/cli/src/acp-integration/acpAgent.test.ts
@@ -11153,3 +11153,228 @@ describe('deliverClientMcpMessage — reverse tool channel (#5626)', () => {
});
});
});
+
+describe('sessionRuntimeContext handler', () => {
+ let capturedAgentFactory:
+ | ((conn: { closed: Promise }) => {
+ initialize: (args: Record) => Promise;
+ newSession: (args: Record) => Promise;
+ extMethod: (
+ method: string,
+ args: Record,
+ ) => Promise>;
+ })
+ | undefined;
+
+ let processExitSpy: MockInstance;
+ let stdinDestroySpy: MockInstance;
+ let stdoutDestroySpy: MockInstance;
+
+ const mockConnectionState = {
+ promise: undefined as unknown as Promise,
+ resolve: undefined as unknown as () => void,
+ reset() {
+ this.promise = new Promise((r) => {
+ this.resolve = r;
+ });
+ },
+ };
+
+ const runtimeCtxMap = new Map();
+
+ function makeRuntimeCtxConfig(overrides: Record = {}) {
+ return {
+ initialize: vi.fn().mockResolvedValue(undefined),
+ waitForMcpReady: vi.fn().mockResolvedValue(undefined),
+ getModel: vi.fn().mockReturnValue('m'),
+ getModelsConfig: vi.fn().mockReturnValue({
+ getCurrentAuthType: vi.fn().mockReturnValue('api-key'),
+ syncAfterAuthRefresh: vi.fn(),
+ }),
+ reloadModelProvidersConfig: vi.fn(),
+ refreshAuth: vi.fn().mockResolvedValue(undefined),
+ getTargetDir: vi.fn().mockReturnValue('/tmp'),
+ getContentGeneratorConfig: vi.fn().mockReturnValue({}),
+ getAvailableModels: vi.fn().mockReturnValue([]),
+ getModes: vi.fn().mockReturnValue([]),
+ getApprovalMode: vi.fn().mockReturnValue('default'),
+ getSessionId: vi.fn().mockReturnValue('rt-sid'),
+ getAuthType: vi.fn().mockReturnValue('api-key'),
+ getAllConfiguredModels: vi.fn().mockReturnValue([]),
+ getGeminiClient: vi.fn().mockReturnValue({
+ isInitialized: vi.fn().mockReturnValue(true),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ waitForMcpReady: vi.fn().mockResolvedValue(undefined),
+ refreshSystemInstruction: vi.fn().mockResolvedValue(undefined),
+ }),
+ getFileSystemService: vi.fn().mockReturnValue(undefined),
+ setFileSystemService: vi.fn(),
+ getHookSystem: vi.fn().mockReturnValue(undefined),
+ getDisableAllHooks: vi.fn().mockReturnValue(true),
+ hasHooksForEvent: vi.fn().mockReturnValue(false),
+ getWorkspaceContext: vi.fn().mockReturnValue({}),
+ getDebugMode: vi.fn().mockReturnValue(false),
+ getRuntimeContext: vi.fn().mockReturnValue(runtimeCtxMap),
+ setRuntimeContextEntry: vi
+ .fn()
+ .mockImplementation((key: string, value: string) => {
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) return false;
+ if (Buffer.byteLength(value, 'utf8') > 32 * 1024) return false;
+ if (!runtimeCtxMap.has(key) && runtimeCtxMap.size >= 16) return false;
+ runtimeCtxMap.set(key, value);
+ return true;
+ }),
+ removeRuntimeContextEntry: vi.fn().mockImplementation((key: string) => {
+ runtimeCtxMap.delete(key);
+ }),
+ ...overrides,
+ };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ runtimeCtxMap.clear();
+ mockConnectionState.reset();
+ capturedAgentFactory = undefined;
+
+ vi.mocked(AgentSideConnection).mockImplementation((factory: unknown) => {
+ capturedAgentFactory = factory as typeof capturedAgentFactory;
+ return {
+ get closed() {
+ return mockConnectionState.promise;
+ },
+ } as unknown as InstanceType;
+ });
+
+ processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((() => undefined) as unknown as typeof process.exit);
+ stdinDestroySpy = vi
+ .spyOn(process.stdin, 'destroy')
+ .mockImplementation(() => process.stdin);
+ stdoutDestroySpy = vi
+ .spyOn(process.stdout, 'destroy')
+ .mockImplementation(() => process.stdout);
+ });
+
+ afterEach(() => {
+ mockConnectionState.resolve();
+ processExitSpy.mockRestore();
+ stdinDestroySpy.mockRestore();
+ stdoutDestroySpy.mockRestore();
+ });
+
+ async function setupAgent() {
+ const cfg = makeRuntimeCtxConfig();
+
+ vi.mocked(loadSettings).mockReturnValue({
+ merged: { mcpServers: {} },
+ getUserHooks: vi.fn().mockReturnValue({}),
+ getProjectHooks: vi.fn().mockReturnValue({}),
+ } as unknown as LoadedSettings);
+
+ vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config);
+
+ vi.mocked(Session).mockImplementation(
+ () =>
+ ({
+ getId: vi.fn().mockReturnValue('rt-sid'),
+ getConfig: vi.fn().mockReturnValue(cfg),
+ sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
+ installRewriter: vi.fn(),
+ startCronScheduler: vi.fn(),
+ dispose: vi.fn(),
+ }) as unknown as Session,
+ );
+
+ vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({
+ availableCommands: [],
+ availableSkills: [],
+ });
+
+ const bootConfig = makeRuntimeCtxConfig();
+ runAcpAgent(
+ bootConfig as unknown as Config,
+ { merged: { mcpServers: {} } } as unknown as LoadedSettings,
+ {} as CliArgs,
+ );
+ await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
+ const agent = capturedAgentFactory!({
+ closed: mockConnectionState.promise,
+ });
+ await agent.initialize({});
+ await agent.newSession({ cwd: '/tmp', mcpServers: [] });
+ return agent;
+ }
+
+ it('sets entries and returns applied keys', async () => {
+ const agent = await setupAgent();
+ const result = await agent.extMethod(
+ SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext,
+ {
+ sessionId: 'rt-sid',
+ entries: { operator: 'Alice', rules: 'no-prod' },
+ },
+ );
+ expect(result['keys']).toEqual(['operator', 'rules']);
+ expect(result['rejected']).toEqual([]);
+ expect(runtimeCtxMap.get('operator')).toBe('Alice');
+ });
+
+ it('removes entries with empty string values', async () => {
+ runtimeCtxMap.set('old-key', 'stale');
+ const agent = await setupAgent();
+ const result = await agent.extMethod(
+ SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext,
+ { sessionId: 'rt-sid', entries: { 'old-key': '' } },
+ );
+ expect(result['keys']).toEqual(['old-key']);
+ expect(runtimeCtxMap.has('old-key')).toBe(false);
+ });
+
+ it('rejects non-string values', async () => {
+ const agent = await setupAgent();
+ const result = await agent.extMethod(
+ SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext,
+ { sessionId: 'rt-sid', entries: { bad: 123 } },
+ );
+ expect(result['keys']).toEqual([]);
+ expect(
+ (result['rejected'] as Array<{ key: string; reason: string }>)[0],
+ ).toEqual({
+ key: 'bad',
+ reason: 'value_not_string',
+ });
+ });
+
+ it('rejects invalid keys', async () => {
+ const agent = await setupAgent();
+ const result = await agent.extMethod(
+ SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext,
+ { sessionId: 'rt-sid', entries: { 'invalid key!': 'value' } },
+ );
+ expect(result['keys']).toEqual([]);
+ expect(
+ (result['rejected'] as Array<{ key: string; reason: string }>)[0]?.reason,
+ ).toBe('invalid_key');
+ });
+
+ it('throws on missing sessionId', async () => {
+ const agent = await setupAgent();
+ await expect(
+ agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, {
+ entries: { a: 'b' },
+ }),
+ ).rejects.toThrow(/sessionId/);
+ });
+
+ it('throws on invalid entries shape', async () => {
+ const agent = await setupAgent();
+ await expect(
+ agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext, {
+ sessionId: 'rt-sid',
+ entries: 'not-an-object',
+ }),
+ ).rejects.toThrow(/entries/);
+ });
+});
diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts
index 304cbc9b553..5cb59ee1083 100644
--- a/packages/cli/src/acp-integration/acpAgent.ts
+++ b/packages/cli/src/acp-integration/acpAgent.ts
@@ -7191,6 +7191,55 @@ class QwenAgent implements Agent {
return { language: resolvedLanguage, outputLanguage, refreshed };
}
+ case SERVE_CONTROL_EXT_METHODS.sessionRuntimeContext: {
+ const sessionId = params['sessionId'];
+ const entries = params['entries'];
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
+ throw RequestError.invalidParams(
+ undefined,
+ 'Invalid or missing sessionId',
+ );
+ }
+ if (
+ typeof entries !== 'object' ||
+ entries === null ||
+ Array.isArray(entries)
+ ) {
+ throw RequestError.invalidParams(
+ undefined,
+ '`entries` must be a non-null object',
+ );
+ }
+ const session = this.sessionOrThrow(sessionId);
+ const config = session.getConfig();
+ const appliedKeys: string[] = [];
+ const rejected: Array<{ key: string; reason: string }> = [];
+ for (const [key, value] of Object.entries(
+ entries as Record,
+ )) {
+ if (typeof value !== 'string') {
+ rejected.push({ key, reason: 'value_not_string' });
+ continue;
+ }
+ if (value === '') {
+ if (config.getRuntimeContext().has(key)) {
+ config.removeRuntimeContextEntry(key);
+ appliedKeys.push(key);
+ }
+ } else if (config.setRuntimeContextEntry(key, value)) {
+ appliedKeys.push(key);
+ } else if (!/^[a-zA-Z0-9_-]{1,64}$/.test(key)) {
+ rejected.push({ key, reason: 'invalid_key' });
+ } else if (
+ Buffer.byteLength(value, 'utf8') > 32 * 1024
+ ) {
+ rejected.push({ key, reason: 'value_too_large' });
+ } else {
+ rejected.push({ key, reason: 'capacity_full' });
+ }
+ }
+ return { keys: appliedKeys, rejected };
+ }
case SERVE_CONTROL_EXT_METHODS.sessionRecap: {
// Generate a one-sentence "where did I leave off" summary.
// Best-effort: returns `null` on short history or model failure.
diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts
index 1866f3d9b2b..2ccd9512f38 100644
--- a/packages/cli/src/serve/capabilities.ts
+++ b/packages/cli/src/serve/capabilities.ts
@@ -253,6 +253,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
writer_idle_timeout: { since: 'v1' },
non_blocking_prompt: { since: 'v1' },
session_language: { since: 'v1' },
+ session_runtime_context: { since: 'v1' },
session_rewind: { since: 'v1' },
workspace_hooks: { since: 'v1' },
session_hooks: { since: 'v1' },
diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts
index 625da4e0c9a..144eb78879c 100644
--- a/packages/cli/src/serve/routes/session.ts
+++ b/packages/cli/src/serve/routes/session.ts
@@ -2832,4 +2832,48 @@ export function registerSessionRoutes(
},
),
);
+
+ app.post(
+ '/session/:id/runtime-context',
+ mutate(),
+ withMutableSession(
+ 'POST /session/:id/runtime-context',
+ async (req, res, sessionId) => {
+ const body = safeBody(req);
+ const entries = body['entries'];
+
+ if (
+ typeof entries !== 'object' ||
+ entries === null ||
+ Array.isArray(entries)
+ ) {
+ res.status(400).json({
+ error:
+ '`entries` is required and must be a non-null object mapping string keys to string values',
+ code: 'invalid_entries',
+ });
+ return;
+ }
+
+ const serialized = JSON.stringify(entries);
+ if (Buffer.byteLength(serialized, 'utf8') > 32 * 1024) {
+ res.status(413).json({
+ error: 'runtime context payload exceeds 32 KiB limit',
+ code: 'payload_too_large',
+ });
+ return;
+ }
+
+ const clientId = parseClientIdHeader(req, res);
+ if (clientId === null) return;
+
+ const response = await bridge.setSessionRuntimeContext(
+ sessionId,
+ entries as Record,
+ clientId !== undefined ? { clientId } : undefined,
+ );
+ res.status(200).json(response);
+ },
+ ),
+ );
}
diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts
index 6f91b5ac83e..381bc1ba02a 100644
--- a/packages/cli/src/serve/server.test.ts
+++ b/packages/cli/src/serve/server.test.ts
@@ -302,6 +302,7 @@ const EXPECTED_STAGE1_FEATURES = [
'permission_mediation',
'non_blocking_prompt',
'session_language',
+ 'session_runtime_context',
'session_rewind',
'workspace_hooks',
'session_hooks',
@@ -347,6 +348,7 @@ const EXPECTED_REGISTERED_FEATURES = [
f !== 'permission_mediation' &&
f !== 'non_blocking_prompt' &&
f !== 'session_language' &&
+ f !== 'session_runtime_context' &&
f !== 'session_rewind' &&
f !== 'workspace_hooks' &&
f !== 'session_hooks' &&
@@ -376,6 +378,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'writer_idle_timeout',
'non_blocking_prompt',
'session_language',
+ 'session_runtime_context',
'session_rewind',
'workspace_hooks',
'session_hooks',
@@ -1644,6 +1647,19 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
});
return setApprovalModeImpl(sessionId, mode, o, context);
},
+ async setSessionRuntimeContext(
+ sessionId: string,
+ entries: Record,
+ _context?: { clientId?: string },
+ ) {
+ return {
+ sessionId,
+ keys: Object.keys(entries).filter(
+ (k) => typeof entries[k] === 'string',
+ ),
+ rejected: [] as Array<{ key: string; reason: string }>,
+ };
+ },
async generateSessionRecap(sessionId, context) {
generateSessionRecapCalls.push({
sessionId,
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 38eb7e7a502..2e327ddca2b 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -15,8 +15,6 @@ import {
APPROVAL_MODE_INFO,
MCPServerConfig,
TrustGateError,
- matchesServerPattern,
- matchesAnyServerPattern,
} from './config.js';
import { Storage } from './storage.js';
import { DEFAULT_MAX_TOOL_CALLS_PER_TURN } from '../services/loopDetectionService.js';
@@ -58,12 +56,6 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { LoadServerHierarchicalMemoryOptions } from '../utils/memoryDiscovery.js';
import { readAutoMemoryIndex } from '../memory/store.js';
-import {
- rebuildTeamAutoMemoryIndex,
- TeamMemoryRootSecurityError,
-} from '../memory/indexer.js';
-import { syncTeamMemory } from '../memory/team-memory-sync.js';
-import { getTeamMemoryShareabilityWarning } from '../memory/team-memory-git-status.js';
import * as runtimeStatus from '../utils/runtimeStatus.js';
import { ExtensionManager } from '../extension/extensionManager.js';
import { SkillManager } from '../skills/skill-manager.js';
@@ -159,20 +151,6 @@ vi.mock('../memory/store.js', () => ({
readAutoMemoryIndex: vi.fn().mockResolvedValue(null),
readUserAutoMemoryIndex: vi.fn().mockResolvedValue(null),
}));
-vi.mock('../memory/indexer.js', async (importActual) => ({
- // Keep the real exports (notably TeamMemoryRootSecurityError, which the sync
- // gate distinguishes via instanceof) and override only the rebuild.
- ...(await importActual()),
- rebuildTeamAutoMemoryIndex: vi.fn().mockResolvedValue(null),
-}));
-vi.mock('../memory/team-memory-sync.js', () => ({
- syncTeamMemory: vi
- .fn()
- .mockResolvedValue({ committed: false, pulled: false, pushed: false }),
-}));
-vi.mock('../memory/team-memory-git-status.js', () => ({
- getTeamMemoryShareabilityWarning: vi.fn().mockReturnValue(null),
-}));
vi.mock('../hooks/index.js', () => {
const HookSystemMock = vi.fn();
@@ -349,92 +327,6 @@ vi.mock('../core/toolHookTriggers.js', () => ({
fireNotificationHook: vi.fn().mockResolvedValue({}),
}));
-describe('matchesServerPattern', () => {
- it('exact match when no glob characters', () => {
- expect(matchesServerPattern('puppeteer', 'puppeteer')).toBe(true);
- expect(matchesServerPattern('puppeteer', 'playwright')).toBe(false);
- });
-
- it('* matches any sequence including empty', () => {
- expect(matchesServerPattern('puppeteer', '*puppeteer*')).toBe(true);
- expect(matchesServerPattern('my-puppeteer-server', '*puppeteer*')).toBe(
- true,
- );
- expect(matchesServerPattern('playwright', '*puppeteer*')).toBe(false);
- expect(matchesServerPattern('anything', '*')).toBe(true);
- expect(matchesServerPattern('prefix-suffix', 'prefix*')).toBe(true);
- expect(matchesServerPattern('prefix-suffix', '*suffix')).toBe(true);
- });
-
- it('? matches exactly one character', () => {
- expect(matchesServerPattern('abc', 'a?c')).toBe(true);
- expect(matchesServerPattern('ac', 'a?c')).toBe(false);
- expect(matchesServerPattern('axc', 'a?c')).toBe(true);
- });
-
- it('escapes regex special characters', () => {
- expect(matchesServerPattern('my.server', 'my.server')).toBe(true);
- expect(matchesServerPattern('myXserver', 'my.server')).toBe(false);
- expect(matchesServerPattern('a+b', 'a+b')).toBe(true);
- expect(matchesServerPattern('a^b', 'a^b')).toBe(true);
- expect(matchesServerPattern('a$b', 'a$b')).toBe(true);
- expect(matchesServerPattern('aXb', 'a$b')).toBe(false);
- });
-
- it('combines glob with exact segments', () => {
- expect(matchesServerPattern('foo-bar-baz', 'foo-*-baz')).toBe(true);
- expect(matchesServerPattern('foo-bar-qux', 'foo-*-baz')).toBe(false);
- });
-
- it('handles empty name', () => {
- expect(matchesServerPattern('', '*')).toBe(true);
- expect(matchesServerPattern('', '?')).toBe(false);
- expect(matchesServerPattern('', '')).toBe(true);
- });
-
- it('handles consecutive * in pattern', () => {
- expect(matchesServerPattern('puppeteer', '**puppeteer**')).toBe(true);
- expect(matchesServerPattern('abc', 'a**c')).toBe(true);
- });
-
- it('handles ? at pattern boundaries', () => {
- expect(matchesServerPattern('abc', '?bc')).toBe(true);
- expect(matchesServerPattern('abc', 'ab?')).toBe(true);
- expect(matchesServerPattern('abc', '???')).toBe(true);
- expect(matchesServerPattern('ab', '???')).toBe(false);
- });
-
- it('rejects when pattern is longer than name', () => {
- expect(matchesServerPattern('ab', 'a*b*c')).toBe(false);
- expect(matchesServerPattern('abc', 'a*b*c')).toBe(true);
- });
-});
-
-describe('matchesAnyServerPattern', () => {
- it('returns false for undefined or empty list', () => {
- expect(matchesAnyServerPattern('puppeteer', undefined)).toBe(false);
- expect(matchesAnyServerPattern('puppeteer', [])).toBe(false);
- });
-
- it('matches if any pattern matches', () => {
- expect(
- matchesAnyServerPattern('puppeteer', ['playwright', '*puppeteer*']),
- ).toBe(true);
- expect(
- matchesAnyServerPattern('chrome', ['playwright', '*puppeteer*']),
- ).toBe(false);
- });
-
- it('works with mixed exact and glob patterns', () => {
- expect(
- matchesAnyServerPattern('playwright', ['playwright', '*puppeteer*']),
- ).toBe(true);
- expect(
- matchesAnyServerPattern('my-puppeteer', ['playwright', '*puppeteer*']),
- ).toBe(true);
- });
-});
-
describe('Server Config (config.ts)', () => {
const MODEL = 'qwen3-coder-plus';
@@ -900,7 +792,6 @@ describe('Server Config (config.ts)', () => {
).toBe(false);
});
});
-
it('should store a system prompt override', () => {
const config = new Config({
...baseParams,
@@ -1651,125 +1542,6 @@ describe('Server Config (config.ts)', () => {
});
expect(config.getAllowedMcpServers()).toEqual(['y']);
});
-
- it('getMcpServers filters by glob pattern in allowedMcpServers', async () => {
- const config = new Config({
- ...baseParams,
- mcpServers: {
- puppeteer: srvA,
- 'my-puppeteer-server': srvB,
- playwright: srvA,
- },
- });
- config.setAllowedMcpServers(['*puppeteer*']);
- const result = config.getMcpServers();
- expect(Object.keys(result!)).toEqual([
- 'puppeteer',
- 'my-puppeteer-server',
- ]);
- expect(Object.keys(result!)).not.toContain('playwright');
- });
-
- it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => {
- const config = new Config({
- ...baseParams,
- mcpServers: {
- puppeteer: srvA,
- 'my-puppeteer': srvA,
- playwright: srvB,
- },
- });
- config.setExcludedMcpServers(['*puppeteer*']);
- expect(config.isMcpServerDisabled('puppeteer')).toBe(true);
- expect(config.isMcpServerDisabled('my-puppeteer')).toBe(true);
- expect(config.isMcpServerDisabled('playwright')).toBe(false);
- expect(config.getMcpServers()!['puppeteer']).toBeDefined();
- expect(config.getMcpServers()!['my-puppeteer']).toBeDefined();
- });
-
- it('getMcpServerUnavailableReason classifies by glob match', async () => {
- const config = new Config({
- ...baseParams,
- mcpServers: {
- puppeteer: srvA,
- playwright: srvB,
- chrome: srvA,
- },
- });
- await config.reinitializeMcpServers({
- puppeteer: srvA,
- playwright: srvB,
- chrome: srvA,
- });
-
- config.setAllowedMcpServers(['play*']);
- expect(config.getMcpServerUnavailableReason('puppeteer')).toBe(
- 'not_allowed',
- );
- expect(
- config.getMcpServerUnavailableReason('playwright'),
- ).toBeUndefined();
-
- // Clear allow-list so the excluded check is reached.
- config.setAllowedMcpServers(undefined);
- config.setExcludedMcpServers(['*chrome*']);
- expect(config.getMcpServerUnavailableReason('chrome')).toBe('excluded');
- });
-
- it('exclude takes precedence over allow with glob patterns', async () => {
- const config = new Config({
- ...baseParams,
- mcpServers: { puppeteer: srvA, playwright: srvB },
- });
- await config.reinitializeMcpServers({
- puppeteer: srvA,
- playwright: srvB,
- });
-
- config.setAllowedMcpServers(['*']);
- config.setExcludedMcpServers(['puppeteer']);
- expect(config.getMcpServerUnavailableReason('puppeteer')).toBe(
- 'excluded',
- );
- expect(
- config.getMcpServerUnavailableReason('playwright'),
- ).toBeUndefined();
- });
-
- it('exclude takes precedence when both lists use globs', async () => {
- const config = new Config({
- ...baseParams,
- mcpServers: { puppeteer: srvA, playwright: srvB },
- });
- await config.reinitializeMcpServers({
- puppeteer: srvA,
- playwright: srvB,
- });
-
- config.setAllowedMcpServers(['*puppeteer*']);
- config.setExcludedMcpServers(['puppeteer']);
- expect(config.getMcpServerUnavailableReason('puppeteer')).toBe(
- 'excluded',
- );
- expect(config.isMcpServerDisabled('puppeteer')).toBe(true);
- });
-
- it('getBlockedMcpServers returns servers not matching allowed glob', () => {
- const config = new Config({
- ...baseParams,
- mcpServers: {
- puppeteer: srvA,
- 'my-puppeteer': srvA,
- playwright: srvB,
- },
- });
- config.setAllowedMcpServers(['*puppeteer*']);
- const blocked = config.getBlockedMcpServers();
- const blockedNames = blocked.map((s) => s.name);
- expect(blockedNames).toContain('playwright');
- expect(blockedNames).not.toContain('puppeteer');
- expect(blockedNames).not.toContain('my-puppeteer');
- });
});
describe('MemoryPressureMonitor isolation', () => {
@@ -3436,180 +3208,6 @@ describe('Server Config (config.ts)', () => {
expect(config.getUserMemory()).toContain('[Project Memory](project.md)');
});
- it('refreshHierarchicalMemory should not load team memory from untrusted workspaces', async () => {
- const config = new Config({ ...baseParams, enableTeamMemory: true });
- vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false);
- vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
- memoryContent: '--- Context from: QWEN.md ---\nProject rules',
- fileCount: 1,
- ruleCount: 0,
- conditionalRules: [],
- projectRoot: '/tmp',
- });
- vi.mocked(rebuildTeamAutoMemoryIndex).mockResolvedValue(
- '# Team Memory\n\n- [Shared](shared.md)',
- );
-
- await config.refreshHierarchicalMemory();
-
- expect(rebuildTeamAutoMemoryIndex).not.toHaveBeenCalled();
- expect(config.getUserMemory()).not.toContain('Team Memory');
- // The shareability check is gated on the active tier, so an inactive
- // (untrusted) tier must never probe git.
- expect(getTeamMemoryShareabilityWarning).not.toHaveBeenCalled();
- });
-
- it('refreshHierarchicalMemory must not sync when the team-root safety check rejects', async () => {
- // The indexer THROWS when the team root is a symlink that could redirect the
- // committed index outside the repo. Sync must respect that refusal: it must
- // never git add/commit/push a dir that failed the safety check.
- const prevSync = process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
- process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = '1';
- try {
- const config = new Config({
- ...baseParams,
- enableTeamMemory: true,
- enableTeamMemorySync: true,
- });
- vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
- vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
- memoryContent: '--- Context from: QWEN.md ---\nProject rules',
- fileCount: 1,
- ruleCount: 0,
- conditionalRules: [],
- projectRoot: '/tmp',
- });
- // Mirror the indexer's symlink-escape rejection: a SECURITY failure, which
- // is the only class that blocks sync (see indexer.ts).
- vi.mocked(rebuildTeamAutoMemoryIndex).mockRejectedValueOnce(
- new TeamMemoryRootSecurityError(
- 'Refusing to write team memory index: /tmp/.qwen/team-memory is a ' +
- 'symlink, which could redirect the committed index outside the repository.',
- ),
- );
-
- await config.refreshHierarchicalMemory();
-
- // Gate proof: sync is enabled, yet the security rejection must skip it
- // entirely. Stop treating TeamMemoryRootSecurityError as blocking and this
- // assertion fails.
- expect(rebuildTeamAutoMemoryIndex).toHaveBeenCalledTimes(1);
- expect(syncTeamMemory).not.toHaveBeenCalled();
- } finally {
- if (prevSync === undefined) {
- delete process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
- } else {
- process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = prevSync;
- }
- }
- });
-
- it('still syncs when the team-index rebuild fails for an OPERATIONAL reason', async () => {
- // An EACCES/ENOSPC/EPERM rebuild failure is not a security escape, so it must
- // NOT permanently gate legitimate sync — it self-corrects on the next
- // successful rebuild. Only TeamMemoryRootSecurityError blocks sync.
- const prevSync = process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
- process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = '1';
- try {
- const config = new Config({
- ...baseParams,
- enableTeamMemory: true,
- enableTeamMemorySync: true,
- });
- vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
- vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
- memoryContent: '--- Context from: QWEN.md ---\nProject rules',
- fileCount: 1,
- ruleCount: 0,
- conditionalRules: [],
- projectRoot: '/tmp',
- });
- // A plain Error stands in for an operational IO failure (e.g. EACCES).
- const operationalError = Object.assign(
- new Error('EACCES: permission denied, lstat'),
- {
- code: 'EACCES',
- },
- );
- vi.mocked(rebuildTeamAutoMemoryIndex).mockRejectedValueOnce(
- operationalError,
- );
-
- await config.refreshHierarchicalMemory();
-
- // Not security-gated: sync still runs despite the operational failure.
- expect(rebuildTeamAutoMemoryIndex).toHaveBeenCalledTimes(1);
- expect(syncTeamMemory).toHaveBeenCalledTimes(1);
- } finally {
- if (prevSync === undefined) {
- delete process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
- } else {
- process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = prevSync;
- }
- }
- });
-
- it('syncs when the rebuild succeeds and sync is enabled (positive gate)', async () => {
- // Complement to the negative branches: a successful rebuild on a trusted
- // folder with sync enabled MUST call syncTeamMemory. Inverting or removing
- // the sync condition is caught here.
- const prevSync = process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
- process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = '1';
- try {
- const config = new Config({
- ...baseParams,
- enableTeamMemory: true,
- enableTeamMemorySync: true,
- });
- vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
- vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
- memoryContent: '--- Context from: QWEN.md ---\nProject rules',
- fileCount: 1,
- ruleCount: 0,
- conditionalRules: [],
- projectRoot: '/tmp',
- });
- vi.mocked(rebuildTeamAutoMemoryIndex).mockResolvedValueOnce(
- '# Team Memory\n\n- [Shared](shared.md)',
- );
-
- await config.refreshHierarchicalMemory();
-
- expect(rebuildTeamAutoMemoryIndex).toHaveBeenCalledTimes(1);
- expect(syncTeamMemory).toHaveBeenCalledTimes(1);
- } finally {
- if (prevSync === undefined) {
- delete process.env['QWEN_CODE_MEMORY_TEAM_SYNC'];
- } else {
- process.env['QWEN_CODE_MEMORY_TEAM_SYNC'] = prevSync;
- }
- }
- });
-
- it('refreshHierarchicalMemory surfaces a one-time warning when team memory is not git-shareable', async () => {
- const config = new Config({ ...baseParams, enableTeamMemory: true });
- vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
- vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
- memoryContent: '--- Context from: QWEN.md ---\nProject rules',
- fileCount: 1,
- ruleCount: 0,
- conditionalRules: [],
- projectRoot: '/tmp',
- });
- vi.mocked(getTeamMemoryShareabilityWarning).mockReturnValue(
- 'Team memory is enabled, but /tmp/.qwen/team-memory is git-ignored',
- );
-
- await config.refreshHierarchicalMemory();
- // A second refresh must not re-emit the warning (latched once per process).
- await config.refreshHierarchicalMemory();
-
- expect(getTeamMemoryShareabilityWarning).toHaveBeenCalledTimes(1);
- expect(config.getWarnings()).toContainEqual(
- expect.stringContaining('is git-ignored'),
- );
- });
-
it('refreshHierarchicalMemory should include appended auto-memory in the context warning estimate', async () => {
const config = new Config({
...baseParams,
@@ -6395,7 +5993,6 @@ describe('Model Switching and Config Updates', () => {
['enableCacheControl']: false,
['forceGlobalCacheScope']: false,
['toolResultContentFormat']: 'string',
- ['modalities']: { image: true },
};
vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({
@@ -6407,7 +6004,6 @@ describe('Model Switching and Config Updates', () => {
enableCacheControl: { kind: 'settings' },
forceGlobalCacheScope: { kind: 'settings' },
toolResultContentFormat: { kind: 'settings' },
- modalities: { kind: 'computed', detail: 'auto' },
},
});
@@ -6429,10 +6025,6 @@ describe('Model Switching and Config Updates', () => {
expect(updatedConfig['enableCacheControl']).toBe(false);
expect(updatedConfig['forceGlobalCacheScope']).toBe(false);
expect(updatedConfig['toolResultContentFormat']).toBe('string');
- // Modalities are model-derived; a hot switch must refresh them so the
- // vision-bridge gate reflects the new model (it reads getEffectiveInputModalities()).
- expect(updatedConfig['modalities']).toEqual({ image: true });
- expect(config.getEffectiveInputModalities()).toEqual({ image: true });
// Verify sources are also updated
const sources = config.getContentGeneratorConfigSources();
@@ -6444,7 +6036,6 @@ describe('Model Switching and Config Updates', () => {
expect(sources['enableCacheControl']?.kind).toBe('settings');
expect(sources['forceGlobalCacheScope']?.kind).toBe('settings');
expect(sources['toolResultContentFormat']?.kind).toBe('settings');
- expect(sources['modalities']?.kind).toBe('computed');
});
it('should trigger full refresh when switching to non-qwen-oauth provider', async () => {
@@ -6840,42 +6431,86 @@ describe('Model Switching and Config Updates', () => {
});
});
- describe('MCP Stop dispatch with context usage data', () => {
- it('buildContextUsage handles MCP input patterns with runtime validation', async () => {
- // Test the buildContextUsage function that's used in MCP Stop dispatch
- // This validates the runtime type coercion and edge cases
- const { buildContextUsage } = await import('../hooks/context-usage.js');
+ describe('RuntimeContext', () => {
+ it('should start empty', () => {
+ const config = new Config(baseParams);
+ expect(config.getRuntimeContext().size).toBe(0);
+ });
- // Normal case: valid numbers
- expect(buildContextUsage(128000, 64000)).toEqual({
- context_usage: 0.5,
- context_limit: 128000,
- input_tokens: 64000,
- });
+ it('should set and get an entry', () => {
+ const config = new Config(baseParams);
+ expect(config.setRuntimeContextEntry('operator', 'Alice')).toBe(true);
+ expect(config.getRuntimeContext().get('operator')).toBe('Alice');
+ });
+
+ it('should delete entry when value is empty', () => {
+ const config = new Config(baseParams);
+ config.setRuntimeContextEntry('operator', 'Alice');
+ expect(config.setRuntimeContextEntry('operator', '')).toBe(true);
+ expect(config.getRuntimeContext().has('operator')).toBe(false);
+ });
- // Missing context_limit: returns undefined
- expect(buildContextUsage(undefined, 64000)).toBeUndefined();
+ it('should remove entry by key', () => {
+ const config = new Config(baseParams);
+ config.setRuntimeContextEntry('operator', 'Alice');
+ config.removeRuntimeContextEntry('operator');
+ expect(config.getRuntimeContext().size).toBe(0);
+ });
- // Missing input_tokens (defaults to 0): returns undefined
- expect(buildContextUsage(128000, 0)).toBeUndefined();
+ it('should reject invalid keys and return false', () => {
+ const config = new Config(baseParams);
+ expect(config.setRuntimeContextEntry('invalid key!', 'value')).toBe(
+ false,
+ );
+ expect(config.setRuntimeContextEntry('', 'value')).toBe(false);
+ expect(config.setRuntimeContextEntry('a'.repeat(65), 'value')).toBe(
+ false,
+ );
+ expect(config.getRuntimeContext().size).toBe(0);
+ });
- // Both missing: returns undefined
- expect(buildContextUsage(undefined, 0)).toBeUndefined();
+ it('should reject values exceeding 32 KiB and return false', () => {
+ const config = new Config(baseParams);
+ expect(
+ config.setRuntimeContextEntry('big', 'x'.repeat(32 * 1024 + 1)),
+ ).toBe(false);
+ expect(config.getRuntimeContext().size).toBe(0);
+ });
- // String values (MCP might send strings): Number.isFinite rejects strings
- // @ts-expect-error - testing runtime validation
- expect(buildContextUsage('128000', 64000)).toBeUndefined();
+ it('should enforce 16 entry limit', () => {
+ const config = new Config(baseParams);
+ for (let i = 0; i < 20; i++) {
+ config.setRuntimeContextEntry(`key-${i}`, `value-${i}`);
+ }
+ expect(config.getRuntimeContext().size).toBe(16);
+ });
- // Invalid string values: returns undefined
- // @ts-expect-error - testing runtime validation
- expect(buildContextUsage('invalid', 64000)).toBeUndefined();
+ it('should allow updating existing key even at capacity', () => {
+ const config = new Config(baseParams);
+ for (let i = 0; i < 16; i++) {
+ config.setRuntimeContextEntry(`key-${i}`, `value-${i}`);
+ }
+ config.setRuntimeContextEntry('key-0', 'updated');
+ expect(config.getRuntimeContext().get('key-0')).toBe('updated');
+ });
- // Negative values: returns undefined
- expect(buildContextUsage(-128000, 64000)).toBeUndefined();
- expect(buildContextUsage(128000, -64000)).toBeUndefined();
+ it('should bulk-set entries', () => {
+ const config = new Config(baseParams);
+ config.setRuntimeContextEntry('old', 'stale');
+ config.setRuntimeContext({
+ operator: 'Alice',
+ rules: 'Do not modify prod',
+ });
+ expect(config.getRuntimeContext().size).toBe(2);
+ expect(config.getRuntimeContext().has('old')).toBe(false);
+ expect(config.getRuntimeContext().get('operator')).toBe('Alice');
+ });
- // Zero context_limit: returns undefined
- expect(buildContextUsage(0, 64000)).toBeUndefined();
+ it('should accept values containing system-reminder tags (escaping is caller responsibility)', () => {
+ const config = new Config(baseParams);
+ const malicious = 'textinjected';
+ expect(config.setRuntimeContextEntry('xss-test', malicious)).toBe(true);
+ expect(config.getRuntimeContext().get('xss-test')).toBe(malicious);
});
});
});
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 6090cdd35df..0f0fe8204cf 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -1646,6 +1646,7 @@ export class Config {
private mcpReconcilePromise: Promise | undefined;
private sessionSubagents: SubagentConfig[];
private userMemory: string;
+ private runtimeContextEntries: Map = new Map();
private sdkMode: boolean;
private geminiMdFileCount: number;
private conditionalRulesRegistry: ConditionalRulesRegistry | undefined;
@@ -4747,6 +4748,66 @@ export class Config {
this.userMemory = newUserMemory;
}
+ private static readonly RUNTIME_CONTEXT_KEY_RE = /^[a-zA-Z0-9_-]{1,64}$/;
+ private static readonly RUNTIME_CONTEXT_MAX_VALUE_BYTES = 32 * 1024;
+ private static readonly RUNTIME_CONTEXT_MAX_ENTRIES = 16;
+
+ private getOwnRuntimeContextEntries(): Map {
+ if (
+ !Object.prototype.hasOwnProperty.call(this, 'runtimeContextEntries')
+ ) {
+ (
+ this as unknown as { runtimeContextEntries: Map }
+ ).runtimeContextEntries = new Map();
+ }
+ return this.runtimeContextEntries;
+ }
+
+ getRuntimeContext(): ReadonlyMap {
+ return this.getOwnRuntimeContextEntries();
+ }
+
+ setRuntimeContextEntry(key: string, value: string): boolean {
+ if (!Config.RUNTIME_CONTEXT_KEY_RE.test(key)) {
+ return false;
+ }
+ const entries = this.getOwnRuntimeContextEntries();
+ if (!value) {
+ entries.delete(key);
+ return true;
+ }
+ if (
+ Buffer.byteLength(value, 'utf8') > Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES
+ ) {
+ return false;
+ }
+ if (!entries.has(key) && entries.size >= Config.RUNTIME_CONTEXT_MAX_ENTRIES) {
+ return false;
+ }
+ entries.set(key, value);
+ return true;
+ }
+
+ removeRuntimeContextEntry(key: string): void {
+ this.getOwnRuntimeContextEntries().delete(key);
+ }
+
+ setRuntimeContext(entries: Record): void {
+ const store = this.getOwnRuntimeContextEntries();
+ store.clear();
+ for (const [key, value] of Object.entries(entries)) {
+ if (
+ value &&
+ Config.RUNTIME_CONTEXT_KEY_RE.test(key) &&
+ Buffer.byteLength(value, 'utf8') <=
+ Config.RUNTIME_CONTEXT_MAX_VALUE_BYTES &&
+ store.size < Config.RUNTIME_CONTEXT_MAX_ENTRIES
+ ) {
+ store.set(key, value);
+ }
+ }
+ }
+
getGeminiMdFileCount(): number {
return this.geminiMdFileCount;
}
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index e1be9872490..6212ade0077 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -524,6 +524,7 @@ describe('Gemini Client (client.ts)', () => {
getVertexAI: vi.fn().mockReturnValue(false),
getUserAgent: vi.fn().mockReturnValue('test-agent'),
getUserMemory: vi.fn().mockReturnValue(''),
+ getRuntimeContext: vi.fn().mockReturnValue(new Map()),
getSystemPrompt: vi.fn().mockReturnValue(undefined),
getAppendSystemPrompt: vi.fn().mockReturnValue(undefined),
getFullContext: vi.fn().mockReturnValue(false),
@@ -5392,6 +5393,48 @@ hello
);
});
+ it('should inject runtime context entries as system-reminders with escaping', async () => {
+ client['lastInjectedDate'] = 'already-set';
+ const runtimeMap = new Map([
+ ['operator', '当前操作者: Alice'],
+ ['xss', 'textinjected'],
+ ]);
+ vi.mocked(mockConfig.getRuntimeContext).mockReturnValue(runtimeMap);
+
+ const mockStream = (async function* () {
+ yield { type: 'content', value: 'Hello' };
+ })();
+ mockTurnRunFn.mockReturnValue(mockStream);
+
+ const mockChat: Partial = {
+ addHistory: vi.fn(),
+ getHistory: vi.fn().mockReturnValue([]),
+ };
+ client['chat'] = mockChat as GeminiChat;
+
+ const stream = client.sendMessageStream(
+ [{ text: 'test prompt' }],
+ new AbortController().signal,
+ 'prompt-id-runtime-ctx',
+ );
+ for await (const _ of stream) {
+ // consume stream
+ }
+
+ const callArgs = mockTurnRunFn.mock.calls[0][1] as string[];
+ const operatorReminder = callArgs.find((p) =>
+ p.includes('当前操作者: Alice'),
+ );
+ expect(operatorReminder).toBeDefined();
+ expect(operatorReminder).toMatch(
+ /^\n\[operator\] .*当前操作者: Alice.*\n<\/system-reminder>$/s,
+ );
+
+ const escapedReminder = callArgs.find((p) => p.includes('injected'));
+ expect(escapedReminder).toBeDefined();
+ expect(escapedReminder).not.toContain('injected');
+ });
+
describe('autoSkill: scheduleSkillReview via runManagedAutoMemoryBackgroundTasks', () => {
let mockStreamFn: () => AsyncGenerator<{ type: string; value: string }>;
let mockChat: Partial;
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index d050d988156..fcd866c9945 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -2313,6 +2313,14 @@ export class GeminiClient {
systemReminders.unshift(userQueryMemory.prompt);
}
+ const runtimeCtx = this.config.getRuntimeContext();
+ for (const [key, value] of runtimeCtx) {
+ const safe = escapeSystemReminderTags(value);
+ systemReminders.push(
+ `\n[${key}] ${safe}\n`,
+ );
+ }
+
requestToSend = [...systemReminders, ...requestToSend];
}
diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts
index 986d83ab2be..8101e780152 100644
--- a/packages/sdk-typescript/src/daemon/DaemonClient.ts
+++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts
@@ -2792,6 +2792,38 @@ export class DaemonClient {
);
}
+ async setSessionRuntimeContext(
+ sessionId: string,
+ entries: Record,
+ clientId?: string,
+ ): Promise<{
+ sessionId: string;
+ keys: string[];
+ rejected: Array<{ key: string; reason: string }>;
+ }> {
+ return await this.fetchWithTimeout(
+ `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/runtime-context`,
+ {
+ method: 'POST',
+ headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
+ body: JSON.stringify({ entries }),
+ },
+ async (res) => {
+ if (!res.ok) {
+ throw await this.failOnError(
+ res,
+ 'POST /session/:id/runtime-context',
+ );
+ }
+ return (await res.json()) as {
+ sessionId: string;
+ keys: string[];
+ rejected: Array<{ key: string; reason: string }>;
+ };
+ },
+ );
+ }
+
/**
* Send a prompt to the agent. Supports both blocking (legacy 200)
* and non-blocking (202 + SSE `turn_complete`) daemon responses.
diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
index c84ceeff912..af87811bd0a 100644
--- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
+++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
@@ -449,6 +449,20 @@ export class DaemonSessionClient {
);
}
+ async setRuntimeContext(
+ entries: Record,
+ ): Promise<{
+ sessionId: string;
+ keys: string[];
+ rejected: Array<{ key: string; reason: string }>;
+ }> {
+ return await this.client.setSessionRuntimeContext(
+ this.sessionId,
+ entries,
+ this.clientId,
+ );
+ }
+
async getRewindSnapshots(): Promise<{
snapshots: DaemonRewindSnapshotInfo[];
}> {