kimi-code/packages/acp-server/test/lifecycle.test.ts
Haozhe 84da6629b1
refactor(agent-core-v2): decouple workspace from session DI via runtime binding (#2961)
* refactor(agent-core-v2): decouple workspace from session DI via runtime binding

* fix(agent-core-v2): unblock session external hooks and scope workspaceMcp seeds

- externalHooksService: inject App-level ISessionManager instead of the
  unregistered ISessionLifecycleService so SessionStart/SessionEnd hooks
  actually activate in production; keep sessionId matching and tolerate
  absent lifecycle events
- workspaceMcpService: ignore onWillCreateSession events whose session
  belongs to another workspace, preventing cross-workspace
  ISessionMcpHandle seed overrides
- update externalHooks integration tests, agent harness, and workspaceMcp
  tests; add reloadSources coverage in skillCatalog tests

* fix(agent-core-v2): honor the bound runtime in prompt context, swarm spawn, and ACP sessions

- map system-prompt cwd, directory listing, and additional dirs through
  RuntimeWorkspaceView, and skip the listing when the bound runtime has
  no fs capability
- pass the caller agent's runtime binding to AgentSwarm child creation
  and prompt-prefix execution instead of hardcoding local
- expose the ACP client filesystem through the ACP session runtime and
  build its shell/path environment from the probed host instead of
  hardcoded Linux
- dispatch klient facade createChild to sessionManager.createChild so
  child sessions keep their parent markers

* fix(agent-core-v2): resolve routed fs and tool paths with runtime path semantics

- WorkspaceFsService resolves via the bound runtime's RuntimePath (extended with basename/dirname) instead of node:path, so mapped roots such as C:\\repo stay runtime-local.
- Read/Write/Glob/Grep pass skill roots through mapRoots via RuntimeWorkspaceView input, matching Edit.
- acp-server unbinds session runtimes on session/close, not only on delete.
- apps/kimi-code drops the /runtime slash command; SDK runtime methods stay.

* fix(agent-core-v2): retire idle session controllers, untrack disposed runtime resources, and rebuild fs watches on generation replace

* fix(agent-core-v2): resolve oxlint errors in runtime lifecycle fixes

* fix(kap-server): untrack download stream from runtime generation on completion

* fix(kap-server): drop meaningless void operator on tracked dispose
2026-08-16 20:24:12 +08:00

501 lines
18 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
IOAuthToolkit,
ISessionManager,
ISessionMcpHandle,
IWorkspaceInstanceManager,
} from '@moonshot-ai/agent-core-v2';
import { afterEach, describe, expect, it } from 'vitest';
import type { SessionSummary } from '@moonshot-ai/klient';
import { filterSessionSummariesByCwd } from '../src/server';
import { createTestClient, type TestClient } from './_helpers/acpClient';
import { writeFakeModelConfig } from './_helpers/fakeModelConfig';
import { createScriptedProvider } from './_helpers/scriptedProvider';
/** Real stdio MCP fixture server from the agent-core-v2 test suite. */
const STDIO_MCP_FIXTURE = fileURLToPath(
new URL('../../agent-core-v2/test/mcpCore/fixtures/mock-stdio-server.mjs', import.meta.url),
);
/**
* config.toml declaring one OAuth provider so `auth.summarize()` considers it;
* the token itself lives in the seeded fake `IOAuthToolkit`.
*/
const OAUTH_PROVIDER_CONFIG = `[providers.test-oauth]
type = "kimi"
baseUrl = "http://localhost"
[providers.test-oauth.oauth]
storage = "file"
key = "test-key"
`;
/**
* In-memory `IOAuthToolkit` stub: starts logged in, `logout()` clears the
* token. Seeded at App scope so the real `OAuthService` / `AuthSummaryService`
* chain runs against it.
*/
function createFakeOAuthToolkit(): {
readonly seed: readonly [typeof IOAuthToolkit, IOAuthToolkit];
hasToken(): boolean;
} {
let token: string | undefined = 'fake-token';
const fake = {
login: () => Promise.reject(new Error('fakeOAuthToolkit: login not implemented')),
logout: (providerName?: string) => {
token = undefined;
return Promise.resolve({ providerName: providerName ?? 'test-oauth' });
},
getCachedAccessToken: () => Promise.resolve(token),
tokenProvider: () => {
throw new Error('fakeOAuthToolkit: tokenProvider not implemented');
},
getManagedUsage: () => Promise.reject(new Error('fakeOAuthToolkit: not implemented')),
getManagedUserInfo: () => Promise.reject(new Error('fakeOAuthToolkit: not implemented')),
} as unknown as IOAuthToolkit;
return { seed: [IOAuthToolkit, fake], hasToken: () => token !== undefined };
}
describe('acp-server session lifecycle', () => {
let homeDir: string | undefined;
let client: TestClient | undefined;
afterEach(async () => {
if (client !== undefined) {
await client.close();
client = undefined;
}
if (homeDir !== undefined) {
await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
homeDir = undefined;
}
});
async function boot(): Promise<TestClient> {
homeDir = await mkdtemp(join(tmpdir(), 'acp-lifecycle-'));
client = await createTestClient({ homeDir });
await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} });
return client;
}
/**
* Read the session scope's MCP entries engine-side: workspace handler →
* session lifecycle → the session's MCP handle (the overlay view when the
* session was created/loaded with ephemeral `mcpServers`).
*/
async function sessionMcpEntries(
c: TestClient,
sessionId: string,
): Promise<readonly { readonly name: string; readonly status: string; readonly error?: string }[]> {
await c.server.core.accessor
.get(IWorkspaceInstanceManager)
.getOrCreate({ root: homeDir! });
const handle = c.server.core.accessor.get(ISessionManager).get(sessionId);
expect(handle).toBeDefined();
const mcp = handle!.accessor.get(ISessionMcpHandle);
await mcp.ready;
return mcp.connectionManager.list();
}
it(
'session/new creates a live session and session/list returns it',
async () => {
const c = await boot();
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
expect(created.sessionId).toMatch(/^session_/);
const listed = (await c.send('session/list', {})) as {
sessions: { sessionId: string }[];
};
expect(listed.sessions.some((s) => s.sessionId === created.sessionId)).toBe(true);
},
30_000,
);
it(
'session/resume on an unknown sessionId fails with invalid_params',
async () => {
const c = await boot();
await expect(
c.send('session/resume', { sessionId: 'does-not-exist', cwd: homeDir, mcpServers: [] }),
).rejects.toThrow();
},
30_000,
);
it(
'session/load replays (empty) history and returns configOptions',
async () => {
const c = await boot();
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
// Drain the available_commands_update pushed after new so the load
// replay assertion only sees load-time notifications.
await c.waitForSessionUpdate('available_commands_update', 10_000);
const before = c.sessionUpdates().length;
const loaded = (await c.send('session/load', {
sessionId: created.sessionId,
cwd: homeDir,
mcpServers: [],
})) as { configOptions?: unknown[] };
expect(Array.isArray(loaded.configOptions)).toBe(true);
// A brand-new session has no persisted history, so load must not emit
// any user/agent/tool replay chunks (only the post-load commands push).
const replayed = c
.sessionUpdates()
.slice(before)
.map((m) => (m.params as { update?: { sessionUpdate?: string } }).update?.sessionUpdate)
.filter((k) => k !== 'available_commands_update');
expect(replayed).toEqual([]);
},
30_000,
);
it(
'session/fork on an unknown sessionId fails with invalid_params',
async () => {
const c = await boot();
await expect(
c.send('session/fork', { sessionId: 'does-not-exist', cwd: homeDir, mcpServers: [] }),
).rejects.toThrow(/-32602/);
},
30_000,
);
it(
'session/fork creates an independently promptable session carrying the source history',
async () => {
homeDir = await mkdtemp(join(tmpdir(), 'acp-fork-'));
await writeFakeModelConfig(homeDir);
const scripted = createScriptedProvider();
client = await createTestClient({ homeDir, extraSeeds: [scripted.seed] });
const c = client;
await c.send('initialize', { protocolVersion: 1, clientCapabilities: {} });
// One real turn on the source session so the fork has history to carry.
scripted.mockNextText('first turn reply');
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
await c.waitForSessionUpdate('available_commands_update', 10_000);
const sourceTurn = (await c.send('session/prompt', {
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'hello from the source session' }],
})) as { stopReason: string };
expect(sourceTurn.stopReason).toBe('end_turn');
const forked = (await c.send('session/fork', {
sessionId: created.sessionId,
cwd: homeDir,
mcpServers: [],
})) as { sessionId: string; configOptions?: unknown[]; modes?: unknown };
expect(forked.sessionId).toMatch(/^session_/);
expect(forked.sessionId).not.toBe(created.sessionId);
// Same response surface as session/new.
expect(Array.isArray(forked.configOptions)).toBe(true);
expect(forked.modes).toBeDefined();
// Both the source and the fork are listed.
const listed = (await c.send('session/list', {})) as {
sessions: { sessionId: string }[];
};
const ids = listed.sessions.map((s) => s.sessionId);
expect(ids).toContain(created.sessionId);
expect(ids).toContain(forked.sessionId);
// The fork is wired for prompts like a session/new session.
scripted.mockNextText('fork reply');
const forkTurn = (await c.send('session/prompt', {
sessionId: forked.sessionId,
prompt: [{ type: 'text', text: 'hello from the fork' }],
})) as { stopReason: string };
expect(forkTurn.stopReason).toBe('end_turn');
// History carried over: loading the fork replays the source turn (and
// the fork's own turn).
const before = c.sessionUpdates().length;
await c.send('session/load', {
sessionId: forked.sessionId,
cwd: homeDir,
mcpServers: [],
});
const replayed = c
.sessionUpdates()
.slice(before)
.map(
(m) =>
(m.params as { update?: { sessionUpdate?: string; content?: { text?: string } } })
.update,
);
const userChunks = replayed
.filter((u) => u?.sessionUpdate === 'user_message_chunk')
.map((u) => u?.content?.text);
const agentChunks = replayed
.filter((u) => u?.sessionUpdate === 'agent_message_chunk')
.map((u) => u?.content?.text);
expect(userChunks).toContain('hello from the source session');
expect(userChunks).toContain('hello from the fork');
expect(agentChunks).toContain('first turn reply');
expect(agentChunks).toContain('fork reply');
},
30_000,
);
it(
'session/delete removes the session and a second delete reports invalid_params',
async () => {
const c = await boot();
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
const deleted = await c.send('session/delete', { sessionId: created.sessionId });
expect(deleted).toEqual({});
// The local ACP session state is torn down with the engine session:
// prompting the deleted id now hits the unknown-session branch.
await expect(
c.send('session/prompt', { sessionId: created.sessionId, prompt: [] }),
).rejects.toThrow(/-32602/);
const listed = (await c.send('session/list', {})) as {
sessions: { sessionId: string }[];
};
expect(listed.sessions.some((s) => s.sessionId === created.sessionId)).toBe(false);
await expect(
c.send('session/delete', { sessionId: created.sessionId }),
).rejects.toThrow(/-32602/);
},
30_000,
);
it(
'session/delete on an unknown sessionId fails with invalid_params',
async () => {
const c = await boot();
await expect(c.send('session/delete', { sessionId: 'does-not-exist' })).rejects.toThrow(
/-32602/,
);
},
30_000,
);
it(
'session/new rejects stdio MCP servers without runtime identity',
async () => {
const c = await boot();
await expect(c.send('session/new', {
cwd: homeDir,
mcpServers: [
{
name: 'mock',
command: process.execPath,
args: [STDIO_MCP_FIXTURE],
env: [{ name: 'KIMI_TEST_MCP_START_DELAY_MS', value: '0' }],
},
],
})).rejects.toThrow('ACP stdio MCP server mock does not declare a runtime identity');
// Engine-side assertion: the session scope's MCP handle is the overlay
// view and the converted server ended up connected under its ACP name.
},
30_000,
);
it(
'session/load rejects stdio MCP servers without runtime identity',
async () => {
const c = await boot();
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
await c.send('session/close', { sessionId: created.sessionId });
await expect(c.send('session/load', {
sessionId: created.sessionId,
cwd: homeDir,
mcpServers: [
{ name: 'mock', command: process.execPath, args: [STDIO_MCP_FIXTURE], env: [] },
],
})).rejects.toThrow('ACP stdio MCP server mock does not declare a runtime identity');
},
30_000,
);
it(
'session/new forwards additionalDirectories to the engine workspace dirs',
async () => {
const c = await boot();
const extraDir = join(homeDir!, 'extra-root');
await mkdir(extraDir, { recursive: true });
const created = (await c.send('session/new', {
cwd: homeDir,
mcpServers: [],
additionalDirectories: [extraDir],
})) as { sessionId: string };
expect(created.sessionId).toMatch(/^session_/);
// The workspace handler merges create-time dirs into its
// (ephemeral) additional-dir set.
const workspace = await c.server.core.accessor
.get(IWorkspaceInstanceManager)
.getOrCreate({ root: homeDir! });
const dirs = workspace.program.dirs;
await dirs.ready;
expect(dirs.additionalDirs).toContain(extraDir);
},
30_000,
);
it(
'a title change pushes session_info_update',
async () => {
const c = await boot();
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
await c.waitForSessionUpdate('available_commands_update', 10_000);
// Retitle through the engine (the same klient the server drives) — the
// metadata.changed event must surface as session_info_update.
await c.server.klient.session(created.sessionId).setTitle('Renamed Session');
const notification = await c.waitForSessionUpdate('session_info_update', 10_000);
const update = (notification.params as { update?: { title?: string | null } }).update;
expect(update?.title).toBe('Renamed Session');
},
30_000,
);
it(
'logout drops the token and the auth gate closes again',
async () => {
homeDir = await mkdtemp(join(tmpdir(), 'acp-logout-'));
await writeFile(join(homeDir, 'config.toml'), OAUTH_PROVIDER_CONFIG, 'utf8');
const toolkit = createFakeOAuthToolkit();
client = await createTestClient({
homeDir,
disableAuth: false,
extraSeeds: [toolkit.seed],
});
const c = client;
await c.send('initialize', { protocolVersion: 1, clientCapabilities: {} });
// Provider hydration from config.toml is async (kosongConfig initialize
// → providerService.loadAll); wait until summarize sees the fake token.
await expect
.poll(
async () => (await c.server.klient.global.auth.summarize()).some((s) => s.loggedIn),
{ timeout: 10_000 },
)
.toBe(true);
// Logged in (fake token present): the gate lets session/new through.
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
expect(created.sessionId).toMatch(/^session_/);
await c.send('logout', {});
expect(toolkit.hasToken()).toBe(false);
// Logged out: the summarize-driven gate rejects with auth_required.
await expect
.poll(
async () => (await c.server.klient.global.auth.summarize()).some((s) => s.loggedIn),
{ timeout: 10_000 },
)
.toBe(false);
await expect(c.send('session/new', { cwd: homeDir, mcpServers: [] })).rejects.toThrow(
/[Aa]uthentication required/,
);
},
30_000,
);
it(
'apiKey-only config passes the auth gate without any OAuth provider',
async () => {
// The flat fake-model config carries an inline apiKey and no OAuth
// provider at all: the engine's readiness probe (not the OAuth-only
// summary) must let session/new through.
homeDir = await mkdtemp(join(tmpdir(), 'acp-apikey-gate-'));
await writeFakeModelConfig(homeDir);
client = await createTestClient({ homeDir, disableAuth: false });
const c = client;
await c.send('initialize', { protocolVersion: 1, clientCapabilities: {} });
const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
expect(created.sessionId).toMatch(/^session_/);
},
30_000,
);
it(
'session/list filters by cwd when the client supplies one',
async () => {
const c = await boot();
const otherDir = join(homeDir!, 'other-root');
await mkdir(otherDir, { recursive: true });
const first = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as {
sessionId: string;
};
const second = (await c.send('session/new', { cwd: otherDir, mcpServers: [] })) as {
sessionId: string;
};
const filtered = (await c.send('session/list', { cwd: homeDir })) as {
sessions: { sessionId: string; cwd: string }[];
};
const ids = filtered.sessions.map((s) => s.sessionId);
expect(ids).toContain(first.sessionId);
expect(ids).not.toContain(second.sessionId);
// No cwd → no filter: both sessions are listed.
const unfiltered = (await c.send('session/list', {})) as {
sessions: { sessionId: string }[];
};
const allIds = unfiltered.sessions.map((s) => s.sessionId);
expect(allIds).toContain(first.sessionId);
expect(allIds).toContain(second.sessionId);
},
30_000,
);
});
describe('filterSessionSummariesByCwd', () => {
const summary = (id: string, cwd?: string): SessionSummary => ({
id,
workspaceId: `ws-${id}`,
cwd,
createdAt: 1,
updatedAt: 1,
archived: false,
});
it('returns every session when no cwd filter is supplied', () => {
const items = [summary('a', '/x'), summary('b'), summary('c', '/y')];
expect(filterSessionSummariesByCwd(items, undefined)).toBe(items);
});
it('keeps cwd-less legacy sessions under an explicit filter', () => {
const items = [summary('a', '/x'), summary('b'), summary('c', '/y')];
// 'b' has no cwd metadata: its workspace is unknown, not known-different,
// so an explicit filter must not silently drop it.
expect(filterSessionSummariesByCwd(items, '/x').map((s) => s.id)).toEqual(['a', 'b']);
expect(filterSessionSummariesByCwd(items, '/y').map((s) => s.id)).toEqual(['b', 'c']);
});
});