mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-31 02:14:58 +00:00
* feat(agent-core): unify the v1 MCP management plane - McpServerRegistry: one config view over global (layered mcp.json), plugin (manifests, read-only, final effective config), and caller (SDK-injected) servers; name collisions keep both entries. - Write plane: add/update/removeGlobalMcpServer mutate the user-level file and push into live sessions; getGlobalMcpServer returns the effective config; mutations of read-only entries are rejected. - testGlobalMcpServer accepts an inline config; addSessionMcpServer connects a server in one live session with an optional persist flag; reconnect accepts a replacement config and re-resolves via the registry. - One process-wide McpOAuthService shared with every session: obtained_at stamps, offline token state, single-flight and proactive refresh, and credential events. Sessions self-subscribe in the constructor, so even initializing sessions see every event; token writes serialize through the process-local OAuthTokenTransaction per credential identity. - inspectAppMcpServers + locator-addressed begin/complete/cancel/reset cover plugin servers; inspection output redacts env/headers to sorted key lists; locator OAuth ops reject ambiguous shared runtime names. - The legacy auth-status surface reads the registry (offline by default, verify=true probes) and never mutates credentials. - VS Code panel receives source/origin/mutable and hides mutating actions on read-only entries. - v2 client facade in node-sdk mirrors the surface over agent-core-v2 (plugin inventory stays v1-only for now). * fix(agent-core): close the v1 MCP live-session reconciliation gaps Recompute each live session's MCP target from the registry's runtime resolution (enabled plugin > project layer > user file; caller injection shadows everything) behind every config mutation, instead of per-path patching: shadowed file layers recover when a plugin winner is disabled or removed, removing a user-level entry resurrects its project-layer shadow, disabled plugin descriptors no longer block removals, persisted session adds validate against the session's project layer and broadcast to other live sessions, and per-session sync failures are logged with context. Session status entries and read-only management entries now report redacted config views (envKeys/headerKeys instead of literal env/headers values); core-internal reconciliation compares full configs via the connection manager's raw-entry accessor. OAuth: interactive flows are serialized per credential (concurrent begins join the in-flight flow instead of clobbering its PKCE/state), a malformed credential meta sidecar no longer aborts core start, grants inside the refresh-ahead window refresh immediately while far-future grants re-arm through a max-length timer, and the service shuts its timers and flows down with KimiCore/SDKRpcClient close. * fix(agent-core): route the proactive MCP OAuth refresh through the token transaction refreshNow ran its /token request with the SDK default fetch, outside the credential-serializing OAuthTokenTransaction that every other token write uses; a slower response carrying an older rotating refresh token could overwrite a newer grant written by a concurrent transport-side refresh. * fix(agent-core): keep disabled MCP servers out of auth-state classification The unified mcpServerAuthState dropped the previous enabled short-circuit, so a disabled oauth-flagged server reported oauth-required — or was even probed over the network — instead of not-applicable. * fix(kimi-code-sdk): short-circuit disabled MCP servers in the v2 auth-status classifier The v2 parity copy of v1's mcpServerAuthState missed the same enabled guard v1 just regained; a disabled oauth-flagged entry would report oauth-required (or be probed). The parity suite now pins the disabled case on both engines. * fix(kimi-code): refresh the VS Code MCP list with the workspace cwd after mutations The add/update/remove RPCs return a cwd-less management list, so the webview broadcast dropped project-layer entries until the next full load; re-list with the workspace cwd after every mutation instead. * fix(agent-core): keep SDK token saves matched to the OAuth token transaction saveTokens stamped obtained_at onto a fresh object before calling tokenTransaction.save, so it never matched the exact payload the transaction recorded for a grant fetch; the consume path was dead and every save re-wrote. Between the fetch and the SDK callback an intervening clear could then be overwritten — the resurrected grant came back after a reset. The write callback stamps the durable record instead. * fix(agent-core): reject ambiguous legacy name-based MCP auth lookups The legacy begin/reset auth RPCs took the registry's first name match, silently starting OAuth for one entry of a runtime-name collision while the locator path refused the same ambiguity; align them on the shared uniqueness rule and point callers at the locator-addressed variants. * fix(agent-core): propagate registry errors during live-session MCP sync resolveMcpRuntimeTarget collapsed every registry failure into "no target": a project config file that turned malformed mid-session made sync treat a still-configured server as gone (tearing down the live connection) and made config-aware reconnects report "no longer configured" instead of the actionable config error. Absence still resolves to undefined; malformed config now propagates — per-session sync logs and keeps the entry, and reconnect surfaces config.invalid. * fix(agent-core): close the remaining registry-error and ambiguity gaps The management guard lookup mapped every registry failure to "absent", so a malformed project config let a persisted session add write a user-level entry over an unknown state; only not-found is a miss now. And the name-only connection test now shares the auth paths' uniqueness rule instead of probing the first match of a runtime-name collision. * fix(agent-core): probe the enabled MCP entry under a disabled-name collision The name-only connection test counted enabled matches for its ambiguity guard but still probed the first registry match, and the file layers list before plugins. With a disabled file entry shadowing an enabled plugin of the same runtime name, Test probed the disabled entry instead of the one a live session would run. Select the sole enabled match, falling back to the first entry only when every match is disabled so it reports as disabled. * fix(agent-core): let session-local MCP adds shadow plugin entries Caller injection shadows every registry source at session start, plugins included, and reconciliation leaves caller entries untouched; the live non-persist add path rejected plugin-owned names anyway, so SDK clients could not apply the same per-session override without a restart. Gate the plugin-source rejection on persist: session-local adds connect as caller, while persisted adds stay rejected as user-level writes behind a read-only owner. * fix(agent-core): normalize session MCP names before connecting The persisted store trims server names, but addSessionMcpServer used the raw name for the live connect and cross-session reconciliation: a padded name persisted under the trimmed key while the requesting session ran and reconciled the raw one, and a blank name connected with no identity at all. Normalize once up front (rejecting blank) so the store write, the session entry, and reconciliation agree on the same server. * fix(agent-core,node-sdk): close the collision-selection and probe-freshness gaps The legacy name-only auth resolver started from the first registry match, so a disabled file-layer shadow plus an enabled plugin of the same runtime name was misread as an ambiguity conflict; select the sole enabled match before judging ambiguity, exactly like the test probe path. On the v2 client, addSessionMcpServer connected the raw name while the store wrote the trimmed key — normalize once for both, and route the verify-triggered auth probes through the per-call OAuth service instead of the cached one whose providers snapshot tokens at construction, so a grant saved after the first probe is honored. * fix(agent-core): normalize global MCP mutation names and guard disabled reconnect swaps The global add/update/remove mutations guarded and reconciled with the raw server name while the store persisted the trimmed key, so a padded name left live sessions unreconciled and could slip past the plugin read-only guard; normalize once before lookup, persistence, and reconciliation. And a config-carrying reconnect assigned the replacement before the disabled check fired, leaving a connected entry that reported the disabled config; reject disabled replacements before mutating, keeping the same error. * fix(agent-core): skip proactive refresh while an interactive flow owns the credential refreshNow reset the shared provider's flow state before and after the token request; when a proactive timer (or a manual refresh) fired while beginAuthorization was waiting on the browser callback for the same store key, that wiped the redirect URL, PKCE verifier, and state the in-flight flow needed — complete() then failed the exchange even though the user authorized. Refresh now skips when an interactive flow is active for the credential: the flow delivers fresh tokens on completion, and the 401 transport path is the backstop if it fails. * fix(agent-core): allow global MCP adds over disabled plugin descriptors A disabled plugin entry is absent from the runtime target, but the read-only guard still treated it as the owner, so a user-level fallback could only exist if it predated the plugin disable. Relax the shared guard: disabled plugin descriptors never block mutations (disabled project entries still shadow the user file and keep their rejection). * fix(node-sdk): close the v2 session-MCP parity gaps A v2 reconnect with an explicit enabled:false replacement config used connect()'s upsert semantics — closing the live client and reporting success where v1's manager reconnect rejects before applying anything; reject disabled replacements up front with the same error. And a persisted v2 session add never consulted the workspace config, so a same-named project-layer entry was silently shadowed: the user-level write never takes effect while the direct workspace-manager upsert displaces the project config for every live session. Resolve the workspace layers and reject like v1's read-only rule. * fix(agent-core): keep __proto__-named MCP servers through config parsing A z.record() parse rebuilds its output via property assignment, so a server literally named __proto__ hit the prototype setter and vanished before validation; the layer merge then repeated the same trap with plain object accumulators. Parse the server map entry-by-entry over the JSON own keys and accumulate into null-prototype maps, so session startup and the unified registry keep the declared server and its origin. * fix(node-sdk): begin v2 MCP auth against a fresh OAuth service The v2 begin path ran through the cached globalMcpOAuth, whose providers snapshot tokens at construction: a grant another process saved (or reset) after that cache materialized was invisible, so begin could open a browser flow over a valid grant, or report already-authorized off a removed one. Build the service per call — the read path and the verify probes already do — and route the status list through the same helper. The test fixture grows a real token endpoint honoring one rotating refresh token; the regression fails against the cached-service implementation on v2. * fix(agent-core): broadcast SDK-driven MCP token invalidations to live sessions * test(agent-core-v2): give the no-op reconnect test runtime plumbing The branch added the case against a bare McpConnectionManager, but #2961 made stdio connects resolve the runtime through runtimeResolver, matching every other case in the file.
1016 lines
34 KiB
TypeScript
1016 lines
34 KiB
TypeScript
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'pathe';
|
|
|
|
import { testKaos } from '../fixtures/test-kaos';
|
|
import type { ProviderConfig, ToolCall } from '@moonshot-ai/kosong';
|
|
import type { Kaos, StatResult } from '@moonshot-ai/kaos';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { Agent, AgentOptions } from '../../src/agent';
|
|
import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector';
|
|
import type { KimiConfig } from '../../src/config';
|
|
import { FlagResolver } from '../../src/flags';
|
|
import { McpOAuthService, type McpOAuthEvent } from '../../src/mcp';
|
|
import { ProviderManager } from '../../src/session/provider-manager';
|
|
import type { ResolvedAgentProfile } from '../../src/profile';
|
|
import type { SDKSessionRPC } from '../../src/rpc';
|
|
import { Session } from '../../src/session';
|
|
import { SessionAPIImpl } from '../../src/session/rpc';
|
|
import { estimateTokensForMessages } from '../../src/utils/tokens';
|
|
import { createScriptedGenerate } from '../agent/harness/scripted-generate';
|
|
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
|
|
import { executeTool } from '../tools/fixtures/execute-tool';
|
|
import { createFakeKaos, toolContentString } from '../tools/fixtures/fake-kaos';
|
|
|
|
const MOCK_PROVIDER = {
|
|
type: 'kimi',
|
|
apiKey: 'test-key',
|
|
model: 'mock-model',
|
|
} as const satisfies ProviderConfig;
|
|
|
|
|
|
const here = import.meta.dirname;
|
|
const mcpStdioFixture = join(here, '..', 'mcp', 'fixtures', 'mock-stdio-server.mjs');
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('Session.init', () => {
|
|
it('subscribes to MCP credential events before app-level registration', async () => {
|
|
const mcpOAuth = new McpOAuthService({ kimiHomeDir: await makeTempDir() });
|
|
const session = new Session({
|
|
id: 'test-mcp-credentials-during-init',
|
|
kaos: testKaos.withCwd(await makeTempDir()),
|
|
homedir: await makeTempDir(),
|
|
rpc: createSessionRpc([]),
|
|
providerManager: testProviderManager(),
|
|
mcpOAuthService: mcpOAuth,
|
|
});
|
|
const handle = vi
|
|
.spyOn(
|
|
session as unknown as {
|
|
handleMcpOAuthEvent(event: McpOAuthEvent): Promise<void>;
|
|
},
|
|
'handleMcpOAuthEvent',
|
|
)
|
|
.mockResolvedValue(undefined);
|
|
|
|
await mcpOAuth.invalidate('remote', 'https://mcp.example.test/service', 'tokens');
|
|
expect(handle).toHaveBeenCalledOnce();
|
|
|
|
await session.close();
|
|
await mcpOAuth.invalidate('remote', 'https://mcp.example.test/service', 'tokens');
|
|
expect(handle).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('runs an isolated system-trigger turn and records the latest AGENTS as a system reminder', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
await mkdir(join(workDir, '.git'));
|
|
await writeFile(join(workDir, 'AGENTS.md'), 'latest project instructions', 'utf-8');
|
|
|
|
const events: Array<Record<string, unknown>> = [];
|
|
const scripted = createScriptedGenerate();
|
|
const session = new Session({
|
|
id: 'test-init',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc(events),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
const { agent: mainAgent } = await session.createAgent(
|
|
{ type: 'main', generate: scripted.generate },
|
|
{ profile: testProfile() },
|
|
);
|
|
mainAgent.config.update({
|
|
modelAlias: 'mock-model',
|
|
thinkingEffort: 'off',
|
|
});
|
|
mainAgent.tools.setActiveTools([]);
|
|
events.length = 0;
|
|
scripted.mockNextResponse({
|
|
type: 'text',
|
|
text: 'Explored the project structure, identified the build and test commands, mapped the module layout, and wrote a comprehensive summary into AGENTS.md covering architecture, conventions, and the developer workflow for future agents.',
|
|
});
|
|
|
|
await session.generateAgentsMd();
|
|
|
|
expect(session.agents.size).toBe(2);
|
|
expect(session.agents.get('main')).toBe(mainAgent);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'subagent.spawned',
|
|
agentId: 'main',
|
|
subagentId: 'agent-0',
|
|
subagentName: 'coder',
|
|
parentToolCallId: 'generate-agents-md',
|
|
}),
|
|
);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.started',
|
|
agentId: 'agent-0',
|
|
origin: { kind: 'system_trigger', name: 'subagent' },
|
|
}),
|
|
);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'subagent.completed',
|
|
agentId: 'main',
|
|
subagentId: 'agent-0',
|
|
contextTokens: expect.any(Number),
|
|
}),
|
|
);
|
|
expect(scripted.calls[0]?.history).toMatchObject([
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
expect.objectContaining({
|
|
text: expect.stringContaining('Task requirements:'),
|
|
}),
|
|
],
|
|
},
|
|
]);
|
|
|
|
const contextText = mainAgent.context.history
|
|
.flatMap((message) => message.content)
|
|
.map((part) => (part.type === 'text' ? part.text : ''))
|
|
.join('\n');
|
|
expect(contextText).toContain('<system-reminder>');
|
|
expect(contextText).toContain('Latest AGENTS.md file content:');
|
|
expect(contextText).toContain('latest project instructions');
|
|
expect(contextText).not.toContain('Task requirements:');
|
|
});
|
|
|
|
it('loads AGENTS.md via the persistence kaos when the tool kaos rejects readText (Zed ACP "Internal error" regression)', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
await mkdir(join(workDir, '.git'));
|
|
await writeFile(join(workDir, 'AGENTS.md'), 'project instructions from disk', 'utf-8');
|
|
|
|
// Simulate Zed's `fs/readTextFile` returning a generic -32603 Internal
|
|
// error: every `readText` through the tool kaos rejects. The persistence
|
|
// kaos is a real LocalKaos that can reach AGENTS.md on disk.
|
|
const toolKaos = wrapReadTextWithError(
|
|
testKaos.withCwd(workDir),
|
|
new Error('acp: readTextFile failed: Internal error'),
|
|
);
|
|
|
|
const capturedContext: { agentsMd: string | undefined } = { agentsMd: undefined };
|
|
const events: Array<Record<string, unknown>> = [];
|
|
const session = new Session({
|
|
id: 'test-bootstrap-acp-fallback',
|
|
kaos: toolKaos,
|
|
persistenceKaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc(events),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
try {
|
|
const { agent } = await session.createAgent(
|
|
{ type: 'main' },
|
|
{
|
|
profile: {
|
|
name: 'capture',
|
|
systemPrompt: (ctx) => {
|
|
capturedContext.agentsMd = ctx.agentsMd;
|
|
return '<system-prompt>';
|
|
},
|
|
tools: [],
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(agent.config.systemPrompt).toBe('<system-prompt>');
|
|
expect(capturedContext.agentsMd).toContain('project instructions from disk');
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('refreshes AGENTS.md from a resumed native session system prompt', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
await mkdir(join(workDir, '.git'));
|
|
await writeFile(join(workDir, 'AGENTS.md'), 'initial resume instructions', 'utf-8');
|
|
|
|
const firstSession = new Session({
|
|
id: 'test-resume-system-prompt-refresh',
|
|
kaos: testKaos.withCwd(workDir),
|
|
persistenceKaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
try {
|
|
const agent = await firstSession.createMain();
|
|
expect(agent.config.systemPrompt).toContain('initial resume instructions');
|
|
} finally {
|
|
await firstSession.closeForReload();
|
|
}
|
|
|
|
await writeFile(join(workDir, 'AGENTS.md'), 'updated resume instructions', 'utf-8');
|
|
|
|
const resumedSession = new Session({
|
|
id: 'test-resume-system-prompt-refresh',
|
|
kaos: testKaos.withCwd(workDir),
|
|
persistenceKaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
try {
|
|
await resumedSession.resume();
|
|
const resumedAgent = await resumedSession.ensureAgentResumed('main');
|
|
expect(resumedAgent.config.systemPrompt).toContain('initial resume instructions');
|
|
|
|
await resumedAgent.refreshSystemPrompt();
|
|
|
|
expect(resumedAgent.config.systemPrompt).toContain('updated resume instructions');
|
|
expect(resumedAgent.config.systemPrompt).not.toContain('initial resume instructions');
|
|
} finally {
|
|
await resumedSession.close();
|
|
}
|
|
});
|
|
|
|
it('resumes a v2 session from the standard path instead of persisted homedir', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
const options = {
|
|
id: 'test-resume-without-agent-homedir',
|
|
kaos: testKaos.withCwd(workDir),
|
|
persistenceKaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
};
|
|
const firstSession = new Session(options);
|
|
try {
|
|
await firstSession.createMain();
|
|
} finally {
|
|
await firstSession.closeForReload();
|
|
}
|
|
|
|
const statePath = join(sessionDir, 'state.json');
|
|
const now = Date.now();
|
|
await writeFile(statePath, JSON.stringify({
|
|
id: options.id,
|
|
version: 2,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
archived: false,
|
|
cwd: workDir,
|
|
agents: { main: { homedir: '/stale/session/agents/main', type: 'main' } },
|
|
custom: {},
|
|
}), 'utf-8');
|
|
|
|
const resumedSession = new Session(options);
|
|
try {
|
|
await resumedSession.resume();
|
|
const main = await resumedSession.ensureAgentResumed('main');
|
|
|
|
expect(main.homedir).toBe(join(sessionDir, 'agents', 'main'));
|
|
expect(main.config.systemPrompt).not.toBe('');
|
|
} finally {
|
|
await resumedSession.close();
|
|
}
|
|
});
|
|
|
|
it('rebuilds builtin tools when rebinding the session tool kaos', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
const staleKaos = createReadToolKaos(workDir, 'stale kaos\n');
|
|
const replacementKaos = createReadToolKaos(workDir, 'replacement kaos\n');
|
|
const session = new Session({
|
|
id: 'test-rebind-tool-kaos',
|
|
kaos: staleKaos,
|
|
persistenceKaos: testKaos.withCwd(sessionDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
|
|
try {
|
|
const { agent } = await session.createAgent({ type: 'main' }, { profile: testProfile() });
|
|
agent.config.update({
|
|
modelAlias: 'mock-model',
|
|
thinkingEffort: 'off',
|
|
});
|
|
agent.tools.initializeBuiltinTools();
|
|
agent.tools.setActiveTools(['Read']);
|
|
|
|
session.setToolKaos(replacementKaos);
|
|
|
|
const readTool = agent.tools.loopTools.find((candidate) => candidate.name === 'Read');
|
|
expect(readTool).toBeDefined();
|
|
const result = await executeTool(readTool!, {
|
|
args: { path: join(workDir, 'file.txt') },
|
|
turnId: '1',
|
|
toolCallId: 'call_read',
|
|
signal: new AbortController().signal,
|
|
});
|
|
|
|
expect(result.isError).not.toBe(true);
|
|
expect(toolContentString(result)).toContain('replacement kaos');
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('tracks connected and failed MCP server totals after initial load', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const session = new Session({
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
providerManager: testProviderManager(),
|
|
mcpConfig: {
|
|
servers: {
|
|
connected: {
|
|
transport: 'stdio',
|
|
command: process.execPath,
|
|
args: [mcpStdioFixture],
|
|
},
|
|
failed: {
|
|
transport: 'stdio',
|
|
command: '/this/path/does/not/exist/anywhere',
|
|
},
|
|
disabled: {
|
|
transport: 'stdio',
|
|
command: process.execPath,
|
|
args: [mcpStdioFixture],
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
telemetry: recordingTelemetry(records),
|
|
});
|
|
|
|
try {
|
|
await session.mcp.waitForInitialLoad();
|
|
await expect(new SessionAPIImpl(session).getMcpStartupMetrics({})).resolves.toEqual({
|
|
durationMs: expect.any(Number),
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'mcp_connected',
|
|
properties: {
|
|
server_count: 1,
|
|
total_count: 2,
|
|
},
|
|
});
|
|
expect(records).toContainEqual({
|
|
event: 'mcp_failed',
|
|
properties: {
|
|
failed_count: 1,
|
|
total_count: 2,
|
|
},
|
|
});
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
}, 20000);
|
|
});
|
|
|
|
describe('AgentAPI.startBtw', () => {
|
|
it('runs a side subagent from a stable parent context snapshot without writing btw history', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
|
|
const events: Array<Record<string, unknown>> = [];
|
|
const scripted = createScriptedGenerate();
|
|
const session = new Session({
|
|
id: 'test-btw',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc(events),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
const { agent: mainAgent } = await session.createAgent(
|
|
{ type: 'main', generate: scripted.generate },
|
|
{ profile: testProfile() },
|
|
);
|
|
mainAgent.config.update({
|
|
modelAlias: 'mock-model',
|
|
thinkingEffort: 'off',
|
|
});
|
|
mainAgent.tools.setActiveTools(['Read']);
|
|
registerLookupNoteTool(mainAgent);
|
|
mainAgent.context.appendUserMessage([{ type: 'text', text: 'Main task: implement /btw.' }]);
|
|
mainAgent.context.appendLoopEvent({
|
|
type: 'step.begin',
|
|
uuid: 'open-step',
|
|
turnId: 'main-turn',
|
|
step: 1,
|
|
});
|
|
mainAgent.context.appendLoopEvent({
|
|
type: 'tool.call',
|
|
uuid: 'open-call',
|
|
turnId: 'main-turn',
|
|
step: 1,
|
|
stepUuid: 'open-step',
|
|
toolCallId: 'call-open',
|
|
name: 'Read',
|
|
args: { path: 'src/main.ts' },
|
|
});
|
|
events.length = 0;
|
|
const summary = 'Main agent is implementing /btw.';
|
|
scripted.mockNextResponse({ type: 'text', text: summary });
|
|
|
|
try {
|
|
const api = new SessionAPIImpl(session);
|
|
const agentId = await api.startBtw({ agentId: 'main' });
|
|
expect(agentId).toBe('agent-0');
|
|
expect(scripted.calls).toHaveLength(0);
|
|
expect(session.metadata.agents[agentId]).toBeUndefined();
|
|
const childAgent = session.getReadyAgent(agentId);
|
|
if (childAgent === undefined) throw new Error('Expected /btw child agent');
|
|
const inheritedHistory = trimTrailingOpenToolExchange(
|
|
mainAgent.context.project(mainAgent.context.history),
|
|
);
|
|
expect(childAgent.context.history.slice(0, inheritedHistory.length)).toEqual(inheritedHistory);
|
|
expect(childAgent.context.tokenCount).toBe(0);
|
|
expect(childAgent.context.tokenCountWithPending).toBeGreaterThanOrEqual(
|
|
estimateTokensForMessages(inheritedHistory),
|
|
);
|
|
|
|
await api.prompt({
|
|
agentId,
|
|
input: [{ type: 'text', text: 'What are you working on right now?' }],
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.ended',
|
|
agentId: 'agent-0',
|
|
reason: 'completed',
|
|
}),
|
|
);
|
|
});
|
|
expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.started',
|
|
agentId: 'agent-0',
|
|
origin: { kind: 'user' },
|
|
}),
|
|
);
|
|
expect(scripted.calls).toHaveLength(1);
|
|
expect(scripted.calls[0]?.systemPrompt).toBe('<system-prompt>');
|
|
expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([
|
|
'LookupNote',
|
|
'Read',
|
|
]);
|
|
const historyText = JSON.stringify(scripted.calls[0]?.history);
|
|
expect(historyText).toContain('Main task: implement /btw.');
|
|
expect(historyText).toContain('This is a side-channel conversation with the user.');
|
|
expect(historyText).toContain('All tool calls are disabled and will be rejected.');
|
|
expect(historyText).toContain('What are you working on right now?');
|
|
expect(historyText).not.toContain('call-open');
|
|
expect(JSON.stringify(mainAgent.context.history)).not.toContain(
|
|
'What are you working on right now?',
|
|
);
|
|
expect(JSON.stringify(session.getReadyAgent('agent-0')?.context.history)).toContain(
|
|
'What are you working on right now?',
|
|
);
|
|
scripted.mockNextResponse({ type: 'text', text: 'Follow-up answer from the same side agent.' });
|
|
await api.prompt({
|
|
agentId,
|
|
input: [{ type: 'text', text: 'Can you say that another way?' }],
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(scripted.calls).toHaveLength(2);
|
|
});
|
|
const followUpHistoryText = JSON.stringify(scripted.calls[1]?.history);
|
|
expect(followUpHistoryText).toContain('What are you working on right now?');
|
|
expect(followUpHistoryText).toContain('Can you say that another way?');
|
|
await expect(access(join(sessionDir, 'agents', 'agent-0', 'wire.jsonl'))).rejects.toThrow();
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('declares parent tools but rejects side-question tool calls before a second text turn', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
|
|
const events: Array<Record<string, unknown>> = [];
|
|
const scripted = createScriptedGenerate();
|
|
const session = new Session({
|
|
id: 'test-btw-deny-tools',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc(events),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
const { agent: mainAgent } = await session.createAgent(
|
|
{ type: 'main', generate: scripted.generate },
|
|
{ profile: testProfile() },
|
|
);
|
|
mainAgent.config.update({
|
|
modelAlias: 'mock-model',
|
|
thinkingEffort: 'off',
|
|
});
|
|
mainAgent.tools.setActiveTools(['Read']);
|
|
registerLookupNoteTool(mainAgent);
|
|
mainAgent.context.appendUserMessage([{ type: 'text', text: 'Main task context.' }]);
|
|
events.length = 0;
|
|
|
|
scripted.mockNextResponse(lookupNoteCall());
|
|
scripted.mockNextResponse({
|
|
type: 'text',
|
|
text: 'Main agent is implementing /btw based on the existing context.',
|
|
});
|
|
|
|
try {
|
|
const api = new SessionAPIImpl(session);
|
|
const agentId = await api.startBtw({ agentId: 'main' });
|
|
expect(agentId).toBe('agent-0');
|
|
await api.prompt({
|
|
agentId,
|
|
input: [{ type: 'text', text: 'What are you working on right now?' }],
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.ended',
|
|
agentId: 'agent-0',
|
|
reason: 'completed',
|
|
}),
|
|
);
|
|
});
|
|
expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]);
|
|
expect(scripted.calls).toHaveLength(2);
|
|
expect(scripted.calls[0]?.systemPrompt).toBe('<system-prompt>');
|
|
expect(scripted.calls[1]?.systemPrompt).toBe('<system-prompt>');
|
|
expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([
|
|
'LookupNote',
|
|
'Read',
|
|
]);
|
|
expect(scripted.calls[1]?.tools.map((tool) => tool.name)).toEqual([
|
|
'LookupNote',
|
|
'Read',
|
|
]);
|
|
expect(JSON.stringify(scripted.calls[1]?.history)).toContain(
|
|
'Tool calls are disabled for side questions. Answer with text only.',
|
|
);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'tool.result',
|
|
agentId: 'agent-0',
|
|
toolCallId: 'call_lookup_note',
|
|
isError: true,
|
|
output: 'Tool calls are disabled for side questions. Answer with text only.',
|
|
}),
|
|
);
|
|
expect(JSON.stringify(mainAgent.context.history)).not.toContain(
|
|
'What are you working on right now?',
|
|
);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('cancels a btw turn through the returned agent id', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
|
|
const events: Array<Record<string, unknown>> = [];
|
|
const generate: NonNullable<AgentOptions['generate']> = vi.fn(
|
|
async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => {
|
|
const signal = options?.signal;
|
|
if (signal === undefined) {
|
|
throw new Error('Expected generate signal');
|
|
}
|
|
return new Promise<never>((_resolve, reject) => {
|
|
if (signal.aborted) {
|
|
reject(signal.reason);
|
|
return;
|
|
}
|
|
signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
reject(signal.reason);
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
},
|
|
);
|
|
const session = new Session({
|
|
id: 'test-btw-cancel',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc(events),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
});
|
|
const { agent: mainAgent } = await session.createAgent(
|
|
{ type: 'main', generate },
|
|
{ profile: testProfile() },
|
|
);
|
|
mainAgent.config.update({
|
|
modelAlias: 'mock-model',
|
|
thinkingEffort: 'off',
|
|
});
|
|
events.length = 0;
|
|
|
|
try {
|
|
const api = new SessionAPIImpl(session);
|
|
const agentId = await api.startBtw({ agentId: 'main' });
|
|
expect(agentId).toBe('agent-0');
|
|
await api.prompt({
|
|
agentId,
|
|
input: [{ type: 'text', text: 'Where are things right now?' }],
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.started',
|
|
agentId: 'agent-0',
|
|
origin: { kind: 'user' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
await api.cancel({ agentId });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: 'turn.ended',
|
|
agentId: 'agent-0',
|
|
reason: 'cancelled',
|
|
}),
|
|
);
|
|
});
|
|
expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('discovers sub-skills and builtins', async () => {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
const skillsRoot = join(workDir, 'skills');
|
|
await mkdir(join(skillsRoot, 'outer', 'inner'), { recursive: true });
|
|
await writeFile(
|
|
join(skillsRoot, 'outer', 'SKILL.md'),
|
|
[
|
|
'---',
|
|
'name: outer',
|
|
'description: Parent skill',
|
|
'has-sub-skill: true',
|
|
'---',
|
|
'',
|
|
'Outer body.',
|
|
].join('\n'),
|
|
);
|
|
await writeFile(
|
|
join(skillsRoot, 'outer', 'inner', 'SKILL.md'),
|
|
['---', 'name: inner', 'description: Nested skill', '---', '', 'Inner body.'].join('\n'),
|
|
);
|
|
|
|
const disabledSession = new Session({
|
|
id: 'test-disabled-sub-skills',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [skillsRoot] },
|
|
});
|
|
|
|
try {
|
|
const disabledSkills = await disabledSession.listSkills();
|
|
expect(disabledSkills.map((skill) => skill.name)).toContain('outer');
|
|
expect(disabledSkills.map((skill) => skill.name)).toContain('outer.inner');
|
|
expect(disabledSkills.map((skill) => skill.name)).toContain('sub-skill.consolidate');
|
|
} finally {
|
|
await disabledSession.close();
|
|
}
|
|
|
|
const enabledSession = new Session({
|
|
id: 'test-enabled-sub-skills',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [skillsRoot] },
|
|
});
|
|
|
|
try {
|
|
const enabledSkills = await enabledSession.listSkills();
|
|
expect(enabledSkills.map((skill) => skill.name)).toContain('outer');
|
|
expect(enabledSkills.map((skill) => skill.name)).toContain('outer.inner');
|
|
expect(enabledSkills.map((skill) => skill.name)).toContain('sub-skill.consolidate');
|
|
} finally {
|
|
await enabledSession.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Session secondary-model live config', () => {
|
|
const SECONDARY_BASE_CONFIG: KimiConfig = {
|
|
providers: {
|
|
test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey },
|
|
},
|
|
models: {
|
|
[MOCK_PROVIDER.model]: {
|
|
provider: 'test',
|
|
model: MOCK_PROVIDER.model,
|
|
maxContextSize: 1_000_000,
|
|
},
|
|
},
|
|
};
|
|
const SECONDARY_POINTER_CONFIG: KimiConfig = {
|
|
...SECONDARY_BASE_CONFIG,
|
|
secondaryModel: { model: MOCK_PROVIDER.model },
|
|
};
|
|
const SECONDARY_PATCHED_CONFIG: KimiConfig = {
|
|
...SECONDARY_BASE_CONFIG,
|
|
models: {
|
|
...SECONDARY_BASE_CONFIG.models,
|
|
__secondary__: {
|
|
...SECONDARY_BASE_CONFIG.models![MOCK_PROVIDER.model]!,
|
|
overrides: { defaultEffort: 'low' },
|
|
},
|
|
},
|
|
secondaryModel: { model: MOCK_PROVIDER.model, defaultEffort: 'low' },
|
|
};
|
|
|
|
async function makeSession(config?: KimiConfig): Promise<Session> {
|
|
const workDir = await makeTempDir();
|
|
const sessionDir = await makeTempDir();
|
|
return new Session({
|
|
id: 'test-secondary-model',
|
|
kaos: testKaos.withCwd(workDir),
|
|
homedir: sessionDir,
|
|
rpc: createSessionRpc([]),
|
|
skills: { explicitDirs: [join(workDir, 'missing-skills')] },
|
|
providerManager: testProviderManager(),
|
|
experimentalFlags: new FlagResolver({
|
|
KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL: '1',
|
|
}),
|
|
config,
|
|
});
|
|
}
|
|
|
|
it('live-applies the recipe to the session snapshot and live agents', async () => {
|
|
const session = await makeSession(SECONDARY_BASE_CONFIG);
|
|
try {
|
|
const { agent } = await session.createAgent(
|
|
{ type: 'main', generate: createScriptedGenerate().generate },
|
|
{ profile: testProfile() },
|
|
);
|
|
|
|
session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG);
|
|
|
|
expect(session.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model });
|
|
expect(agent.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model });
|
|
// A pointer-only recipe synthesizes no derived entry.
|
|
expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined();
|
|
|
|
// Agents created after the switch read the updated snapshot too.
|
|
const { agent: second } = await session.createAgent(
|
|
{ type: 'sub', generate: createScriptedGenerate().generate },
|
|
{ profile: testProfile(), parentAgentId: 'main' },
|
|
);
|
|
expect(second.kimiConfig?.secondaryModel).toEqual({ model: MOCK_PROVIDER.model });
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('refreshes collaboration tool descriptions when live-applying a recipe', async () => {
|
|
const session = await makeSession(SECONDARY_BASE_CONFIG);
|
|
try {
|
|
const { agent } = await session.createAgent(
|
|
{ type: 'main', generate: createScriptedGenerate().generate },
|
|
{ profile: { ...testProfile(), tools: ['Agent', 'AgentSwarm'] } },
|
|
);
|
|
agent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' });
|
|
|
|
const descriptionsBefore = Object.fromEntries(
|
|
agent.tools.loopTools.map((tool) => [tool.name, tool.description]),
|
|
);
|
|
expect(descriptionsBefore['Agent']).not.toContain('Available models');
|
|
expect(descriptionsBefore['AgentSwarm']).not.toContain('Available models');
|
|
|
|
session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG);
|
|
|
|
const descriptionsAfter = Object.fromEntries(
|
|
agent.tools.loopTools.map((tool) => [tool.name, tool.description]),
|
|
);
|
|
expect(descriptionsAfter['Agent']).toContain('- secondary: mock-model');
|
|
expect(descriptionsAfter['AgentSwarm']).toContain('- secondary: mock-model');
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('replaces a patched runtime snapshot with a pointer-only runtime snapshot', async () => {
|
|
const session = await makeSession(SECONDARY_BASE_CONFIG);
|
|
try {
|
|
session.setSecondaryModelConfig(SECONDARY_PATCHED_CONFIG);
|
|
const derived = session.kimiConfig?.models?.['__secondary__'];
|
|
expect(derived).toBeDefined();
|
|
expect(derived?.overrides?.defaultEffort).toBe('low');
|
|
|
|
session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG);
|
|
expect(session.kimiConfig?.models?.['__secondary__']).toBeUndefined();
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('keeps unrelated session settings when applying a secondary-model config', async () => {
|
|
const session = await makeSession({
|
|
...SECONDARY_BASE_CONFIG,
|
|
loopControl: { maxStepsPerTurn: 7 },
|
|
});
|
|
try {
|
|
session.setSecondaryModelConfig({
|
|
...SECONDARY_POINTER_CONFIG,
|
|
loopControl: { maxStepsPerTurn: 99 },
|
|
});
|
|
|
|
expect(session.kimiConfig?.loopControl?.maxStepsPerTurn).toBe(7);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('rejects a dangling pointer with the wrapped secondary-model error', async () => {
|
|
const session = await makeSession(SECONDARY_BASE_CONFIG);
|
|
try {
|
|
expect(() =>
|
|
session.setSecondaryModelConfig({
|
|
...SECONDARY_BASE_CONFIG,
|
|
secondaryModel: { model: 'missing-model' },
|
|
}),
|
|
).toThrow(/\[secondary_model\]\.model/);
|
|
expect(session.kimiConfig?.secondaryModel).toBeUndefined();
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('rejects when the complete config has no persisted secondary recipe', async () => {
|
|
const session = await makeSession(SECONDARY_BASE_CONFIG);
|
|
try {
|
|
expect(() => session.setSecondaryModelConfig(SECONDARY_BASE_CONFIG)).toThrow(/persist/);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
it('rejects when the session has no config', async () => {
|
|
const session = await makeSession();
|
|
try {
|
|
expect(() => session.setSecondaryModelConfig(SECONDARY_POINTER_CONFIG)).toThrow(/no config/);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function makeTempDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'kimi-core-init-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function testProviderManager(): ProviderManager {
|
|
return new ProviderManager({
|
|
config: {
|
|
providers: {
|
|
test: {
|
|
type: MOCK_PROVIDER.type,
|
|
apiKey: MOCK_PROVIDER.apiKey,
|
|
},
|
|
},
|
|
models: {
|
|
[MOCK_PROVIDER.model]: {
|
|
provider: 'test',
|
|
model: MOCK_PROVIDER.model,
|
|
maxContextSize: 1_000_000,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function testProfile(): ResolvedAgentProfile {
|
|
return {
|
|
name: 'test',
|
|
systemPrompt: () => '<system-prompt>',
|
|
tools: [],
|
|
};
|
|
}
|
|
|
|
function createReadToolKaos(cwd: string, content: string): Kaos {
|
|
return createFakeKaos({
|
|
getcwd: () => cwd,
|
|
stat: async () =>
|
|
({
|
|
stMode: 0o100644,
|
|
stIno: 1,
|
|
stDev: 1,
|
|
stNlink: 1,
|
|
stUid: 0,
|
|
stGid: 0,
|
|
stSize: content.length,
|
|
stAtime: 0,
|
|
stMtime: 0,
|
|
stCtime: 0,
|
|
}) satisfies StatResult,
|
|
readBytes: async () => Buffer.from(content),
|
|
readLines: async function* () {
|
|
yield content;
|
|
},
|
|
});
|
|
}
|
|
|
|
function registerLookupNoteTool(agent: Agent): void {
|
|
agent.tools.registerUserTool({
|
|
name: 'LookupNote',
|
|
description: 'Look up a note from the host application.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string' },
|
|
},
|
|
required: ['query'],
|
|
additionalProperties: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
function lookupNoteCall(): ToolCall {
|
|
return {
|
|
type: 'function',
|
|
id: 'call_lookup_note',
|
|
name: 'LookupNote',
|
|
arguments: JSON.stringify({ query: 'status' }),
|
|
};
|
|
}
|
|
|
|
function createSessionRpc(events: Array<Record<string, unknown>>): SDKSessionRPC {
|
|
return {
|
|
emitEvent: vi.fn(async (event) => {
|
|
events.push(event);
|
|
}),
|
|
requestApproval: vi.fn(async () => ({ decision: 'cancelled' })),
|
|
requestQuestion: vi.fn(async () => null),
|
|
toolCall: vi.fn(async () => ({
|
|
output: 'custom tools are not supported in this test',
|
|
isError: true,
|
|
})),
|
|
} as SDKSessionRPC;
|
|
}
|
|
|
|
/**
|
|
* Wrap a {@link Kaos} so every `readText` (and `readLines`, which reads via
|
|
* `readText` in the ACP bridge) rejects with `cause`. Used to simulate the
|
|
* Zed ACP `fs/readTextFile` "Internal error" path that broke session bootstrap
|
|
* before AGENTS.md loading was rerouted onto the persistence kaos.
|
|
*/
|
|
function wrapReadTextWithError(inner: Kaos, cause: Error): Kaos {
|
|
return new Proxy(inner, {
|
|
get(target, prop, receiver) {
|
|
if (prop === 'readText') {
|
|
return async () => {
|
|
throw cause;
|
|
};
|
|
}
|
|
if (prop === 'readLines') {
|
|
return async function* () {
|
|
yield* [];
|
|
throw cause;
|
|
};
|
|
}
|
|
if (prop === 'withCwd') {
|
|
return (cwd: string) => wrapReadTextWithError(target.withCwd(cwd), cause);
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
}
|