kimi-code/packages/agent-core-v2/test/program/program.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

234 lines
8.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { Program } from '#/program/program';
import type { ProgramSessionControllerInput } from '#/program/programDependencies';
import { FakeRuntime } from '#/runtime/fakeRuntime';
import type { RuntimeStatus } from '#/runtime/runtime';
import { RuntimeRegistry } from '#/runtime/runtimeRegistry';
function runtime(generation: string, status: RuntimeStatus = 'ready'): FakeRuntime {
return Object.assign(
new FakeRuntime(
{ workspaceId: 'workspace', runtimeId: 'local', generation },
{ status, capabilities: ['fs', 'process', 'watch'] },
),
{ fs: {}, process: {}, watch: {} },
) as FakeRuntime;
}
function deferred(): { readonly promise: Promise<void>; resolve(): void; reject(error: Error): void } {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function setup(readiness = new Map<string, Promise<void>>(), order: string[] = []) {
const registry = new RuntimeRegistry('workspace', 50);
const program = new Program(
'workspace',
registry,
{
_serviceBrand: undefined,
workspaceId: 'workspace',
cwd: '/workspace',
source: 'local',
meta: {
id: 'workspace',
name: 'workspace',
root: '/workspace',
createdAt: 0,
lastOpenedAt: 0,
},
persistenceScope: 'sessions/workspace',
},
{
agentProfiles: { entries: () => [] },
createSessionController: (input: ProgramSessionControllerInput) => ({ dispose: input.onDispose }) as never,
} as never,
);
const create = vi.fn(() => {
const lease = registry.acquire(program.binding, ['fs', 'process', 'watch']);
const id = lease.runtime.identity.generation;
const behavior = {
ready: readiness.get(id) ?? Promise.resolve(),
dispose: () => { order.push(`behavior:${id}`); },
};
const catalog = {
listSkills: () => [],
listInvocableSkills: () => [],
getSkippedByPolicy: () => [],
getSkillRoots: () => [],
};
return {
id,
lease,
state: behavior,
dirs: behavior,
fs: behavior,
watch: behavior,
git: behavior,
instructions: { ...behavior, snapshot: {} },
mcpConfig: { ...behavior, servers: () => ({}) },
mcp: behavior,
trust: { ...behavior, isTrusted: () => false },
skills: { ...behavior, catalog },
agentProfiles: behavior,
userAgentProfiles: behavior,
pluginAgentProfiles: behavior,
explicitAgentProfiles: behavior,
extraAgentProfiles: behavior,
disposables: [behavior],
ready: false,
failed: false,
references: 1,
retired: false,
};
});
(program as unknown as { createGeneration: typeof create }).createGeneration = create;
return { registry, program, create };
}
describe('Program', () => {
it('acquires only available local generations and recovers after reconnect', async () => {
const { registry, program, create } = setup();
const current = runtime('one', 'disconnected');
registry.register(current);
expect(create).toHaveBeenCalledTimes(1);
expect(program.status).toBe('degraded');
expect(() => program.dirs).toThrow('no available local runtime generation');
current.setStatus('ready');
await program.ready;
expect(create).toHaveBeenCalledTimes(2);
expect(program.sessionControllerGeneration).toBe('one');
expect(program.status).toBe('ready');
program.dispose();
await registry.dispose();
});
it('stays preparing until the fixed local generation behavior becomes ready', async () => {
const pending = deferred();
const { registry, program } = setup(new Map([['one', pending.promise]]));
registry.register(runtime('one'));
expect(program.binding).toEqual({ workspaceId: 'workspace', runtimeId: 'local' });
expect(program.status).toBe('preparing');
expect(program.snapshot().ready).toBe(false);
pending.resolve();
await program.ready;
await Promise.resolve();
expect(program.status).toBe('ready');
expect(program.snapshot().ready).toBe(true);
program.dispose();
await registry.dispose();
});
it('marks rejected behavior readiness degraded', async () => {
const failed = deferred();
const { registry, program } = setup(new Map([['one', failed.promise]]));
registry.register(runtime('one'));
failed.reject(new Error('failed'));
await program.ready;
await Promise.resolve();
expect(program.status).toBe('degraded');
program.dispose();
await registry.dispose();
});
it('retains the replaced generation lease until its session controller is disposed', async () => {
const { registry, program } = setup();
const first = runtime('one');
const registration = registry.register(first);
await program.ready;
const controller = program.createSessionController();
const replacement = registration.replace(runtime('two'));
await Promise.resolve();
expect(program.sessionControllerGeneration).toBe('two');
expect(first.disposed).toBe(false);
controller.dispose();
await replacement;
expect(first.disposed).toBe(true);
program.dispose();
await registry.dispose();
});
it('owns catalog, instructions, MCP, provenance, and current runtime in one generation', async () => {
const { registry, program, create } = setup();
registry.register(runtime('one'));
const generation = create.mock.results[0]?.value as {
skills: { catalog: {
listSkills(): unknown[];
listInvocableSkills(): unknown[];
getSkippedByPolicy(): unknown[];
getSkillRoots(): string[];
} };
instructions: { snapshot: { agentsMdPaths?: readonly string[] } };
mcpConfig: { servers(): Record<string, unknown> };
mcp: unknown;
};
const skill = { source: 'workspace' };
generation.skills.catalog.listSkills = () => [skill];
generation.skills.catalog.listInvocableSkills = () => [skill];
generation.skills.catalog.getSkippedByPolicy = () => [];
generation.skills.catalog.getSkillRoots = () => ['/workspace/.agents/skills'];
generation.instructions.snapshot = { agentsMdPaths: ['/workspace/AGENTS.md'] };
generation.mcpConfig.servers = () => ({ baseline: {} });
await program.ready;
await Promise.resolve();
expect(program.skills).toBe(generation.skills);
expect(program.instructions).toBe(generation.instructions);
expect(program.mcpConfig).toBe(generation.mcpConfig);
expect(program.mcp).toBe(generation.mcp);
expect(program.snapshot()).toMatchObject({
workspaceId: 'workspace',
binding: { workspaceId: 'workspace', runtimeId: 'local' },
status: 'ready',
ready: true,
generation: 'one',
trusted: false,
catalog: {
skills: { total: 1, invocable: 1, skipped: 0 },
agentProfiles: 0,
mcpServers: 1,
},
sources: {
skills: [{ source: 'workspace', count: 1 }],
skillRoots: ['/workspace/.agents/skills'],
agentProfiles: [],
instructionPaths: ['/workspace/AGENTS.md'],
mcpServers: ['baseline'],
},
runtimes: [{ runtimeId: 'local', generation: 'one', status: 'ready' }],
});
program.dispose();
await registry.dispose();
});
it('replaces generations atomically and disposes behavior before releasing its lease', async () => {
const firstReady = deferred();
const order: string[] = [];
const { registry, program } = setup(new Map([['one', firstReady.promise]]), order);
const registration = registry.register(runtime('one'));
const replacement = registration.replace(runtime('two'));
await replacement;
await Promise.resolve();
expect(program.sessionControllerGeneration).toBe('two');
expect(program.status).toBe('ready');
expect(order).toEqual(['behavior:one']);
firstReady.resolve();
await Promise.resolve();
expect(program.sessionControllerGeneration).toBe('two');
expect(program.status).toBe('ready');
program.dispose();
expect(order).toEqual(['behavior:one', 'behavior:two']);
await registry.dispose();
});
});