mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-26 17:14:33 +00:00
* 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
234 lines
8.2 KiB
TypeScript
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();
|
|
});
|
|
});
|