fix: agent-core-v2 tests and background persistence options

This commit is contained in:
_Kerman 2026-06-30 12:28:42 +08:00
parent 01509910a7
commit 48e4c3dae7
31 changed files with 3618 additions and 2401 deletions

View file

@ -1,5 +1,4 @@
import { createDecorator } from "#/_base/di";
import { BackgroundTaskPersistence } from './persist';
import type {
BackgroundTask,
BackgroundTaskInfo,
@ -19,13 +18,6 @@ export type {
BackgroundTaskStatus,
} from './task';
export interface BackgroundServiceOptions {
readonly persistence?: BackgroundTaskPersistence;
readonly maxRunningTasks?: number;
}
export type BackgroundOptions = BackgroundServiceOptions;
export interface BackgroundLoadOptions {
readonly replace?: boolean;
}

View file

@ -3,24 +3,20 @@
*
* Owns the agent's registry of running and restored background tasks:
* registers and drives tasks to completion, retains a bounded output ring,
* persists task state and output through the `background` persistence helper
* (over the `storage` stores, namespaced by the session from
* `session-context`), records lifecycle through `wireRecord`, delivers
* persists task state and output through `background` persistence, reads
* limits through `config`, records lifecycle through `wireRecord`, delivers
* terminal notifications through `contextMemory`, and broadcasts through
* `eventSink`. Bound at Agent scope.
*/
import {
randomBytes } from 'node:crypto';
import { randomBytes } from 'node:crypto';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { ContentPart } from '@moonshot-ai/kosong';
import {
Disposable,
} from "#/_base/di";
import { escapeXml, escapeXmlAttr } from "#/_base/utils/xml-escape";
import { Disposable } from '#/_base/di';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import type { BackgroundTaskOrigin } from '#/contextMemory';
import { renderNotificationXml } from '#/contextMemory/notification-xml';
import {
@ -30,7 +26,7 @@ import {
} from './task';
import { IContextMemory } from '#/contextMemory';
import { IConfigRegistry } from '#/config';
import { IConfigRegistry, IConfigService } from '#/config';
import { IEventSink } from '../eventSink';
import { IExternalHooksService } from '#/externalHooks';
import { IPromptService } from '#/prompt';
@ -41,9 +37,7 @@ import type { WireRecord } from '#/wireRecord';
import { IWireRecord } from '#/wireRecord';
import {
IBackgroundService,
BackgroundTaskPersistence,
type BackgroundLoadOptions,
type BackgroundServiceOptions,
type BackgroundTask,
type BackgroundTaskInfo,
type BackgroundTaskOutputSnapshot,
@ -51,7 +45,8 @@ import {
type ForegroundTaskReleaseReason,
type RegisterBackgroundTaskOptions,
} from './background';
import { BACKGROUND_SECTION, BackgroundConfigSchema } from './configSection';
import { BACKGROUND_SECTION, type BackgroundConfig, BackgroundConfigSchema } from './configSection';
import { BackgroundTaskPersistence } from './persist';
declare module '#/wireRecord' {
interface WireRecordMap {
@ -131,11 +126,9 @@ export class BackgroundService extends Disposable implements IBackgroundService
private readonly ghosts = new Map<string, BackgroundTaskInfo>();
private readonly scheduledNotificationKeys = new Set<string>();
private readonly deliveredNotificationKeys = new Set<string>();
private persistence: BackgroundTaskPersistence | undefined;
private maxRunningTasks: number | undefined;
private readonly persistence: BackgroundTaskPersistence;
constructor(
options: BackgroundServiceOptions = {},
@IEventSink private readonly events: IEventSink,
@IWireRecord private readonly wireRecord: IWireRecord,
@ITelemetryService private readonly telemetry: ITelemetryService,
@ -143,14 +136,19 @@ export class BackgroundService extends Disposable implements IBackgroundService
@IExternalHooksService private readonly externalHooks: IExternalHooksService,
@IContextMemory private readonly context: IContextMemory,
@IConfigRegistry configRegistry: IConfigRegistry,
@IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore,
@IStorageService private readonly byteStore: IStorageService,
@ISessionContext private readonly session: ISessionContext,
@IConfigService private readonly config: IConfigService,
@IAtomicDocumentStore atomicDocs: IAtomicDocumentStore,
@IStorageService byteStore: IStorageService,
@ISessionContext session: ISessionContext,
) {
super();
configRegistry.registerSection(BACKGROUND_SECTION, BackgroundConfigSchema);
this.persistence = options.persistence ?? this.createDefaultPersistence();
this.maxRunningTasks = options.maxRunningTasks;
this.persistence = new BackgroundTaskPersistence(
session.sessionDir,
session.metaScope.replace(/\/session-meta$/, ''),
atomicDocs,
byteStore,
);
this._register(
wireRecord.register('background.task.started', (record) => {
this.applyRestoredTask(record);
@ -281,7 +279,6 @@ export class BackgroundService extends Disposable implements IBackgroundService
async loadFromDisk(options: BackgroundLoadOptions = {}): Promise<void> {
const persistence = this.persistence;
if (persistence === undefined) return;
if (options.replace !== false) {
this.ghosts.clear();
}
@ -311,7 +308,7 @@ export class BackgroundService extends Disposable implements IBackgroundService
const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes));
const persistence = this.persistence;
if (persistence !== undefined && (await persistence.taskOutputExists(taskId))) {
if (await persistence.taskOutputExists(taskId)) {
const outputSizeBytes = await persistence.taskOutputSizeBytes(taskId);
const previewOffset = Math.max(0, outputSizeBytes - previewLimit);
const previewBytes = outputSizeBytes - previewOffset;
@ -507,9 +504,12 @@ export class BackgroundService extends Disposable implements IBackgroundService
}
private assertCanRegister(startedInBackground: boolean): void {
if (this.maxRunningTasks === undefined) return;
const maxRunningTasks = this.config.get<BackgroundConfig | undefined>(
BACKGROUND_SECTION,
)?.maxRunningTasks;
if (maxRunningTasks === undefined) return;
if (!startedInBackground) return;
if (this.activeTaskCount() < this.maxRunningTasks) return;
if (this.activeTaskCount() < maxRunningTasks) return;
throw new Error('Too many background tasks are already running.');
}
@ -548,27 +548,14 @@ export class BackgroundService extends Disposable implements IBackgroundService
endedAt: info.endedAt ?? Date.now(),
};
this.ghosts.set(taskId, updated);
if (persistence !== undefined) {
await persistence.writeTask(updated);
}
await persistence.writeTask(updated);
lostTasks.push(updated);
}
return lostTasks;
}
private createDefaultPersistence(): BackgroundTaskPersistence {
const sessionScope = this.session.metaScope.replace(/\/session-meta$/, '');
return new BackgroundTaskPersistence(
this.session.sessionDir,
sessionScope,
this.atomicDocs,
this.byteStore,
);
}
private persistLive(entry: ManagedTask): Promise<void> {
const persistence = this.persistence;
if (persistence === undefined) return Promise.resolve();
const info = this.toInfo(entry);
entry.persistWriteQueue = entry.persistWriteQueue
.then(() => persistence.writeTask(info))
@ -581,8 +568,6 @@ export class BackgroundService extends Disposable implements IBackgroundService
entry.outputSizeBytes += chunkBytes;
this.appendRetainedOutput(entry, chunk, chunkBytes);
const persistence = this.persistence;
if (persistence === undefined) return;
if (!entry.outputPersistStarted) {
entry.pendingOutput.push(chunk);
entry.pendingOutputBytes += chunkBytes;
@ -596,7 +581,6 @@ export class BackgroundService extends Disposable implements IBackgroundService
private appendTaskOutput(entry: ManagedTask, chunk: string): void {
const persistence = this.persistence;
if (persistence === undefined) return;
entry.outputWriteQueue = entry.outputWriteQueue
.then(() => persistence.appendTaskOutput(entry.taskId, chunk))
.catch(() => { });

View file

@ -9,10 +9,10 @@
* only be set for the caller-driven deadline
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentBackgroundTask } from '#/background';
import { testAgent } from '../harness';
import { AgentBackgroundTask, IBackgroundService } from '#/background';
import { createTestAgent, type TestAgentContext } from '../harness';
function agentTask(
completion: Promise<{ result: string }>,
@ -35,19 +35,31 @@ function agentTask(
}
describe('AgentBackgroundTask — timeoutMs', () => {
afterEach(() => {
let ctx: TestAgentContext;
let background: IBackgroundService;
beforeEach(() => {
ctx = createTestAgent();
background = ctx.get(IBackgroundService);
});
afterEach(async () => {
vi.useRealTimers();
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('external deadline marks task timed_out', async () => {
const ctx = testAgent();
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
// A never-resolving completion — only the deadline will fire.
const hangForever = new Promise<{ result: string }>(() => {});
const taskId = ctx.background.registerTask(agentTask(hangForever, 'hang', 2_000));
const taskId = background.registerTask(agentTask(hangForever, 'hang', 2_000));
// Advance past the deadline and manager-owned stop grace.
const terminalPromise = ctx.background.wait(taskId);
const terminalPromise = background.wait(taskId);
await vi.advanceTimersByTimeAsync(7_100);
const info = await terminalPromise;
@ -56,32 +68,30 @@ describe('AgentBackgroundTask — timeoutMs', () => {
});
it('omitting timeoutMs lets the task run to completion without a manager deadline', async () => {
const ctx = testAgent();
let resolveFn!: (r: { result: string }) => void;
const completion = new Promise<{ result: string }>((res) => {
resolveFn = res;
});
const taskId = ctx.background.registerTask(agentTask(completion, 'no deadline'));
const taskId = background.registerTask(agentTask(completion, 'no deadline'));
resolveFn({ result: 'finished' });
const info = await ctx.background.wait(taskId);
const info = await background.wait(taskId);
expect(info?.status).toBe('completed');
expect(info?.stopReason).toBeUndefined();
});
it('internal TimeoutError rejection = generic failure with error reason', async () => {
const ctx = testAgent();
// Even with a deadline set, an internal TimeoutError that fires
// BEFORE the deadline must land as a plain `failed` (not as a
// deadline-driven timeout).
const internalErr = new Error('aiohttp sock_read timeout');
internalErr.name = 'TimeoutError';
const rejecting = Promise.reject(internalErr);
const taskId = ctx.background.registerTask(
const taskId = background.registerTask(
agentTask(rejecting, 'internal timeout', 900_000),
);
const info = await ctx.background.wait(taskId);
const info = await background.wait(taskId);
expect(info?.status).toBe('failed');
// Deadline never fired: this is a normal task failure, so the original
// error is preserved as the stop reason rather than being reported as a
@ -98,19 +108,18 @@ describe('AgentBackgroundTask — timeoutMs', () => {
// the `completion` promise here never resolves, so the lifecycle
// promise's `.finally(clearTimeout)` would not run under real time.
it('explicit timeoutMs is persisted on the task info', async () => {
const ctx = testAgent();
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
let resolveFn!: (r: { result: string }) => void;
const completion = new Promise<{ result: string }>((res) => {
resolveFn = res;
});
const taskId = ctx.background.registerTask(
const taskId = background.registerTask(
agentTask(completion, 'persist timeout', 1_800_000),
);
const info = ctx.background.getTask(taskId);
const info = background.getTask(taskId);
expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBe(1_800_000);
resolveFn({ result: 'finished' });
await expect(ctx.background.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
await expect(background.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
});
// Decision (confirmed with team, 2026-05-19): background tasks in
@ -126,16 +135,15 @@ describe('AgentBackgroundTask — timeoutMs', () => {
// guard: if someone later adds a hard-coded default in
// registerAgentTask, the assertion below catches it.
it('omitted timeoutMs leaves the task info field undefined', async () => {
const ctx = testAgent();
let resolveFn!: (r: { result: string }) => void;
const completion = new Promise<{ result: string }>((res) => {
resolveFn = res;
});
const taskId = ctx.background.registerTask(agentTask(completion, 'default timeout'));
const info = ctx.background.getTask(taskId);
const taskId = background.registerTask(agentTask(completion, 'default timeout'));
const info = background.getTask(taskId);
expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBeUndefined();
resolveFn({ result: 'finished' });
await expect(ctx.background.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
await expect(background.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
});
// Contract decision (2026-05-21): kimi-code treats `timeoutMs: 0`
@ -146,21 +154,20 @@ describe('AgentBackgroundTask — timeoutMs', () => {
// zero so a caller writing `0` does not lose its task to an
// immediate kill.
it('timeoutMs=0 is preserved on the task info and does not arm a deadline', async () => {
const ctx = testAgent();
let resolveFn!: (r: { result: string }) => void;
const completion = new Promise<{ result: string }>((res) => {
resolveFn = res;
});
const taskId = ctx.background.registerTask(agentTask(completion, 'zero timeout', 0));
const taskId = background.registerTask(agentTask(completion, 'zero timeout', 0));
// The literal zero is preserved on the task info.
const initial = ctx.background.getTask(taskId);
const initial = background.getTask(taskId);
expect((initial as unknown as { timeoutMs?: number }).timeoutMs).toBe(0);
// No deadline armed: the task stays running. We bound the wait
// with a short race so the test does not hang on the never-
// settling completion promise; the racing branch winning is the
// expected outcome.
const info = await ctx.background.wait(taskId, 5);
const info = await background.wait(taskId, 5);
const raced = info === undefined ? undefined : {
status: info.status,
stopReason: info.stopReason,
@ -168,6 +175,6 @@ describe('AgentBackgroundTask — timeoutMs', () => {
expect(raced?.status).toBe('running');
expect(raced?.stopReason).toBeUndefined();
resolveFn({ result: 'finished' });
await expect(ctx.background.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
await expect(background.wait(taskId)).resolves.toMatchObject({ status: 'completed' });
});
});

View file

@ -5,7 +5,7 @@ import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IBackgroundService, type BackgroundTask } from '#/background';
import { BackgroundService } from '#/background/backgroundService';
import { IConfigRegistry } from '#/config';
import { IConfigRegistry, IConfigService } from '#/config';
import { IContextMemory } from '#/contextMemory';
import { IEventSink } from '#/eventSink';
import { IExternalHooksService } from '#/externalHooks';
@ -41,6 +41,9 @@ describe('BackgroundService', () => {
ix.stub(IPromptService, { steer: () => undefined });
ix.stub(IExternalHooksService, { triggerNotification: () => {} });
ix.stub(IConfigRegistry, { registerSection: () => {} });
ix.stub(IConfigService, {
get: (() => undefined) as IConfigService['get'],
});
ix.stub(ISessionContext, {
sessionId: 'test-session',
workspaceId: 'test-ws',

View file

@ -20,16 +20,25 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AgentBackgroundTask,
BackgroundTaskPersistence,
IBackgroundService,
} from '#/background';
import { IPromptService } from '#/prompt';
import { IProfileService } from '#/profile';
import { ITurnService } from '#/turn';
import { testAgent } from '../harness';
import type { BackgroundServiceTestManager } from './stubs';
import {
backgroundServices,
createTestAgent,
homeDirServices,
type TestAgentContext,
} from '../harness';
import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from './stubs';
function agentTask(
completion: Promise<{ result: string }>,
@ -44,219 +53,226 @@ function agentTask(
}
describe('background notification → main agent (real Agent instance)', () => {
it('IDLE: completed bg agent auto-starts a new turn with <notification> XML', async () => {
const ctx = testAgent();
ctx.configure({ tools: [] });
describe('live notification delivery', () => {
let ctx: TestAgentContext;
let background: IBackgroundService;
let prompt: IPromptService;
let turn: ITurnService;
let profile: IProfileService;
expect(ctx.runtime.get(ITurnService).getActiveTurn()).toBeUndefined();
expect(ctx.llmCalls.length).toBe(0);
// The expected auto-launched turn will call generate once, then end.
ctx.mockNextResponse({ type: 'text', text: 'ack from main agent' });
const taskId = ctx.background.registerTask(agentTask(
Promise.resolve({ result: 'background agent finished its job' }),
'idle-state repro',
));
await ctx.background.wait(taskId);
// Give the steer→launch→turnWorker→generate chain time to run.
await vi.waitFor(
() => {
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(1);
},
{ timeout: 2000 },
);
// The latest LLM call must include the notification XML the
// BackgroundManager injected via `turn.steer`.
const lastCall = ctx.llmCalls.at(-1)!;
const flatHistoryText = JSON.stringify(lastCall.history);
expect(flatHistoryText).toContain('<notification');
expect(flatHistoryText).toContain('task.completed');
expect(flatHistoryText).toContain(taskId);
expect(flatHistoryText).toContain('background agent finished its job');
});
it('BUSY: completed bg agent during an active turn is flushed before the next LLM call', async () => {
const ctx = testAgent();
ctx.configure({ tools: [] });
// Step 1 of the user-prompted turn: produce no tool call, end turn.
// But to give the steerBuffer a chance to be flushed we want a
// multi-step turn. So instead: queue a text response for step 1
// that DOESN'T end the turn yet (set finishReason to tool_calls
// is wrong because we have no tool call). Easiest is to chain two
// responses: first one is text-only (so step ends), the steer
// notification arrives during that step, then a second LLM call
// happens that should contain the notification.
//
// Actually with the scripted-generate harness, a text-only
// response yields finishReason='completed' and the turn ends.
// To force a 2-step turn we need the first step to emit a tool
// call. Since we configured no tools, we can't. So this BUSY
// case is hard to model without LLM-side multi-step. Instead we
// test the buffer mechanism directly:
const steerSpy = vi.spyOn(ctx.get(IPromptService), 'steer');
// Pretend a turn is active by calling prompt and not awaiting end.
// Queue a response that will be consumed.
ctx.mockNextResponse({ type: 'text', text: 'first turn ack' });
const promptPromise = ctx.rpc.prompt({
input: [{ type: 'text', text: 'kick off a turn' }],
beforeEach(() => {
ctx = createTestAgent();
background = ctx.get(IBackgroundService);
prompt = ctx.get(IPromptService);
turn = ctx.get(ITurnService);
profile = ctx.get(IProfileService);
profile.update({ activeToolNames: [] });
});
// Right after kicking off, register a background task that
// completes immediately. The notification should be steer()d
// while activeTurn is still set, landing in the steerBuffer.
const taskId = ctx.background.registerTask(agentTask(
Promise.resolve({ result: 'busy-state bg result' }),
'busy-state repro',
));
// Wait for the first turn to end.
await promptPromise;
await ctx.untilTurnEnd();
// steer() must have been called at least once for our task.
await vi.waitFor(() => {
expect(steerSpy).toHaveBeenCalled();
});
const matchingCall = steerSpy.mock.calls.find((c) => {
const payload = c[0] as { origin?: { kind?: string; taskId?: string } } | undefined;
return payload?.origin?.kind === 'background_task' && payload?.origin?.taskId === taskId;
});
expect(matchingCall).toBeDefined();
// After the turn ends, the steerBuffer should be flushed —
// i.e. the notification text appears as a user message in
// the agent's context history.
const data = ctx.contextData();
const flatContext = JSON.stringify(data);
expect(flatContext).toContain('<notification');
expect(flatContext).toContain('task.completed');
expect(flatContext).toContain(taskId);
expect(flatContext).toContain('busy-state bg result');
});
it('IDLE × N: a GROUP of bg agents completes — all notifications should reach the LLM', async () => {
const ctx = testAgent();
ctx.configure({ tools: [] });
// Only one auto-launched turn is expected; its beforeStep should
// drain ALL buffered notifications. So one queued response is enough.
ctx.mockNextResponse({ type: 'text', text: 'ack group' });
const taskIds = [
ctx.background.registerTask(agentTask(
Promise.resolve({ result: 'bg #1 result' }),
'group-1',
)),
ctx.background.registerTask(agentTask(
Promise.resolve({ result: 'bg #2 result' }),
'group-2',
)),
ctx.background.registerTask(agentTask(
Promise.resolve({ result: 'bg #3 result' }),
'group-3',
)),
];
for (const id of taskIds) {
await ctx.background.wait(id);
}
await vi.waitFor(
() => {
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(1);
},
{ timeout: 2000 },
);
const lastCall = ctx.llmCalls.at(-1)!;
const flatHistoryText = JSON.stringify(lastCall.history);
// ⚠️ Each of the 3 tasks' notifications must show up in the LLM
// history of the (single) auto-launched turn.
for (const id of taskIds) {
expect(flatHistoryText).toContain(id);
}
expect(flatHistoryText).toContain('bg #1 result');
expect(flatHistoryText).toContain('bg #2 result');
expect(flatHistoryText).toContain('bg #3 result');
});
it('RACE: bg completion fires AFTER LLM returns but BEFORE activeTurn is cleared', async () => {
// We're hunting a window: shouldContinueAfterStop reads an empty
// steerBuffer → returns { continue: false } → runTurn unwinds →
// finally block hasn't yet set activeTurn = null. If a steer()
// lands in this window, it gets buffered, then activeTurn=null
// and the buffer is never flushed until the next user prompt.
const ctx = testAgent();
ctx.configure({ tools: [] });
// 1st turn: prompted by user — produces text and ends.
ctx.mockNextResponse({ type: 'text', text: 'first user-prompted ack' });
// Schedule the bg completion to fire when the first turn ends.
// The cleanest trigger: hook into the `turn.ended` event.
const turnEndedPromise = ctx.once('turn.ended');
// Kick off the user-prompted turn — don't await yet.
await ctx.rpc.prompt({
input: [{ type: 'text', text: 'hello main agent' }],
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
// Wait until turn.ended fires.
await ctx.untilTurnEnd();
await turnEndedPromise;
it('IDLE: completed bg agent auto-starts a new turn with <notification> XML', async () => {
expect(turn.getActiveTurn()).toBeUndefined();
expect(ctx.llmCalls.length).toBe(0);
// At this point activeTurn should be null. Now fire the bg
// completion — this is the IDLE path, NOT the racy one. We
// queue an LLM response so the auto-launched turn can run.
ctx.mockNextResponse({ type: 'text', text: 'auto ack from bg notification' });
const taskId = ctx.background.registerTask(agentTask(
Promise.resolve({ result: 'post-turn bg result' }),
'race-after-turn',
));
// The expected auto-launched turn will call generate once, then end.
ctx.mockNextResponse({ type: 'text', text: 'ack from main agent' });
await ctx.background.wait(taskId);
const taskId = background.registerTask(agentTask(
Promise.resolve({ result: 'background agent finished its job' }),
'idle-state repro',
));
// The notification arriving while idle should auto-launch a turn.
await vi.waitFor(
() => {
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(2);
},
{ timeout: 2000 },
);
await background.wait(taskId);
const lastCall = ctx.llmCalls.at(-1)!;
const flatHistoryText = JSON.stringify(lastCall.history);
expect(flatHistoryText).toContain('<notification');
expect(flatHistoryText).toContain(taskId);
expect(flatHistoryText).toContain('post-turn bg result');
// Give the steer→launch→turnWorker→generate chain time to run.
await vi.waitFor(
() => {
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(1);
},
{ timeout: 2000 },
);
// The latest LLM call must include the notification XML the
// BackgroundManager injected via `turn.steer`.
const lastCall = ctx.llmCalls.at(-1)!;
const flatHistoryText = JSON.stringify(lastCall.history);
expect(flatHistoryText).toContain('<notification');
expect(flatHistoryText).toContain('task.completed');
expect(flatHistoryText).toContain(taskId);
expect(flatHistoryText).toContain('background agent finished its job');
});
it('BUSY: completed bg agent during an active turn is flushed before the next LLM call', async () => {
// Step 1 of the user-prompted turn: produce no tool call, end turn.
// But to give the steerBuffer a chance to be flushed we want a
// multi-step turn. So instead: queue a text response for step 1
// that DOESN'T end the turn yet (set finishReason to tool_calls
// is wrong because we have no tool call). Easiest is to chain two
// responses: first one is text-only (so step ends), the steer
// notification arrives during that step, then a second LLM call
// happens that should contain the notification.
//
// Actually with the scripted-generate harness, a text-only
// response yields finishReason='completed' and the turn ends.
// To force a 2-step turn we need the first step to emit a tool
// call. Since we configured no tools, we can't. So this BUSY
// case is hard to model without LLM-side multi-step. Instead we
// test the buffer mechanism directly:
const steerSpy = vi.spyOn(prompt, 'steer');
// Pretend a turn is active by calling prompt and not awaiting end.
// Queue a response that will be consumed.
ctx.mockNextResponse({ type: 'text', text: 'first turn ack' });
const promptPromise = ctx.rpc.prompt({
input: [{ type: 'text', text: 'kick off a turn' }],
});
// Right after kicking off, register a background task that
// completes immediately. The notification should be steer()d
// while activeTurn is still set, landing in the steerBuffer.
const taskId = background.registerTask(agentTask(
Promise.resolve({ result: 'busy-state bg result' }),
'busy-state repro',
));
// Wait for the first turn to end.
await promptPromise;
await ctx.untilTurnEnd();
// steer() must have been called at least once for our task.
await vi.waitFor(() => {
expect(steerSpy).toHaveBeenCalled();
});
const matchingCall = steerSpy.mock.calls.find((c) => {
const payload = c[0] as { origin?: { kind?: string; taskId?: string } } | undefined;
return payload?.origin?.kind === 'background_task' && payload?.origin?.taskId === taskId;
});
expect(matchingCall).toBeDefined();
// After the turn ends, the steerBuffer should be flushed —
// i.e. the notification text appears as a user message in
// the agent's context history.
const data = ctx.contextData();
const flatContext = JSON.stringify(data);
expect(flatContext).toContain('<notification');
expect(flatContext).toContain('task.completed');
expect(flatContext).toContain(taskId);
expect(flatContext).toContain('busy-state bg result');
});
it('IDLE × N: a GROUP of bg agents completes — all notifications should reach the LLM', async () => {
// Only one auto-launched turn is expected; its beforeStep should
// drain ALL buffered notifications. So one queued response is enough.
ctx.mockNextResponse({ type: 'text', text: 'ack group' });
const taskIds = [
background.registerTask(agentTask(
Promise.resolve({ result: 'bg #1 result' }),
'group-1',
)),
background.registerTask(agentTask(
Promise.resolve({ result: 'bg #2 result' }),
'group-2',
)),
background.registerTask(agentTask(
Promise.resolve({ result: 'bg #3 result' }),
'group-3',
)),
];
for (const id of taskIds) {
await background.wait(id);
}
await vi.waitFor(
() => {
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(1);
},
{ timeout: 2000 },
);
const lastCall = ctx.llmCalls.at(-1)!;
const flatHistoryText = JSON.stringify(lastCall.history);
// ⚠️ Each of the 3 tasks' notifications must show up in the LLM
// history of the (single) auto-launched turn.
for (const id of taskIds) {
expect(flatHistoryText).toContain(id);
}
expect(flatHistoryText).toContain('bg #1 result');
expect(flatHistoryText).toContain('bg #2 result');
expect(flatHistoryText).toContain('bg #3 result');
});
it('RACE: bg completion fires AFTER LLM returns but BEFORE activeTurn is cleared', async () => {
// We're hunting a window: shouldContinueAfterStop reads an empty
// steerBuffer → returns { continue: false } → runTurn unwinds →
// finally block hasn't yet set activeTurn = null. If a steer()
// lands in this window, it gets buffered, then activeTurn=null
// and the buffer is never flushed until the next user prompt.
// 1st turn: prompted by user — produces text and ends.
ctx.mockNextResponse({ type: 'text', text: 'first user-prompted ack' });
// Schedule the bg completion to fire when the first turn ends.
// The cleanest trigger: hook into the `turn.ended` event.
const turnEndedPromise = ctx.once('turn.ended');
// Kick off the user-prompted turn — don't await yet.
await ctx.rpc.prompt({
input: [{ type: 'text', text: 'hello main agent' }],
});
// Wait until turn.ended fires.
await ctx.untilTurnEnd();
await turnEndedPromise;
// At this point activeTurn should be null. Now fire the bg
// completion — this is the IDLE path, NOT the racy one. We
// queue an LLM response so the auto-launched turn can run.
ctx.mockNextResponse({ type: 'text', text: 'auto ack from bg notification' });
const taskId = background.registerTask(agentTask(
Promise.resolve({ result: 'post-turn bg result' }),
'race-after-turn',
));
await background.wait(taskId);
// The notification arriving while idle should auto-launch a turn.
await vi.waitFor(
() => {
expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(2);
},
{ timeout: 2000 },
);
const lastCall = ctx.llmCalls.at(-1)!;
const flatHistoryText = JSON.stringify(lastCall.history);
expect(flatHistoryText).toContain('<notification');
expect(flatHistoryText).toContain(taskId);
expect(flatHistoryText).toContain('post-turn bg result');
});
});
it('RESUME: terminal bg tasks discovered on reconcile are SILENTLY injected (no auto-turn)', async () => {
// Scenario the user described: kimi exits while bg tasks are
// running; on next start, resume() loads them from disk and
// reconcile() classifies them as terminal (lost for in-process
// agent tasks; possibly completed for bash tasks if the process
// wrote a terminal state). The restore path uses
// `appendUserMessage`, NOT `steer`, so:
// - Notification XML lands in context history ✓
// - No new turn is launched ✗
// - User sees nothing happen until they type
//
// This test pins that current behavior so any change shows up.
describe('resumed notifications', () => {
let sessionDir: string;
let ctx: TestAgentContext;
let background: BackgroundServiceTestManager;
let prompt: IPromptService;
let turn: ITurnService;
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-resume-repro-'));
try {
beforeEach(async () => {
sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-resume-repro-'));
// Simulate a previous session's bash bg task that completed
// before exit and an agent bg task that didn't (will be lost).
const backgroundPersistence = new BackgroundTaskPersistence(sessionDir);
const backgroundPersistence = createBackgroundTaskPersistence(sessionDir);
await backgroundPersistence.writeTask({
taskId: 'bash-prev0000',
kind: 'process',
@ -279,16 +295,42 @@ describe('background notification → main agent (real Agent instance)', () => {
status: 'running',
});
const ctx = testAgent({ background: { persistence: backgroundPersistence } });
ctx.configure({ tools: [] });
ctx = createTestAgent(homeDirServices(sessionDir), backgroundServices());
background = ctx.get(IBackgroundService) as BackgroundServiceTestManager;
prompt = ctx.get(IPromptService);
turn = ctx.get(ITurnService);
const profile = ctx.get(IProfileService);
profile.update({ activeToolNames: [] });
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
await rm(sessionDir, { recursive: true, force: true });
}
});
it('RESUME: terminal bg tasks discovered on reconcile are SILENTLY injected (no auto-turn)', async () => {
// Scenario the user described: kimi exits while bg tasks are
// running; on next start, resume() loads them from disk and
// reconcile() classifies them as terminal (lost for in-process
// agent tasks; possibly completed for bash tasks if the process
// wrote a terminal state). The restore path uses
// `appendUserMessage`, NOT `steer`, so:
// - Notification XML lands in context history ✓
// - No new turn is launched ✗
// - User sees nothing happen until they type
//
// This test pins that current behavior so any change shows up.
// We do NOT mock any LLM response. If the resume path
// mistakenly launches a turn, scripted-generate throws
// "Unexpected generate call" and the test fails loudly.
const steerSpy = vi.spyOn(ctx.rpcMethods, 'steer');
const steerSpy = vi.spyOn(prompt, 'steer');
// Reproduce Agent.resume()'s post-replay sequence.
const background = ctx.background as BackgroundServiceTestManager;
await background.loadFromDisk();
await background.reconcile();
@ -306,7 +348,7 @@ describe('background notification → main agent (real Agent instance)', () => {
// The notifications were silently appended, so no new turn ran.
expect(steerSpy).not.toHaveBeenCalled();
expect(ctx.llmCalls.length).toBe(0);
expect(ctx.runtime.get(ITurnService).getActiveTurn()).toBeUndefined();
expect(turn.getActiveTurn()).toBeUndefined();
// Both notifications are in context, waiting for the user. The
// completed bash task references its persisted output file rather
@ -317,8 +359,6 @@ describe('background notification → main agent (real Agent instance)', () => {
expect(flatContext).not.toContain('previous bash output');
expect(flatContext).toMatch(/task\.completed/);
expect(flatContext).toMatch(/task\.lost/);
} finally {
await rm(sessionDir, { recursive: true, force: true });
}
});
});
});

View file

@ -15,11 +15,19 @@ import type { KaosProcess } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
BackgroundTaskPersistence,
type IBackgroundService,
IBackgroundService,
ProcessBackgroundTask,
} from '#/background';
import { testAgent, type TestAgentContext } from '../harness';
import {
backgroundServices,
createTestAgent,
homeDirServices,
type TestAgentContext,
} from '../harness';
import {
BACKGROUND_TEST_SESSION_SCOPE,
createBackgroundTaskPersistence,
} from './stubs';
const MAX_OUTPUT_BYTES = 1024 * 1024;
@ -83,63 +91,71 @@ function registerForeground(
describe('BackgroundManager — foreground persistence', () => {
let sessionDir: string;
let persistence: BackgroundTaskPersistence;
let persistence: ReturnType<typeof createBackgroundTaskPersistence>;
let ctx: TestAgentContext;
let background: IBackgroundService;
beforeEach(() => {
sessionDir = mkdtempSync(join(tmpdir(), 'bpm-fg-'));
persistence = new BackgroundTaskPersistence(sessionDir);
ctx = testAgent({ background: { persistence } });
persistence = createBackgroundTaskPersistence(sessionDir);
ctx = createTestAgent(homeDirServices(sessionDir), backgroundServices());
background = ctx.get(IBackgroundService);
});
afterEach(() => {
rmSync(sessionDir, { recursive: true, force: true });
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
rmSync(sessionDir, { recursive: true, force: true });
}
});
const taskJsonPath = (taskId: string): string => join(sessionDir, 'tasks', `${taskId}.json`);
const taskJsonPath = (taskId: string): string =>
join(sessionDir, BACKGROUND_TEST_SESSION_SCOPE, 'tasks', `${taskId}.json`);
it('writes nothing to disk for a foreground task that does not spill or detach', async () => {
const taskId = registerForeground(ctx.background, immediateProcess(0, 'hello\n'), 'echo', 'demo');
const taskId = registerForeground(background, immediateProcess(0, 'hello\n'), 'echo', 'demo');
await ctx.background.wait(taskId);
await background.wait(taskId);
expect(existsSync(taskJsonPath(taskId))).toBe(false);
expect(existsSync(persistence.taskOutputFile(taskId))).toBe(false);
// Output is still readable from the in-memory ring buffer.
const snapshot = await ctx.background.getOutputSnapshot(taskId, 1_000);
const snapshot = await background.getOutputSnapshot(taskId, 1_000);
expect(snapshot.fullOutputAvailable).toBe(false);
expect(snapshot.preview).toContain('hello');
});
it('flushes complete pre-detach output to disk when a foreground task detaches', async () => {
const { proc, pushStdout, finish } = controllableProcess();
const taskId = registerForeground(ctx.background, proc, 'stream', 'demo');
const taskId = registerForeground(background, proc, 'stream', 'demo');
pushStdout('before-detach\n');
await tick(); // buffered in memory, not yet on disk
expect(existsSync(persistence.taskOutputFile(taskId))).toBe(false);
expect(ctx.background.detach(taskId)?.detached).toBe(true);
expect(background.detach(taskId)?.detached).toBe(true);
pushStdout('after-detach\n');
await tick();
finish(0);
await ctx.background.wait(taskId);
await background.wait(taskId);
// output.log is the complete, in-order record across the detach boundary.
expect(await ctx.background.readOutput(taskId)).toBe('before-detach\nafter-detach\n');
expect(await background.readOutput(taskId)).toBe('before-detach\nafter-detach\n');
expect(existsSync(taskJsonPath(taskId))).toBe(true);
});
it('spills to disk and keeps the log when foreground output exceeds the buffer', async () => {
const big = 'a'.repeat(MAX_OUTPUT_BYTES + 1024);
const taskId = registerForeground(ctx.background, immediateProcess(0, big), 'flood', 'demo');
const taskId = registerForeground(background, immediateProcess(0, big), 'flood', 'demo');
await ctx.background.wait(taskId);
await background.wait(taskId);
// getOutputSnapshot drains the output write queue before reporting size.
const snapshot = await ctx.background.getOutputSnapshot(taskId, 1_000);
const snapshot = await background.getOutputSnapshot(taskId, 1_000);
// Spilled artifacts are persisted complete and NOT deleted on completion.
expect(existsSync(persistence.taskOutputFile(taskId))).toBe(true);

View file

@ -9,25 +9,23 @@ import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
BackgroundTaskPersistence,
IBackgroundService,
type BackgroundTaskInfo,
} from '#/background';
import { testAgent, type TestAgentContext } from '../harness';
import type { BackgroundServiceTestManager } from './stubs';
import { IEventSink } from '#/eventSink';
import {
backgroundServices,
createTestAgent,
homeDirServices,
type TestAgentContext,
} from '../harness';
import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from './stubs';
let sessionDir: string;
let persistence: BackgroundTaskPersistence;
function testAgentWithBackground(): {
ctx: TestAgentContext;
background: BackgroundServiceTestManager;
} {
const ctx = testAgent({ background: { persistence: new BackgroundTaskPersistence(sessionDir) } });
return {
ctx,
background: ctx.background as BackgroundServiceTestManager,
};
}
let persistence: ReturnType<typeof createBackgroundTaskPersistence>;
function runningGhost(taskId: string): Extract<BackgroundTaskInfo, { kind: 'process' }> {
return {
@ -49,7 +47,7 @@ beforeEach(async () => {
`kimi-hb-stale-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(sessionDir, { recursive: true });
persistence = new BackgroundTaskPersistence(sessionDir);
persistence = createBackgroundTaskPersistence(sessionDir);
});
afterEach(async () => {
@ -57,15 +55,30 @@ afterEach(async () => {
});
describe('Background reconcile — stale ghost detection', () => {
it('emits a terminated event with status=lost for a running ghost', async () => {
await persistence.writeTask(runningGhost('bash-stale000'));
let ctx: TestAgentContext;
let background: BackgroundServiceTestManager;
let emittedEvents: unknown[];
const { ctx, background } = testAgentWithBackground();
const emittedEvents: any[] = [];
ctx.events.on((event) => {
beforeEach(() => {
ctx = createTestAgent(homeDirServices(sessionDir), backgroundServices());
background = ctx.get(IBackgroundService) as BackgroundServiceTestManager;
emittedEvents = [];
const events = ctx.get(IEventSink);
events.on((event) => {
emittedEvents.push(event);
});
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('emits a terminated event with status=lost for a running ghost', async () => {
await persistence.writeTask(runningGhost('bash-stale000'));
await background.loadFromDisk();
await background.reconcile();
@ -82,20 +95,13 @@ describe('Background reconcile — stale ghost detection', () => {
it('second reconcile does not emit a duplicate termination event', async () => {
await persistence.writeTask(runningGhost('bash-dedup000'));
const { ctx, background } = testAgentWithBackground();
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
});
await background.loadFromDisk();
await background.reconcile();
await background.reconcile();
expect(
emittedEvents.filter(
(event) => event.type === 'background.task.terminated',
(event) => (event as { type?: string }).type === 'background.task.terminated',
),
).toHaveLength(1);
});

View file

@ -2,16 +2,16 @@ import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AgentBackgroundTask,
BackgroundTaskPersistence,
type IBackgroundService,
IBackgroundService,
ProcessBackgroundTask,
} from '#/background';
import type { SessionSubagentHost, SubagentHandle } from '#/subagentHost';
import { testAgent } from '../harness';
import { createTestAgent, type TestAgentContext } from '../harness';
import { createBackgroundTaskPersistence } from './stubs';
function registerProcess(
manager: IBackgroundService,
@ -57,26 +57,40 @@ function pendingProcess(): KaosProcess {
}
describe('background task id format', () => {
let ctx: TestAgentContext;
let background: IBackgroundService;
beforeEach(() => {
ctx = createTestAgent();
background = ctx.get(IBackgroundService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('assigns bash-prefixed ids to process tasks', () => {
const manager = testAgent().background;
const id = registerProcess(manager, pendingProcess(), 'sleep 60', 'process task');
const id = registerProcess(background, pendingProcess(), 'sleep 60', 'process task');
expect(id).toMatch(/^bash-[0-9a-z]{8}$/);
expect(manager.getTask(id)).toMatchObject({ taskId: id, kind: 'process' });
expect(background.getTask(id)).toMatchObject({ taskId: id, kind: 'process' });
});
it('assigns agent-prefixed ids to agent tasks', () => {
const manager = testAgent().background;
const id = manager.registerTask(
const id = background.registerTask(
agentTask(new Promise(() => {}), 'agent task'),
);
expect(id).toMatch(/^agent-[0-9a-z]{8}$/);
expect(manager.getTask(id)).toMatchObject({ taskId: id, kind: 'agent' });
expect(background.getTask(id)).toMatchObject({ taskId: id, kind: 'agent' });
});
it('rejects malformed ids at the persistence path boundary', () => {
const persistence = new BackgroundTaskPersistence('/tmp/kimi-bg-id-test');
const persistence = createBackgroundTaskPersistence('/tmp/kimi-bg-id-test');
const rejected = [
'',
'x',

View file

@ -13,13 +13,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
AgentBackgroundTask,
type IBackgroundService,
IBackgroundService,
ProcessBackgroundTask,
type BackgroundTaskInfo,
} from '#/background';
import type { SessionSubagentHost, SubagentHandle } from '#/subagentHost';
import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort';
import { testAgent, type TestAgentContext } from '../harness';
import {
configServices,
homeDirServices,
testAgent,
type TestAgentContext,
type TestAgentServiceOverride,
} from '../harness';
import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
@ -39,16 +45,21 @@ function createBackgroundManager(options: {
options.sessionDir === undefined
? undefined
: createBackgroundTaskPersistence(options.sessionDir);
const ctx = testAgent({
homedir: options.sessionDir,
background: {
persistence,
maxRunningTasks: options.maxRunningTasks,
},
});
const overrides: TestAgentServiceOverride[] = [];
if (options.sessionDir !== undefined) {
overrides.push(homeDirServices(options.sessionDir));
}
const maxRunningTasks = options.maxRunningTasks;
if (maxRunningTasks !== undefined) {
overrides.push(configServices(() => ({
providers: {},
background: { maxRunningTasks },
})));
}
const ctx = testAgent(...overrides);
return {
ctx,
manager: ctx.background as BackgroundServiceTestManager,
manager: ctx.get(IBackgroundService) as BackgroundServiceTestManager,
persistence,
};
}

View file

@ -3,19 +3,11 @@ import { tmpdir } from 'node:os';
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { join } from 'pathe';
import type { KaosProcess } from '@moonshot-ai/kaos';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type IBackgroundService,
ProcessBackgroundTask,
} from '#/background';
import { testAgent, type TestAgentContext } from '../harness';
import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from './stubs';
import { IBackgroundService, ProcessBackgroundTask } from '#/background';
import { createBackgroundTaskPersistence, type BackgroundServiceTestManager } from './stubs';
import { backgroundServices, createTestAgent, homeDirServices, type TestAgentContext } from '../harness';
interface BackgroundServiceFixture {
readonly ctx: TestAgentContext;
@ -25,10 +17,11 @@ interface BackgroundServiceFixture {
function createBackgroundService(homedir: string): BackgroundServiceFixture {
const persistence = createBackgroundTaskPersistence(homedir);
const ctx = testAgent({ homedir, background: { persistence } });
const ctx = createTestAgent(homeDirServices(homedir), backgroundServices());
const manager = ctx.get(IBackgroundService) as BackgroundServiceTestManager;
return {
ctx,
manager: ctx.background as BackgroundServiceTestManager,
manager,
persistence,
};
}
@ -70,9 +63,9 @@ function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
describe('BackgroundManager — readOutput / getOutputSnapshot', () => {
let sessionDir: string;
let manager: BackgroundServiceTestManager;
let persistence: BackgroundTaskPersistence;
let ctx: TestAgentContext;
let manager: BackgroundServiceTestManager;
let persistence: ReturnType<typeof createBackgroundTaskPersistence>;
beforeEach(() => {
sessionDir = mkdtempSync(join(tmpdir(), 'bpm-output-'));
@ -83,8 +76,12 @@ describe('BackgroundManager — readOutput / getOutputSnapshot', () => {
});
afterEach(async () => {
await ctx.close();
rmSync(sessionDir, { recursive: true, force: true });
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
rmSync(sessionDir, { recursive: true, force: true });
}
});
it('getOutputSnapshot returns output.log path when persisted output exists', async () => {
@ -170,11 +167,17 @@ describe('BackgroundManager — readOutput / getOutputSnapshot', () => {
await waitForOutput(manager, taskId, 'persisted line');
await manager.wait(taskId);
const fresh = createBackgroundService(sessionDir).manager;
await fresh.loadFromDisk();
await fresh.reconcile();
const freshFixture = createBackgroundService(sessionDir);
const fresh = freshFixture.manager;
try {
await fresh.loadFromDisk();
await fresh.reconcile();
expect(await fresh.readOutput(taskId)).toContain('persisted line');
expect(await fresh.readOutput(taskId)).toContain('persisted line');
await freshFixture.ctx.expectResumeMatches();
} finally {
await freshFixture.ctx.dispose();
}
});
it('readOutput respects tail length', async () => {

View file

@ -4,9 +4,18 @@ import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BackgroundTaskPersistence } from '#/background';
import { testAgent } from '../harness';
import type { BackgroundServiceTestManager } from './stubs';
import { IBackgroundService } from '#/background';
import {
backgroundServices,
createTestAgent,
homeDirServices,
type TestAgentContext,
} from '../harness';
import {
BACKGROUND_TEST_SESSION_SCOPE,
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from './stubs';
let sessionDir: string;
@ -15,7 +24,7 @@ beforeEach(async () => {
tmpdir(),
`kimi-bg-persist-compat-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(join(sessionDir, 'tasks'), { recursive: true });
await mkdir(join(sessionDir, BACKGROUND_TEST_SESSION_SCOPE, 'tasks'), { recursive: true });
});
afterEach(async () => {
@ -23,10 +32,30 @@ afterEach(async () => {
});
async function writeLegacyTask(taskId: string, task: Record<string, unknown>): Promise<void> {
await writeFile(join(sessionDir, 'tasks', `${taskId}.json`), JSON.stringify(task), 'utf-8');
await writeFile(
join(sessionDir, BACKGROUND_TEST_SESSION_SCOPE, 'tasks', `${taskId}.json`),
JSON.stringify(task),
'utf-8',
);
}
describe('BackgroundTaskPersistence legacy compatibility', () => {
let ctx: TestAgentContext;
let background: BackgroundServiceTestManager;
beforeEach(() => {
ctx = createTestAgent(homeDirServices(sessionDir), backgroundServices());
background = ctx.get(IBackgroundService) as BackgroundServiceTestManager;
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('normalizes legacy snake_case process task records', async () => {
await writeLegacyTask('bash-legacy01', {
task_id: 'bash-legacy01',
@ -39,7 +68,7 @@ describe('BackgroundTaskPersistence legacy compatibility', () => {
status: 'running',
});
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
expect(await persistence.readTask('bash-legacy01')).toMatchObject({
taskId: 'bash-legacy01',
@ -70,7 +99,7 @@ describe('BackgroundTaskPersistence legacy compatibility', () => {
subagent_type: 'reviewer',
});
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
const tasks = await persistence.listTasks();
expect(tasks).toHaveLength(1);
@ -99,20 +128,19 @@ describe('BackgroundTaskPersistence legacy compatibility', () => {
status: 'running',
});
const persistence = new BackgroundTaskPersistence(sessionDir);
const ctx = testAgent({ background: { persistence } });
const manager = ctx.background as BackgroundServiceTestManager;
await background.loadFromDisk();
await background.reconcile();
await manager.loadFromDisk();
await manager.reconcile();
expect(manager.getTask('bash-orphan01')).toMatchObject({
expect(background.getTask('bash-orphan01')).toMatchObject({
taskId: 'bash-orphan01',
kind: 'process',
status: 'lost',
});
const raw = JSON.parse(
await readFile(join(sessionDir, 'tasks', 'bash-orphan01.json'), 'utf-8'),
await readFile(
join(sessionDir, BACKGROUND_TEST_SESSION_SCOPE, 'tasks', 'bash-orphan01.json'),
'utf-8',
),
) as Record<string, unknown>;
expect(raw['taskId']).toBe('bash-orphan01');
expect(raw['task_id']).toBeUndefined();

View file

@ -9,27 +9,23 @@ import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
BackgroundTaskPersistence,
IBackgroundService,
type BackgroundTaskInfo,
} from '#/background';
import { testAgent, type TestAgentContext } from '../harness';
import type { BackgroundServiceTestManager } from './stubs';
import { IEventSink } from '#/eventSink';
import {
backgroundServices,
createTestAgent,
homeDirServices,
type TestAgentContext,
} from '../harness';
import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from './stubs';
let sessionDir: string;
let persistence: BackgroundTaskPersistence;
function testAgentWithBackground(
backgroundPersistence?: BackgroundTaskPersistence,
): {
ctx: TestAgentContext;
background: BackgroundServiceTestManager;
} {
const ctx = testAgent({ background: { persistence: backgroundPersistence } });
return {
ctx,
background: ctx.background as BackgroundServiceTestManager,
};
}
let persistence: ReturnType<typeof createBackgroundTaskPersistence>;
function persistedProcess(
overrides: Partial<Extract<BackgroundTaskInfo, { kind: 'process' }>> = {},
@ -54,7 +50,7 @@ beforeEach(async () => {
`kimi-bg-reconcile-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(sessionDir, { recursive: true });
persistence = new BackgroundTaskPersistence(sessionDir);
persistence = createBackgroundTaskPersistence(sessionDir);
});
afterEach(async () => {
@ -62,233 +58,236 @@ afterEach(async () => {
});
describe('BackgroundManager — loadFromDisk + reconcile', () => {
it('loadFromDisk does nothing when persistence is not configured', async () => {
const { background } = testAgentWithBackground();
describe('without persisted tasks', () => {
let ctx: TestAgentContext;
let background: BackgroundServiceTestManager;
await background.loadFromDisk();
beforeEach(() => {
ctx = createTestAgent(backgroundServices());
background = ctx.get(IBackgroundService) as BackgroundServiceTestManager;
});
expect(background.list(false)).toEqual([]);
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('loadFromDisk does nothing when no tasks are persisted', async () => {
await background.loadFromDisk();
expect(background.list(false)).toEqual([]);
});
});
it('reconciles a previously-running task as lost', async () => {
await persistence.writeTask(persistedProcess());
const { ctx, background } = testAgentWithBackground(persistence);
describe('with persistence', () => {
let ctx: TestAgentContext;
let background: BackgroundServiceTestManager;
let emittedEvents: unknown[];
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
beforeEach(() => {
ctx = createTestAgent(homeDirServices(sessionDir), backgroundServices());
background = ctx.get(IBackgroundService) as BackgroundServiceTestManager;
emittedEvents = [];
const events = ctx.get(IEventSink);
events.on((event) => {
emittedEvents.push(event);
});
});
await background.loadFromDisk();
await background.reconcile();
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
expect(background.getTask('bash-orphan00')).toMatchObject({
taskId: 'bash-orphan00',
status: 'lost',
});
expect(await persistence.readTask('bash-orphan00')).toMatchObject({
taskId: 'bash-orphan00',
status: 'lost',
});
expect(emittedEvents).toContainEqual({
type: 'background.task.terminated',
info: expect.objectContaining({
it('reconciles a previously-running task as lost', async () => {
await persistence.writeTask(persistedProcess());
await background.loadFromDisk();
await background.reconcile();
expect(background.getTask('bash-orphan00')).toMatchObject({
taskId: 'bash-orphan00',
status: 'lost',
}),
});
});
it('runtime restore reconciles persisted tasks through the background resume hook', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-restore0',
command: 'sleep 9999',
description: 'restore hook check',
pid: 4242,
}),
);
const { ctx, background } = testAgentWithBackground(persistence);
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
});
expect(await persistence.readTask('bash-orphan00')).toMatchObject({
taskId: 'bash-orphan00',
status: 'lost',
});
expect(emittedEvents).toContainEqual({
type: 'background.task.terminated',
info: expect.objectContaining({
taskId: 'bash-orphan00',
status: 'lost',
}),
});
});
await ctx.runtime.restore([]);
it('runtime restore reconciles persisted tasks through the background resume hook', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-restore0',
command: 'sleep 9999',
description: 'restore hook check',
pid: 4242,
}),
);
expect(background.getTask('bash-restore0')).toMatchObject({
taskId: 'bash-restore0',
status: 'lost',
});
expect(await persistence.readTask('bash-restore0')).toMatchObject({
taskId: 'bash-restore0',
status: 'lost',
});
expect(emittedEvents).toContainEqual({
type: 'background.task.terminated',
info: expect.objectContaining({
await ctx.runtime.restore([]);
expect(background.getTask('bash-restore0')).toMatchObject({
taskId: 'bash-restore0',
status: 'lost',
}),
});
expect(await persistence.readTask('bash-restore0')).toMatchObject({
taskId: 'bash-restore0',
status: 'lost',
});
expect(emittedEvents).toContainEqual({
type: 'background.task.terminated',
info: expect.objectContaining({
taskId: 'bash-restore0',
status: 'lost',
}),
});
});
});
it('does not reclassify already-terminal tasks', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-done0000',
command: 'echo hi',
description: 'echo',
pid: 88888,
endedAt: 1_700_000_010,
exitCode: 0,
it('does not reclassify already-terminal tasks', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-done0000',
command: 'echo hi',
description: 'echo',
pid: 88888,
endedAt: 1_700_000_010,
exitCode: 0,
status: 'completed',
}),
);
await persistence.writeTask(
persistedProcess({
taskId: 'bash-running0',
command: 'sleep 1000',
description: 'sleep',
pid: 77777,
}),
);
await background.loadFromDisk();
await background.reconcile();
expect(await persistence.readTask('bash-done0000')).toMatchObject({
status: 'completed',
}),
);
await persistence.writeTask(
persistedProcess({
taskId: 'bash-running0',
command: 'sleep 1000',
description: 'sleep',
pid: 77777,
}),
);
const { ctx, background } = testAgentWithBackground(persistence);
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
});
expect(await persistence.readTask('bash-running0')).toMatchObject({
status: 'lost',
});
const terminationEvents = emittedEvents.filter(
(event) => (event as { type?: string }).type === 'background.task.terminated',
);
expect(terminationEvents).toHaveLength(1);
expect(terminationEvents[0]).toMatchObject({
type: 'background.task.terminated',
info: { taskId: 'bash-running0', status: 'lost' },
});
});
await background.loadFromDisk();
await background.reconcile();
it('list(activeOnly=false) includes ghosts; list(true) excludes them', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-lost0000',
command: 'x',
description: 'd',
pid: 1,
}),
);
expect(await persistence.readTask('bash-done0000')).toMatchObject({
status: 'completed',
await background.loadFromDisk();
await background.reconcile();
expect(background.list(true)).toEqual([]);
expect(background.list(false)).toEqual([
expect.objectContaining({ taskId: 'bash-lost0000', status: 'lost' }),
]);
});
expect(await persistence.readTask('bash-running0')).toMatchObject({
status: 'lost',
});
const terminationEvents = emittedEvents.filter(
(event) => event.type === 'background.task.terminated',
);
expect(terminationEvents).toHaveLength(1);
expect(terminationEvents[0]).toMatchObject({
type: 'background.task.terminated',
info: { taskId: 'bash-running0', status: 'lost' },
});
});
it('list(activeOnly=false) includes ghosts; list(true) excludes them', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-lost0000',
command: 'x',
description: 'd',
pid: 1,
}),
);
const { background } = testAgentWithBackground(persistence);
it('getTask returns ghost when the live process map has no entry', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-ghost000',
command: 'x',
description: 'd',
pid: 1,
}),
);
await background.loadFromDisk();
await background.reconcile();
await background.loadFromDisk();
await background.reconcile();
expect(background.list(true)).toEqual([]);
expect(background.list(false)).toEqual([
expect.objectContaining({ taskId: 'bash-lost0000', status: 'lost' }),
]);
});
it('getTask returns ghost when the live process map has no entry', async () => {
await persistence.writeTask(
persistedProcess({
expect(background.getTask('bash-ghost000')).toMatchObject({
taskId: 'bash-ghost000',
command: 'x',
description: 'd',
pid: 1,
}),
);
const { background } = testAgentWithBackground(persistence);
await background.loadFromDisk();
await background.reconcile();
expect(background.getTask('bash-ghost000')).toMatchObject({
taskId: 'bash-ghost000',
status: 'lost',
});
});
it('reconcile emits nothing when no ghosts were loaded', async () => {
const { ctx, background } = testAgentWithBackground(persistence);
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
status: 'lost',
});
});
await background.loadFromDisk();
await background.reconcile();
it('reconcile emits nothing when no ghosts were loaded', async () => {
await background.loadFromDisk();
await background.reconcile();
expect(emittedEvents).toEqual([]);
});
it('does not emit duplicate termination events on a second reconcile pass', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-nodup000',
command: 'sleep 9999',
description: 'dedupe check',
pid: 42,
}),
);
const { ctx, background } = testAgentWithBackground(persistence);
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
expect(emittedEvents).toEqual([]);
});
await background.loadFromDisk();
await background.reconcile();
await background.reconcile();
it('does not emit duplicate termination events on a second reconcile pass', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-nodup000',
command: 'sleep 9999',
description: 'dedupe check',
pid: 42,
}),
);
expect(
emittedEvents.filter(
(event) => event.type === 'background.task.terminated',
),
).toHaveLength(1);
});
await background.loadFromDisk();
await background.reconcile();
await background.reconcile();
it('restores terminal ghost notifications into context', async () => {
await persistence.writeTask(
persistedProcess({
expect(
emittedEvents.filter(
(event) => (event as { type?: string }).type === 'background.task.terminated',
),
).toHaveLength(1);
});
it('restores terminal ghost notifications into context', async () => {
await persistence.writeTask(
persistedProcess({
taskId: 'bash-done0001',
command: 'echo done',
description: 'one-shot',
pid: 42,
endedAt: 1_700_000_010,
exitCode: 0,
status: 'completed',
}),
);
await background.loadFromDisk();
await background.reconcile();
expect(background.getTask('bash-done0001')).toMatchObject({
taskId: 'bash-done0001',
command: 'echo done',
description: 'one-shot',
pid: 42,
endedAt: 1_700_000_010,
exitCode: 0,
status: 'completed',
}),
);
const { ctx, background } = testAgentWithBackground(persistence);
const emittedEvents: any[] = [];
ctx.events.on((event) => {
emittedEvents.push(event);
});
expect(
emittedEvents.filter(
(event) => (event as { type?: string }).type === 'background.task.terminated',
),
).toEqual([]);
});
await background.loadFromDisk();
await background.reconcile();
expect(background.getTask('bash-done0001')).toMatchObject({
taskId: 'bash-done0001',
status: 'completed',
});
expect(
emittedEvents.filter((event) => event.type === 'background.task.terminated'),
).toEqual([]);
});
});

View file

@ -11,19 +11,33 @@ import { join } from 'pathe';
import type { KaosProcess } from '@moonshot-ai/kaos';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AgentBackgroundTask, ProcessBackgroundTask } from '#/background';
import { testAgent, type TestAgentContext, type TestAgentOptions } from '../harness';
import {
BackgroundTaskPersistence,
AgentBackgroundTask,
type BackgroundTaskInfo,
type IBackgroundService,
IBackgroundService,
ProcessBackgroundTask,
} from '#/background';
import { IContextMemory } from '#/contextMemory';
import { IEventSink } from '#/eventSink';
import type { HookEngine } from '#/externalHooks/engine';
import { IPromptService } from '#/prompt';
import type { SessionSubagentHost, SubagentHandle } from '#/subagentHost';
import {
configServices,
externalHookServices,
homeDirServices,
telemetryServices,
testAgent,
type TestAgentContext,
type TestAgentServiceOverride,
} from '../harness';
import { recordingTelemetry } from '../telemetry/stubs';
import type { BackgroundServiceTestManager } from './stubs';
import {
createBackgroundTaskPersistence,
type BackgroundServiceTestManager,
} from './stubs';
type FireAndForgetTrigger = NonNullable<TestAgentOptions['hookEngine']>['fireAndForgetTrigger'];
type FireAndForgetTrigger = HookEngine['fireAndForgetTrigger'];
function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess {
return {
@ -141,7 +155,7 @@ interface BackgroundServiceFixture {
ctx: TestAgentContext;
agent: FakeBackgroundAgent;
manager: BackgroundServiceTestManager;
persistence?: BackgroundTaskPersistence;
persistence?: ReturnType<typeof createBackgroundTaskPersistence>;
}
type TestContextMessage = {
@ -162,33 +176,39 @@ function createBackgroundManager(options: {
const track = vi.fn();
const telemetry = recordingTelemetry([]);
vi.spyOn(telemetry, 'track').mockImplementation(track);
const hookEngine: TestAgentOptions['hookEngine'] = options.hooks === undefined
const hookEngine: Pick<HookEngine, 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger'> | undefined = options.hooks === undefined
? undefined
: {
trigger: vi.fn().mockResolvedValue([]),
triggerBlock: vi.fn().mockResolvedValue(undefined),
fireAndForgetTrigger: options.hooks.fireAndForgetTrigger,
};
const ctx = testAgent({
telemetry,
background: {
persistence:
options.sessionDir === undefined
? undefined
: new BackgroundTaskPersistence(options.sessionDir),
maxRunningTasks: options.maxRunningTasks,
},
hookEngine,
});
const overrides: TestAgentServiceOverride[] = [telemetryServices(telemetry)];
if (options.sessionDir !== undefined) {
overrides.push(homeDirServices(options.sessionDir));
}
const maxRunningTasks = options.maxRunningTasks;
if (maxRunningTasks !== undefined) {
overrides.push(configServices(() => ({
providers: {},
background: { maxRunningTasks },
})));
}
if (hookEngine !== undefined) {
overrides.push(externalHookServices(hookEngine));
}
const ctx = testAgent(...overrides);
ctx.configure();
const emittedEvents: Array<{ type: string; info?: unknown }> = [];
const disposable = ctx.events.on((event) => {
const events = ctx.get(IEventSink);
const disposable = events.on((event) => {
emittedEvents.push(event as { type: string; info?: unknown });
});
const steerSpy = vi.spyOn(ctx.get(IPromptService), 'steer').mockReturnValue(undefined);
const spliceHistorySpy = vi.spyOn(ctx.context, 'splice');
const context = ctx.get(IContextMemory);
const spliceHistorySpy = vi.spyOn(context, 'splice');
const agent: FakeBackgroundAgent = {
emittedEvents,
@ -208,12 +228,12 @@ function createBackgroundManager(options: {
const persistence =
options.sessionDir === undefined
? undefined
: new BackgroundTaskPersistence(options.sessionDir);
: createBackgroundTaskPersistence(options.sessionDir);
return {
ctx,
agent,
manager: ctx.background as BackgroundServiceTestManager,
manager: ctx.get(IBackgroundService) as BackgroundServiceTestManager,
persistence,
};
}
@ -348,7 +368,7 @@ describe('BackgroundManager — event emission', () => {
it('emits background.task.terminated when a restored task is marked lost', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-reconcile-'));
try {
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(
persistedProcess({
taskId: 'bash-orphan00',
@ -456,7 +476,7 @@ describe('BackgroundManager — notification delivery', () => {
it('replays restored terminal agent task notifications when undelivered', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-'));
try {
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedAgent());
await persistence.appendTaskOutput('agent-done0000', 'restored subagent summary');
const { agent, manager } = createBackgroundManager({ sessionDir });
@ -488,7 +508,7 @@ describe('BackgroundManager — notification delivery', () => {
it('replays restored terminal process task notifications when undelivered', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-replay-'));
try {
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedProcess());
await persistence.appendTaskOutput('bash-done0000', 'restored shell output');
const { agent, manager } = createBackgroundManager({ sessionDir });
@ -522,7 +542,7 @@ describe('BackgroundManager — notification delivery', () => {
try {
const taskId = 'bash-large000';
const largeOutput = `early-output-marker\n${'x'.repeat(8_000)}\nfinal output line`;
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedProcess({ taskId }));
await persistence.appendTaskOutput(taskId, largeOutput);
const { agent, manager } = createBackgroundManager({ sessionDir });
@ -553,11 +573,12 @@ describe('BackgroundManager — notification delivery', () => {
status: 'completed',
notificationId: 'task:agent-seen0000:completed',
} as const;
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(persistedAgent({ taskId: 'agent-seen0000' }));
await persistence.appendTaskOutput('agent-seen0000', 'already delivered summary');
const { agent, ctx, manager } = createBackgroundManager({ sessionDir });
ctx.context.splice(ctx.context.getHistory().length, 0, [
const context = ctx.get(IContextMemory);
context.splice(context.get().length, 0, [
{
role: 'user',
content: [{ type: 'text', text: 'already delivered' }],
@ -581,7 +602,7 @@ describe('BackgroundManager — notification delivery', () => {
it('does not double-notify newly lost restored agent tasks', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-lost-'));
try {
const persistence = new BackgroundTaskPersistence(sessionDir);
const persistence = createBackgroundTaskPersistence(sessionDir);
await persistence.writeTask(
persistedAgent({
taskId: 'agent-run00000',

View file

@ -12,12 +12,13 @@ export type BackgroundServiceTestManager = IBackgroundService & {
reconcile(): Promise<readonly BackgroundTaskInfo[]>;
};
export const BACKGROUND_TEST_SESSION_SCOPE = 'sessions/test-workspace/test-session';
export function createBackgroundTaskPersistence(homedir: string): BackgroundTaskPersistence {
const sessionScope = 'sessions/test-workspace/test-session';
const storage = new FileStorageService(homedir);
return new BackgroundTaskPersistence(
join(homedir, sessionScope),
sessionScope,
join(homedir, BACKGROUND_TEST_SESSION_SCOPE),
BACKGROUND_TEST_SESSION_SCOPE,
new AtomicDocumentStore(storage),
storage,
);

View file

@ -1,28 +1,44 @@
import type { Message } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { IContextMemory, IContextSizeService, IProfileService } from '#/index';
import { project } from '#/contextProjector';
import type { ContextMessage } from '#/contextMemory';
import { renderNotificationXml } from '#/contextMemory/notification-xml';
import { testAgent } from '../harness';
import { createTestAgent, type TestAgentContext } from '../harness';
describe('Agent context', () => {
it('stores prompt origins without leaking them to LLM projection', () => {
const ctx = testAgent();
ctx.configure();
let ctx: TestAgentContext;
let context: IContextMemory;
let contextSize: IContextSizeService;
let profile: IProfileService;
beforeEach(() => {
ctx = createTestAgent();
context = ctx.get(IContextMemory);
contextSize = ctx.get(IContextSizeService);
profile = ctx.get(IProfileService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('stores prompt origins without leaking them to LLM projection', () => {
ctx.appendUserMessage([{ type: 'text', text: 'hello' }]);
ctx.appendSystemReminder('Remember this.', { kind: 'injection', variant: 'host' });
ctx.context.splice(ctx.context.get().length, 0, [
context.splice(context.get().length, 0, [
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_origin', name: 'Run', arguments: '{}' }],
},
]);
ctx.context.splice(ctx.context.get().length, 0, [
context.splice(context.get().length, 0, [
{
role: 'tool',
content: [{ type: 'text', text: 'tool output' }],
@ -31,7 +47,7 @@ describe('Agent context', () => {
},
]);
expect(ctx.context.get().map(({ role, origin }) => ({ role, origin }))).toEqual([
expect(context.get().map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'user' } },
{ role: 'user', origin: { kind: 'injection', variant: 'host' } },
{ role: 'assistant', origin: undefined },
@ -41,10 +57,7 @@ describe('Agent context', () => {
});
it('renders tool error and empty-output status as model-visible text', () => {
const ctx = testAgent();
ctx.configure();
ctx.context.splice(ctx.context.get().length, 0, [
context.splice(context.get().length, 0, [
{
role: 'assistant',
content: [],
@ -54,7 +67,7 @@ describe('Agent context', () => {
],
},
]);
ctx.context.splice(ctx.context.get().length, 0, [
context.splice(context.get().length, 0, [
{
role: 'tool',
content: [
@ -64,7 +77,7 @@ describe('Agent context', () => {
toolCallId: 'call_error',
},
]);
ctx.context.splice(ctx.context.get().length, 0, [
context.splice(context.get().length, 0, [
{
role: 'tool',
content: [{ type: 'text', text: '<system>Tool output is empty.</system>' }],
@ -184,11 +197,8 @@ describe('Agent context', () => {
});
it('projects hook result messages into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
ctx.appendUserMessage([{ type: 'text', text: 'hooked input' }]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'user',
content: [
{
@ -199,7 +209,7 @@ describe('Agent context', () => {
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
}]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'assistant',
content: [
{
@ -210,14 +220,14 @@ describe('Agent context', () => {
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true },
}]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'user',
content: [{ type: 'text', text: 'continue from stop hook' }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'Stop' },
}]);
expect(ctx.context.get()).toHaveLength(4);
expect(context.get()).toHaveLength(4);
expect(ctx.project()).toEqual([
{
role: 'user',
@ -250,15 +260,11 @@ describe('Agent context', () => {
toolCalls: [],
},
]);
await ctx.expectResumeMatches();
});
it('projects blocked UserPromptSubmit prompts into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
ctx.appendUserMessage([{ type: 'text', text: 'blocked prompt' }]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'assistant',
content: [
{
@ -271,7 +277,7 @@ describe('Agent context', () => {
}]);
ctx.appendUserMessage([{ type: 'text', text: 'safe followup' }]);
expect(ctx.context.get()).toHaveLength(3);
expect(context.get()).toHaveLength(3);
expect(ctx.project()).toEqual([
{
role: 'user',
@ -294,13 +300,10 @@ describe('Agent context', () => {
toolCalls: [],
},
]);
await ctx.expectResumeMatches();
});
it('projects user, assistant, tool call, and tool result records into LLM history', async () => {
const ctx = testAgent();
ctx.configure();
ctx.profile.update({ activeToolNames: [] });
profile.update({ activeToolNames: [] });
ctx.appendAssistantText(1, 'earlier assistant');
ctx.appendToolExchange();
@ -319,13 +322,10 @@ describe('Agent context', () => {
tool[call_lookup]: text "lookup result"
user: text "continue"
`);
await ctx.expectResumeMatches();
});
it('keeps system reminders separate from real user prompts', async () => {
const ctx = testAgent();
ctx.configure();
ctx.profile.update({ activeToolNames: [] });
profile.update({ activeToolNames: [] });
ctx.appendSystemReminder('Remember the host note.', {
kind: 'injection',
variant: 'host',
@ -345,11 +345,8 @@ describe('Agent context', () => {
});
it('defers system reminders until pending tool results are recorded and resumed', async () => {
const ctx = testAgent();
ctx.configure();
ctx.appendUserMessage([{ type: 'text', text: 'load a skill' }]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'assistant',
content: [],
toolCalls: [
@ -357,7 +354,7 @@ describe('Agent context', () => {
{ type: 'function', id: 'call_skill', name: 'Skill', arguments: '{}' },
],
}]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'user',
content: [{ type: 'text', text: '<system-reminder>\nskill body\n</system-reminder>' }],
toolCalls: [],
@ -371,7 +368,7 @@ describe('Agent context', () => {
// Raw history records the reminder in insertion order, behind the open
// exchange.
expect(ctx.context.get().map((message) => message.role)).toEqual([
expect(context.get().map((message) => message.role)).toEqual([
'user',
'assistant',
'user',
@ -386,7 +383,7 @@ describe('Agent context', () => {
'user',
]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'tool',
content: [{ type: 'text', text: 'wrote file' }],
toolCalls: [],
@ -401,7 +398,7 @@ describe('Agent context', () => {
'user',
]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'tool',
content: [{ type: 'text', text: 'skill loaded' }],
toolCalls: [],
@ -418,13 +415,9 @@ describe('Agent context', () => {
expect(ctx.project()[4]?.content).toEqual([
{ type: 'text', text: '<system-reminder>\nskill body\n</system-reminder>' },
]);
await ctx.expectResumeMatches();
});
it('preserves deferred reminders when compaction keeps a pending tool exchange', async () => {
const ctx = testAgent();
ctx.configure();
ctx.appendUserMessage([{ type: 'text', text: 'old prompt' }]);
ctx.appendContextPartiallyResolvedParallelToolExchange();
@ -432,7 +425,7 @@ describe('Agent context', () => {
kind: 'injection',
variant: 'host',
});
ctx.context.splice(0, 1, [{
context.splice(0, 1, [{
role: 'assistant',
content: [{ type: 'text', text: 'summary of old prompt' }],
toolCalls: [],
@ -455,7 +448,7 @@ describe('Agent context', () => {
'user',
]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'tool',
content: [{ type: 'text', text: 'two result' }],
toolCalls: [],
@ -477,13 +470,10 @@ describe('Agent context', () => {
expect(ctx.project()[6]?.content).toEqual([
{ type: 'text', text: '<system-reminder>\nsecond reminder\n</system-reminder>' },
]);
await ctx.expectResumeMatches();
});
it('clears context before the next LLM request', async () => {
const ctx = testAgent();
ctx.configure();
ctx.profile.update({ activeToolNames: [] });
profile.update({ activeToolNames: [] });
ctx.appendUserMessage([{ type: 'text', text: 'stale user message' }]);
await ctx.rpc.clearContext({});
@ -497,16 +487,13 @@ describe('Agent context', () => {
messages:
user: text "fresh prompt"
`);
await ctx.expectResumeMatches();
});
it('uses compacted summary plus recent messages', async () => {
const ctx = testAgent();
ctx.configure();
ctx.profile.update({ activeToolNames: [] });
profile.update({ activeToolNames: [] });
ctx.appendUserMessage([{ type: 'text', text: 'old user message' }]);
ctx.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
ctx.context.splice(
context.splice(
0,
1,
[
@ -519,7 +506,7 @@ describe('Agent context', () => {
],
20,
);
expect(ctx.context.get()[0]?.origin).toEqual({ kind: 'compaction_summary' });
expect(context.get()[0]?.origin).toEqual({ kind: 'compaction_summary' });
ctx.mockNextResponse({ type: 'text', text: 'after compaction' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'new prompt' }] });
@ -533,36 +520,31 @@ describe('Agent context', () => {
user: text "recent user message"
user: text "new prompt"
`);
await ctx.expectResumeMatches();
});
it('includes new user messages as pending until the next usage update', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000);
expect(ctx.contextSize.getStatus().contextTokens).toBe(1_000);
expect(contextSize.getStatus().contextTokens).toBe(1_000);
ctx.appendUserMessage([{ type: 'text', text: 'next user prompt'.repeat(20) }]);
const pendingMessages = ctx.context.get().slice(-1);
expect(ctx.contextSize.getStatus().contextTokensWithPending).toBe(
ctx.contextSize.getStatus().contextTokens + estimateTokensForMessages(pendingMessages),
const pendingMessages = context.get().slice(-1);
expect(contextSize.getStatus().contextTokensWithPending).toBe(
contextSize.getStatus().contextTokens + estimateTokensForMessages(pendingMessages),
);
});
it('keeps tool results pending when step usage covers only through the assistant message', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendUserMessage([{ type: 'text', text: 'lookup pending tokens' }]);
ctx.context.splice(ctx.context.get().length, 0, [
context.splice(context.get().length, 0, [
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_pending_tokens', name: 'Lookup', arguments: '{}' }],
},
]);
ctx.contextSize.measured(ctx.context.get().length, 1_280);
ctx.context.splice(ctx.context.get().length, 0, [
contextSize.measured(context.get().length, 1_280);
context.splice(context.get().length, 0, [
{
role: 'tool',
content: [{ type: 'text', text: 'large tool result '.repeat(50) }],
@ -571,36 +553,31 @@ describe('Agent context', () => {
},
]);
const pendingMessages = ctx.context.get().slice(-1);
expect(ctx.contextSize.getStatus().contextTokens).toBe(1_280);
expect(ctx.contextSize.getStatus().contextTokensWithPending).toBe(
const pendingMessages = context.get().slice(-1);
expect(contextSize.getStatus().contextTokens).toBe(1_280);
expect(contextSize.getStatus().contextTokensWithPending).toBe(
1_280 + estimateTokensForMessages(pendingMessages),
);
});
it('keeps zero-usage steps pending instead of zeroing tokenCount', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000);
expect(ctx.contextSize.getStatus().contextTokens).toBe(1_000);
expect(contextSize.getStatus().contextTokens).toBe(1_000);
ctx.appendUserMessage([{ type: 'text', text: 'next prompt' }]);
expect(ctx.contextSize.getStatus().contextTokens).toBe(1_000);
expect(ctx.contextSize.getStatus().contextTokensWithPending).toBeGreaterThanOrEqual(
ctx.contextSize.getStatus().contextTokens,
expect(contextSize.getStatus().contextTokens).toBe(1_000);
expect(contextSize.getStatus().contextTokensWithPending).toBeGreaterThanOrEqual(
contextSize.getStatus().contextTokens,
);
});
it('undo only counts real user prompts, skipping background notifications', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantText(1, 'first response');
ctx.appendAssistantText(2, 'second response');
// Append a background task notification (role: 'user' but not a real prompt)
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'user',
content: [{ type: 'text', text: 'background task completed' }],
toolCalls: [],
@ -612,7 +589,7 @@ describe('Agent context', () => {
},
}]);
expect(ctx.context.get().map((m) => m.role)).toEqual([
expect(context.get().map((m) => m.role)).toEqual([
'user',
'assistant',
'user',
@ -623,14 +600,12 @@ describe('Agent context', () => {
ctx.undoHistory(1);
// Should remove the background notification, the second assistant, and the second user prompt
expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'assistant']);
expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']);
});
it('stops at compaction summary and records the requested undo count', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendUserMessage([{ type: 'text', text: 'old user message' }]);
ctx.context.splice(
context.splice(
0,
1,
[
@ -644,7 +619,7 @@ describe('Agent context', () => {
20,
);
ctx.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'assistant',
content: [{ type: 'text', text: 'recent answer' }],
toolCalls: [],
@ -658,7 +633,7 @@ describe('Agent context', () => {
'Cannot undo 2 prompts; only 1 prompt can be undone in the active context after the last compaction.',
);
expect(ctx.context.get()).toEqual([
expect(context.get()).toEqual([
expect.objectContaining({
role: 'assistant',
origin: { kind: 'compaction_summary' },
@ -674,54 +649,67 @@ describe('Agent context', () => {
);
});
it('does not throw while restoring an undo that stops at compaction summary', async () => {
const ctx = testAgent();
ctx.configure();
it('restores a compacted history with later messages removed', async () => {
await expect(
ctx.wireRecord.restore([
{ type: 'metadata', protocol_version: '1.4', created_at: 1 },
ctx.restore([
{
type: 'context.append_message',
message: {
type: 'context.splice',
start: 0,
deleteCount: 0,
messages: [{
role: 'user',
content: [{ type: 'text', text: 'old user message' }],
toolCalls: [],
origin: { kind: 'user' },
},
}],
time: 1,
},
{
type: 'context.apply_compaction',
summary: 'summary of compacted context',
compactedCount: 1,
tokensBefore: 100,
tokensAfter: 20,
type: 'context.splice',
start: 0,
deleteCount: 1,
messages: [{
role: 'assistant',
content: [{ type: 'text', text: 'summary of compacted context' }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
}],
tokens: 20,
time: 2,
},
{
type: 'context.append_message',
message: {
type: 'context.splice',
start: 1,
deleteCount: 0,
messages: [{
role: 'user',
content: [{ type: 'text', text: 'recent user message' }],
toolCalls: [],
origin: { kind: 'user' },
},
}],
time: 3,
},
{
type: 'context.append_message',
message: {
type: 'context.splice',
start: 2,
deleteCount: 0,
messages: [{
role: 'assistant',
content: [{ type: 'text', text: 'recent answer' }],
toolCalls: [],
},
}],
time: 4,
},
{ type: 'context.undo', count: 2, time: 5 },
{
type: 'context.splice',
start: 1,
deleteCount: 2,
messages: [],
time: 5,
},
]),
).resolves.not.toThrow();
expect(ctx.context.get()).toEqual([
expect(context.get()).toEqual([
expect.objectContaining({
role: 'assistant',
origin: { kind: 'compaction_summary' },
@ -731,15 +719,12 @@ describe('Agent context', () => {
});
it('preserves injection messages when undo removes the surrounding turn', () => {
const ctx = testAgent();
ctx.configure();
ctx.context.splice(ctx.context.get().length, 0, [userMessage('do the work', { kind: 'user' })]);
ctx.context.splice(ctx.context.get().length, 0, [userMessage('Plan mode is active', {
context.splice(context.get().length, 0, [userMessage('do the work', { kind: 'user' })]);
context.splice(context.get().length, 0, [userMessage('Plan mode is active', {
kind: 'injection',
variant: 'plan_mode',
})]);
ctx.context.splice(ctx.context.get().length, 0, [{
context.splice(context.get().length, 0, [{
role: 'assistant',
content: [{ type: 'text', text: 'work done' }],
toolCalls: [],
@ -748,7 +733,7 @@ describe('Agent context', () => {
ctx.undoHistory(1);
expect(ctx.context.get()).toEqual([
expect(context.get()).toEqual([
expect.objectContaining({
role: 'user',
origin: { kind: 'injection', variant: 'plan_mode' },
@ -756,9 +741,7 @@ describe('Agent context', () => {
]);
});
});
describe('Agent context notification projection', () => {
describe('notification projection', () => {
it('renders task notifications with escaped attributes and generic children', () => {
const text = renderNotificationXml({
id: 'n_"1&2',
@ -885,6 +868,7 @@ describe('Agent context notification projection', () => {
expect(textOf(messages[1]!)).toBe('No origin prompt');
expect(textOf(messages[2]!)).toBe('Third real prompt');
});
});
});
function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {
@ -898,7 +882,7 @@ function userMessage(text: string, origin?: ContextMessage['origin']): ContextMe
function textOf(message: Message): string {
return message.content
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
.filter((part): part is { type: 'text'; text: string; } => part.type === 'text')
.map((part) => part.text)
.join('');
}

View file

@ -9,72 +9,99 @@ import {
CronCreateTool,
type CronCreateInput,
} from '#/cron/tools/cron-create';
import { testAgent, type TestAgentContext } from '../harness';
import { ICronService } from '#/cron';
import { IProfileService } from '#/profile';
import { IToolRegistry } from '#/toolRegistry';
import { createTestAgent, type TestAgentContext } from '../harness';
describe('Agent + Cron integration (P1.7)', () => {
let ctx: TestAgentContext;
describe('default cron wiring', () => {
let ctx: TestAgentContext;
let cron: ICronService;
let profile: IProfileService;
beforeEach(() => {
ctx = testAgent();
// `configure({ tools: [...] })` triggers `agent.config.update(...)`,
// which is the only path that calls `initializeBuiltinTools()`.
// Listing all three cron tools turns them on in `enabledTools` so
// `agent.tools.data()[i].active` is true — useful for callers that
// want to confirm the model would actually see the tool, not just
// that we registered it.
ctx.configure({ tools: ['CronCreate', 'CronList', 'CronDelete'] });
beforeEach(() => {
ctx = createTestAgent();
cron = ctx.get(ICronService);
profile = ctx.get(IProfileService);
profile.update({ activeToolNames: ['CronCreate', 'CronList', 'CronDelete'] });
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
vi.unstubAllEnvs();
}
});
it('exposes agent.cron with its session store on construction', () => {
expect(cron).toBeDefined();
expect(cron.store).toBeDefined();
expect(cron.store.list()).toEqual([]);
});
it('registers CronCreate / CronList / CronDelete in the tool manager', () => {
const toolNames = ctx.toolsData().map((info) => info.name);
expect(toolNames).toContain('CronCreate');
expect(toolNames).toContain('CronList');
expect(toolNames).toContain('CronDelete');
// All three came in through the builtin barrel.
for (const name of ['CronCreate', 'CronList', 'CronDelete'] as const) {
const info = ctx.toolsData().find((i) => i.name === name);
expect(info?.source).toBe('builtin');
expect(info?.active).toBe(true);
}
});
});
afterEach(async () => {
await ctx.cron.stop();
vi.unstubAllEnvs();
});
describe('disabled cron config', () => {
let ctx: TestAgentContext;
let cron: ICronService;
let profile: IProfileService;
let tools: IToolRegistry;
it('exposes agent.cron with its session store on construction', () => {
expect(ctx.cron).toBeDefined();
expect(ctx.cron!.store).toBeDefined();
expect(ctx.cron!.store.list()).toEqual([]);
});
beforeEach(() => {
vi.stubEnv('KIMI_DISABLE_CRON', '1');
ctx = createTestAgent();
cron = ctx.get(ICronService);
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
profile.update({ activeToolNames: ['CronCreate'] });
});
it('registers CronCreate / CronList / CronDelete in the tool manager', () => {
const toolNames = ctx.toolsData().map((info) => info.name);
expect(toolNames).toContain('CronCreate');
expect(toolNames).toContain('CronList');
expect(toolNames).toContain('CronDelete');
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
vi.unstubAllEnvs();
}
});
// All three came in through the builtin barrel.
for (const name of ['CronCreate', 'CronList', 'CronDelete'] as const) {
const info = ctx.toolsData().find((i) => i.name === name);
expect(info?.source).toBe('builtin');
expect(info?.active).toBe(true);
}
});
it('short-circuits CronCreate with a disabled error', () => {
const tool = tools.resolve('CronCreate') as CronCreateTool | undefined;
expect(tool).toBeDefined();
const args: CronCreateInput = {
cron: '*/5 * * * *',
prompt: 'x',
recurring: true,
};
const result = tool!.resolveExecution(args);
it('KIMI_DISABLE_CRON=1 short-circuits CronCreate with a disabled error', async () => {
await ctx.cron.stop();
vi.stubEnv('KIMI_DISABLE_CRON', '1');
ctx = testAgent();
ctx.configure({ tools: ['CronCreate'] });
// resolveExecution returns a `ToolExecution` — when it errors
// up-front the shape is `{ isError: true, output: string }` with no
// `execute` callback (see CronCreate's killswitch branch).
expect(result).toMatchObject({ isError: true });
expect('output' in result ? result.output : '').toMatch(/disabled/i);
expect('execute' in result ? typeof result.execute : 'no-execute').toBe(
'no-execute',
);
const tool = ctx.tools.resolve('CronCreate') as CronCreateTool | undefined;
expect(tool).toBeDefined();
const args: CronCreateInput = {
cron: '*/5 * * * *',
prompt: 'x',
recurring: true,
};
const result = tool!.resolveExecution(args);
// resolveExecution returns a `ToolExecution` — when it errors
// up-front the shape is `{ isError: true, output: string }` with no
// `execute` callback (see CronCreate's killswitch branch).
expect(result).toMatchObject({ isError: true });
expect('output' in result ? result.output : '').toMatch(/disabled/i);
expect('execute' in result ? typeof result.execute : 'no-execute').toBe(
'no-execute',
);
// And no task slipped into the store.
expect(ctx.cron!.store.list()).toEqual([]);
// And no task slipped into the store.
expect(cron.store.list()).toEqual([]);
});
});
});

View file

@ -12,11 +12,10 @@ import { CronCreateTool } from '#/cron/tools/cron-create';
import { CronDeleteTool } from '#/cron/tools/cron-delete';
import { CronListTool } from '#/cron/tools/cron-list';
import type { ExecutableToolOutput } from '#/tool';
import {
IPromptService,
type ContextMessage,
} from '#/index';
import { testAgent, type TestAgentContext } from '../harness';
import type { ContextMessage } from '#/contextMemory';
import { ICronService } from '#/cron';
import { IPromptService } from '#/prompt';
import { createTestAgent, cronServices, type TestAgentContext } from '../harness';
// Local-time anchor (cron-expr matches on local fields, so a UTC anchor
// would shift the result by the host's offset). At noon + 15 min the
@ -50,6 +49,8 @@ function outputText(out: ExecutableToolOutput): string {
describe('Cron — session E2E (P1.9)', () => {
let ctx: TestAgentContext;
let cron: ICronService;
let prompt: IPromptService;
let harness: ReturnType<typeof createClocks>;
beforeEach(() => {
@ -61,24 +62,23 @@ describe('Cron — session E2E (P1.9)', () => {
// that widens the jitter window past 10 minutes.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
harness = createClocks();
ctx = testAgent({
cron: {
autoStart: false,
clocks: harness.clocks,
pollIntervalMs: null,
},
});
ctx.configure();
ctx.cron.start();
ctx = createTestAgent(cronServices({
autoStart: false,
clocks: harness.clocks,
pollIntervalMs: null,
}));
cron = ctx.get(ICronService);
prompt = ctx.get(IPromptService);
cron.start();
});
afterEach(async () => {
// The harness's `onTestFinished` cleanup already calls
// `ctx.close()`, but doing it here as well keeps the test
// self-contained against future harness changes and ensures the
// SIGUSR1 handler (if any) is unbound before the next test.
await ctx.cron.stop();
vi.unstubAllEnvs();
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
vi.unstubAllEnvs();
}
});
it('recurring */5 task advances 15min → exactly one steer with coalescedCount=3', async () => {
@ -88,7 +88,7 @@ describe('Cron — session E2E (P1.9)', () => {
readonly content: readonly unknown[];
readonly origin: unknown;
}> = [];
vi.spyOn(ctx.get(IPromptService), 'steer').mockImplementation((message: ContextMessage) => {
vi.spyOn(prompt, 'steer').mockImplementation((message: ContextMessage) => {
steerCalls.push({ content: message.content, origin: message.origin });
return {
id: 1,
@ -104,7 +104,7 @@ describe('Cron — session E2E (P1.9)', () => {
// bypass `emitScheduled` telemetry and skip the byte-length /
// expression checks; that would not be the production code path
// this commit is meant to smoke.
const createTool = new CronCreateTool(ctx.cron);
const createTool = new CronCreateTool(cron);
const execution = createTool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'cron-fired prompt',
@ -121,13 +121,13 @@ describe('Cron — session E2E (P1.9)', () => {
signal: new AbortController().signal,
});
expect(createResult.isError ?? false).toBe(false);
expect(ctx.cron.list().length).toBe(1);
expect(cron.list().length).toBe(1);
// Advance 15 minutes — exactly three ideal */5 fires across the gap
// (12:05, 12:10, 12:15). See the file header for the calibration
// derivation.
harness.advance(15 * 60_000);
ctx.cron.tick();
cron.tick();
// ── Steer was called exactly once ─────────────────────────────────
expect(steerCalls.length).toBe(1);
@ -157,9 +157,9 @@ describe('Cron — session E2E (P1.9)', () => {
// Optional second case from the P1.9 plan: prove the three-tool
// surface composes correctly end-to-end on the real manager. No
// clock manipulation needed — list/delete are time-invariant.
const createTool = new CronCreateTool(ctx.cron);
const listTool = new CronListTool(ctx.cron);
const deleteTool = new CronDeleteTool(ctx.cron);
const createTool = new CronCreateTool(cron);
const listTool = new CronListTool(cron);
const deleteTool = new CronDeleteTool(cron);
const ctxArgs = {
turnId: 'p19-tools',
toolCallId: 'p19-tools-call',

View file

@ -5,8 +5,10 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IPromptService, type ContextMessage } from '#/index';
import { testAgent } from '../harness';
import type { ContextMessage } from '#/contextMemory';
import { ICronService } from '#/cron';
import { IPromptService } from '#/prompt';
import { createTestAgent, cronServices, type TestAgentContext } from '../harness';
const WALL_ANCHOR = 1_700_000_000_000;
@ -34,8 +36,8 @@ function createClocks(initial: number = WALL_ANCHOR): ClockHarness {
};
}
function spySteer(ctx: ReturnType<typeof testAgent>) {
return vi.spyOn(ctx.get(IPromptService), 'steer').mockImplementation((_message: ContextMessage) => ({
function spySteer(prompt: IPromptService) {
return vi.spyOn(prompt, 'steer').mockImplementation((_message: ContextMessage) => ({
id: 1,
abortController: new AbortController(),
ready: Promise.resolve(),
@ -55,206 +57,240 @@ describe('CronService — P1.8 manual tick + SIGUSR1', () => {
});
describe('KIMI_CRON_MANUAL_TICK=1', () => {
it('does not install setInterval; tick() must be called manually', async () => {
let ctx: TestAgentContext;
let cron: ICronService;
let prompt: IPromptService;
let harness: ClockHarness;
beforeEach(() => {
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
harness = createClocks();
ctx = createTestAgent(cronServices({
autoStart: true,
pollIntervalMs: 50,
clocks: harness.clocks,
}));
cron = ctx.get(ICronService);
prompt = ctx.get(IPromptService);
});
const harness = createClocks();
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: 50, clocks: harness.clocks },
});
const steerSpy = spySteer(ctx);
afterEach(async () => {
try {
ctx.cron.start();
ctx.cron.addTask({ cron: '*/5 * * * *', prompt: 'manual-only' });
harness.advance(6 * 60_000);
// Real-time wait: if an interval were registered, 50ms is more
// than enough to fire at least once. We do NOT use fake timers
// here because the whole point is to prove no timer exists.
await new Promise((r) => setTimeout(r, 50));
expect(steerSpy).toHaveBeenCalledTimes(0);
// Manual drive → fires.
ctx.cron.tick();
expect(steerSpy).toHaveBeenCalledTimes(1);
await ctx.expectResumeMatches();
} finally {
await ctx.cron.stop();
await ctx.dispose();
}
});
it('does not install setInterval; tick() must be called manually', async () => {
const steerSpy = spySteer(prompt);
cron.start();
cron.addTask({ cron: '*/5 * * * *', prompt: 'manual-only' });
harness.advance(6 * 60_000);
// Real-time wait: if an interval were registered, 50ms is more
// than enough to fire at least once. We do NOT use fake timers
// here because the whole point is to prove no timer exists.
await new Promise((r) => setTimeout(r, 50));
expect(steerSpy).toHaveBeenCalledTimes(0);
// Manual drive → fires.
cron.tick();
expect(steerSpy).toHaveBeenCalledTimes(1);
});
});
describe('without KIMI_CRON_MANUAL_TICK', () => {
it('auto-tick fires when fake timers advance past pollIntervalMs', async () => {
let ctx: TestAgentContext;
let cron: ICronService;
let prompt: IPromptService;
let harness: ClockHarness;
beforeEach(() => {
// Fake timers must be in place BEFORE the manager calls
// setInterval, otherwise the scheduler captures the real one.
vi.useFakeTimers();
harness = createClocks();
ctx = createTestAgent(cronServices({
autoStart: true,
pollIntervalMs: 50,
clocks: harness.clocks,
}));
cron = ctx.get(ICronService);
prompt = ctx.get(IPromptService);
});
const harness = createClocks();
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: 50, clocks: harness.clocks },
});
const steerSpy = spySteer(ctx);
afterEach(async () => {
try {
ctx.cron.start();
ctx.cron.addTask({ cron: '*/5 * * * *', prompt: 'auto-tick' });
// Move the injected wall clock past one ideal fire, then let the
// setInterval drain by advancing fake timers past one poll.
harness.advance(6 * 60_000);
vi.advanceTimersByTime(60);
expect(steerSpy).toHaveBeenCalledTimes(1);
await ctx.expectResumeMatches();
} finally {
await ctx.cron.stop();
await ctx.dispose();
}
});
it('auto-tick fires when fake timers advance past pollIntervalMs', () => {
const steerSpy = spySteer(prompt);
cron.start();
cron.addTask({ cron: '*/5 * * * *', prompt: 'auto-tick' });
// Move the injected wall clock past one ideal fire, then let the
// setInterval drain by advancing fake timers past one poll.
harness.advance(6 * 60_000);
vi.advanceTimersByTime(60);
expect(steerSpy).toHaveBeenCalledTimes(1);
});
});
describe('SIGUSR1', () => {
// SIGUSR1 binding is opt-in via KIMI_CRON_MANUAL_TICK=1 so that
// production (1 main agent + N subagents) doesn't pile up listeners
// and trip Node's MaxListenersExceededWarning cap. All four SIGUSR1
// tests stub the env before constructing the manager.
beforeEach(() => {
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
});
// and trip Node's MaxListenersExceededWarning cap.
describe('manual tick enabled', () => {
let ctx: TestAgentContext;
let cron: ICronService;
let listenerCountBeforeCreate: number;
it('triggers tick() once per emit (POSIX only)', async () => {
if (process.platform === 'win32') return;
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
beforeEach(() => {
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
listenerCountBeforeCreate = process.listenerCount('SIGUSR1');
ctx = createTestAgent(cronServices({ autoStart: true, pollIntervalMs: null }));
cron = ctx.get(ICronService);
});
try {
ctx.cron.start();
const spy = vi.spyOn(ctx.cron, 'tick');
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('triggers tick() once per emit (POSIX only)', () => {
if (process.platform === 'win32') return;
const spy = vi.spyOn(cron, 'tick');
process.emit('SIGUSR1', 'SIGUSR1');
expect(spy).toHaveBeenCalledTimes(1);
} finally {
await ctx.cron.stop();
}
});
it('swallows throws from tick() so the host process never crashes', async () => {
if (process.platform === 'win32') return;
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
});
try {
ctx.cron.start();
vi.spyOn(ctx.cron, 'tick').mockImplementation(() => {
it('swallows throws from tick() so the host process never crashes', () => {
if (process.platform === 'win32') return;
vi.spyOn(cron, 'tick').mockImplementation(() => {
throw new Error('boom');
});
// If the handler re-threw, this `emit` would propagate. The
// assertion below is the "no throw" side-effect.
expect(() => process.emit('SIGUSR1', 'SIGUSR1')).not.toThrow();
} finally {
await ctx.cron.stop();
}
});
it('does not write to stderr on tick() throw when KIMI_CRON_DEBUG is unset', () => {
if (process.platform === 'win32') return;
// KIMI_CRON_DEBUG intentionally NOT set in this test.
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
vi.spyOn(cron, 'tick').mockImplementation(() => {
throw new Error('silent-boom');
});
process.emit('SIGUSR1', 'SIGUSR1');
// No cron/service line was emitted because debug is off.
const calls = writeSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((s) => /cron\/service/.test(s))).toBe(false);
} finally {
writeSpy.mockRestore();
}
});
it('stop() removes the SIGUSR1 listener (no leak)', async () => {
if (process.platform === 'win32') return;
// Constructor auto-starts, which binds SIGUSR1 under KIMI_CRON_MANUAL_TICK=1.
expect(process.listenerCount('SIGUSR1')).toBe(listenerCountBeforeCreate + 1);
await cron.stop();
expect(process.listenerCount('SIGUSR1')).toBe(listenerCountBeforeCreate);
});
it('start() is idempotent — second call does not double-bind', () => {
if (process.platform === 'win32') return;
// Constructor already calls start() once; an explicit second
// call must not stack a handler.
cron.start();
expect(process.listenerCount('SIGUSR1')).toBe(listenerCountBeforeCreate + 1);
});
});
it('logs swallowed tick() throws to stderr when KIMI_CRON_DEBUG=1', async () => {
if (process.platform === 'win32') return;
vi.stubEnv('KIMI_CRON_DEBUG', '1');
describe('manual tick debug logging', () => {
let ctx: TestAgentContext;
let cron: ICronService;
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
beforeEach(() => {
vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1');
vi.stubEnv('KIMI_CRON_DEBUG', '1');
ctx = createTestAgent(cronServices({ autoStart: true, pollIntervalMs: null }));
cron = ctx.get(ICronService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('logs swallowed tick() throws to stderr when KIMI_CRON_DEBUG=1', () => {
if (process.platform === 'win32') return;
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
vi.spyOn(cron, 'tick').mockImplementation(() => {
throw new Error('debug-boom');
});
process.emit('SIGUSR1', 'SIGUSR1');
expect(writeSpy).toHaveBeenCalled();
const calls = writeSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((s) => /cron\/service.*SIGUSR1/.test(s))).toBe(
true,
);
expect(calls.some((s) => s.includes('debug-boom'))).toBe(true);
} finally {
writeSpy.mockRestore();
}
});
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
ctx.cron.start();
vi.spyOn(ctx.cron, 'tick').mockImplementation(() => {
throw new Error('debug-boom');
});
process.emit('SIGUSR1', 'SIGUSR1');
expect(writeSpy).toHaveBeenCalled();
const calls = writeSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((s) => /cron\/service.*SIGUSR1/.test(s))).toBe(
true,
);
expect(calls.some((s) => s.includes('debug-boom'))).toBe(true);
} finally {
writeSpy.mockRestore();
await ctx.cron.stop();
}
});
it('does not write to stderr on tick() throw when KIMI_CRON_DEBUG is unset', async () => {
if (process.platform === 'win32') return;
// KIMI_CRON_DEBUG intentionally NOT set in this test.
describe('manual tick disabled', () => {
let ctx: TestAgentContext;
let cron: ICronService;
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
beforeEach(() => {
ctx = createTestAgent(cronServices({ autoStart: true, pollIntervalMs: null }));
cron = ctx.get(ICronService);
});
const writeSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
try {
ctx.cron.start();
vi.spyOn(ctx.cron, 'tick').mockImplementation(() => {
throw new Error('silent-boom');
});
process.emit('SIGUSR1', 'SIGUSR1');
// No cron/service line was emitted because debug is off.
const calls = writeSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((s) => /cron\/service/.test(s))).toBe(false);
} finally {
writeSpy.mockRestore();
await ctx.cron.stop();
}
});
it('stop() removes the SIGUSR1 listener (no leak)', async () => {
if (process.platform === 'win32') return;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
// Constructor auto-starts, which binds SIGUSR1 under KIMI_CRON_MANUAL_TICK=1.
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
await ctx.cron.stop();
expect(process.listenerCount('SIGUSR1')).toBe(before);
});
it('start() is idempotent — second call does not double-bind', async () => {
if (process.platform === 'win32') return;
it('does not bind when KIMI_CRON_MANUAL_TICK is unset', () => {
if (process.platform === 'win32') return;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
});
// Constructor already calls start() once; an explicit second
// call must not stack a handler.
try {
ctx.cron.start();
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
} finally {
await ctx.cron.stop();
}
});
it('does not bind when KIMI_CRON_MANUAL_TICK is unset', async () => {
if (process.platform === 'win32') return;
// Override the describe-scope stub so the env is genuinely unset.
vi.unstubAllEnvs();
// Re-pin jitter so other describe-scope state stays consistent.
vi.stubEnv('KIMI_CRON_NO_JITTER', '1');
const ctx = testAgent({
cron: { autoStart: true, pollIntervalMs: null },
});
const before = process.listenerCount('SIGUSR1');
try {
ctx.cron.start();
const before = process.listenerCount('SIGUSR1');
cron.start();
expect(process.listenerCount('SIGUSR1')).toBe(before);
} finally {
await ctx.cron.stop();
}
});
});
});
});

View file

@ -16,7 +16,9 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { testAgent } from '../harness';
import { ICronService } from '#/cron';
import { IProfileService } from '#/profile';
import { createTestAgent, cronServices, type TestAgentContext } from '../harness';
const CRON_TOOL_NAMES = ['CronCreate', 'CronList', 'CronDelete'] as const;
@ -33,57 +35,110 @@ describe('Agent + Cron — subagent suppression', () => {
vi.unstubAllEnvs();
});
it("type='sub': cron exists, start() is skipped, tools not registered", () => {
if (process.platform === 'win32') return;
describe("type='sub'", () => {
let ctx: TestAgentContext;
let cron: ICronService;
let profile: IProfileService;
let listenerCountBeforeCreate: number;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'sub' });
beforeEach(() => {
listenerCountBeforeCreate = process.listenerCount('SIGUSR1');
ctx = createTestAgent(cronServices({ isSubagent: true }));
cron = ctx.get(ICronService);
profile = ctx.get(IProfileService);
});
// Subagents get a disabled CronService: no scheduler, no timers,
// no SIGUSR1 listener and no tools — the service-DI equivalent of
// the old `agent.cron === null`.
expect(ctx.cron.isEnabled).toBe(false);
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
// start() was not called — no SIGUSR1 binding accrued.
expect(process.listenerCount('SIGUSR1')).toBe(before);
it('cron exists, start() is skipped, tools not registered', () => {
if (process.platform === 'win32') return;
// Configure with the cron tool names in the whitelist; even with
// the LLM allowlist explicitly listing them, the BuiltinToolManager
// must not have constructed the instances for a subagent.
ctx.configure({ tools: [...CRON_TOOL_NAMES] });
const toolNames = ctx.toolsData().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).not.toContain(name);
}
// Subagents get a disabled CronService: no scheduler, no timers,
// no SIGUSR1 listener and no tools — the service-DI equivalent of
// the old `agent.cron === null`.
expect(cron.isEnabled).toBe(false);
// start() was not called — no SIGUSR1 binding accrued.
expect(process.listenerCount('SIGUSR1')).toBe(listenerCountBeforeCreate);
// Configure with the cron tool names in the whitelist; even with
// the LLM allowlist explicitly listing them, the BuiltinToolManager
// must not have constructed the instances for a subagent.
profile.update({ activeToolNames: [...CRON_TOOL_NAMES] });
const toolNames = ctx.toolsData().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).not.toContain(name);
}
});
});
it("type='main': start() runs, tools registered", () => {
if (process.platform === 'win32') return;
describe("type='main'", () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let listenerCountBeforeCreate: number;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'main' });
beforeEach(() => {
listenerCountBeforeCreate = process.listenerCount('SIGUSR1');
ctx = createTestAgent();
profile = ctx.get(IProfileService);
});
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
ctx.configure({ tools: [...CRON_TOOL_NAMES] });
const toolNames = ctx.toolsData().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).toContain(name);
}
it('start() runs, tools registered', () => {
if (process.platform === 'win32') return;
expect(process.listenerCount('SIGUSR1')).toBe(listenerCountBeforeCreate + 1);
profile.update({ activeToolNames: [...CRON_TOOL_NAMES] });
const toolNames = ctx.toolsData().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).toContain(name);
}
});
});
it("type='independent': start() runs, tools registered", () => {
if (process.platform === 'win32') return;
describe("type='independent'", () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let listenerCountBeforeCreate: number;
const before = process.listenerCount('SIGUSR1');
const ctx = testAgent({ type: 'independent' });
beforeEach(() => {
listenerCountBeforeCreate = process.listenerCount('SIGUSR1');
ctx = createTestAgent();
profile = ctx.get(IProfileService);
});
expect(process.listenerCount('SIGUSR1')).toBe(before + 1);
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
ctx.configure({ tools: [...CRON_TOOL_NAMES] });
const toolNames = ctx.toolsData().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).toContain(name);
}
it('start() runs, tools registered', () => {
if (process.platform === 'win32') return;
expect(process.listenerCount('SIGUSR1')).toBe(listenerCountBeforeCreate + 1);
profile.update({ activeToolNames: [...CRON_TOOL_NAMES] });
const toolNames = ctx.toolsData().map((info) => info.name);
for (const name of CRON_TOOL_NAMES) {
expect(toolNames).toContain(name);
}
});
});
});

View file

@ -1,64 +1,77 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ErrorCodes } from '#/errors';
import { IContextMemory } from '#/contextMemory';
import { IEventSink } from '#/eventSink';
import { IGoalService, type GoalService } from '#/goal';
import { IReplayBuilderService } from '#/replayBuilder';
import type { PersistedWireRecord, WireRecord } from '#/wireRecord';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import {
IEventBus,
IGoalService,
InMemoryWireRecordPersistence,
IReplayBuilderService,
type GoalService,
type PersistedWireRecord,
type WireRecord,
} from '#/index';
import { recordingTelemetry, type TelemetryRecord } from '../../fixtures/telemetry';
import { testAgent } from '../harness';
createTestAgent,
telemetryServices,
wireRecordPersistenceServices,
type TestAgentContext,
} from '../harness';
type GoalServiceTestManager = IGoalService & GoalService;
type GoalRecord = Extract<PersistedWireRecord, { type: `goal.${string}` }>;
type AgentEvent = Parameters<IEventBus['emit']>[0];
type AgentEvent = Parameters<IEventSink['emit']>[0];
type GoalUpdatedEvent = Extract<AgentEvent, { type: 'goal.updated' }>;
type GoalSnapshot = NonNullable<ReturnType<IGoalService['getGoal']>['goal']>;
type GoalChange = GoalUpdatedEvent['change'];
function makeGoalService() {
const persistence = new InMemoryWireRecordPersistence();
const events: Array<{ readonly type: string; readonly snapshot?: GoalSnapshot | null; readonly change?: GoalChange }> = [];
const telemetry: TelemetryRecord[] = [];
const ctx = testAgent({
persistence,
telemetry: recordingTelemetry(telemetry),
});
ctx.configure();
ctx.events.on((event) => {
if (event.type === 'goal.updated') events.push(event);
});
return {
ctx,
goals: ctx.get(IGoalService) as GoalServiceTestManager,
records: persistence.records,
replay: () => ctx.get(IReplayBuilderService).buildResult(),
events,
telemetry,
};
}
function goalRecords(records: readonly PersistedWireRecord[]): readonly GoalRecord[] {
return records.filter((record): record is GoalRecord => record.type.startsWith('goal.'));
}
async function restoreGoalRecords(
ctx: ReturnType<typeof testAgent>,
ctx: TestAgentContext,
goals: IGoalService,
records: readonly WireRecord[],
): Promise<void> {
ctx.get(IGoalService).getGoal();
await ctx.runtime.restore(records);
goals.getGoal();
await ctx.restore(records as readonly PersistedWireRecord[]);
}
describe('GoalService', () => {
let ctx: TestAgentContext;
let context: IContextMemory;
let goals: GoalServiceTestManager;
let records: PersistedWireRecord[];
let replayBuilder: IReplayBuilderService;
let events: Array<{ readonly type: string; readonly snapshot?: GoalSnapshot | null; readonly change?: GoalChange }>;
let telemetry: TelemetryRecord[];
beforeEach(() => {
const persistence = new InMemoryWireRecordPersistence();
telemetry = [];
events = [];
ctx = createTestAgent(
wireRecordPersistenceServices(persistence),
telemetryServices(recordingTelemetry(telemetry)),
);
context = ctx.get(IContextMemory);
goals = ctx.get(IGoalService) as GoalServiceTestManager;
records = persistence.records;
replayBuilder = ctx.get(IReplayBuilderService);
const eventSink = ctx.get(IEventSink);
eventSink.on((event) => {
if (event.type === 'goal.updated') events.push(event);
});
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
describe('GoalService creation', () => {
it('creates a goal and exposes it through getGoal', async () => {
const { goals } = makeGoalService();
const snapshot = await goals.createGoal({ objective: 'Ship feature X' });
expect(snapshot.objective).toBe('Ship feature X');
@ -67,8 +80,6 @@ describe('GoalService creation', () => {
});
it('stores a completion criterion when provided', async () => {
const { goals } = makeGoalService();
const snapshot = await goals.createGoal({
objective: 'Ship feature X',
completionCriterion: ' tests pass ',
@ -79,8 +90,6 @@ describe('GoalService creation', () => {
});
it('sets no default work caps when none is provided', async () => {
const { goals } = makeGoalService();
const snapshot = await goals.createGoal({ objective: 'Do work' });
expect(snapshot.budget.turnBudget).toBeNull();
@ -90,8 +99,6 @@ describe('GoalService creation', () => {
});
it('rejects empty and too-long objectives', async () => {
const { goals } = makeGoalService();
await expect(goals.createGoal({ objective: ' ' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_OBJECTIVE_EMPTY,
});
@ -101,8 +108,6 @@ describe('GoalService creation', () => {
});
it('rejects duplicate active, paused, and blocked goals without replace', async () => {
const { goals } = makeGoalService();
await goals.createGoal({ objective: 'first' });
await expect(goals.createGoal({ objective: 'second' })).rejects.toMatchObject({
code: ErrorCodes.GOAL_ALREADY_EXISTS,
@ -119,8 +124,6 @@ describe('GoalService creation', () => {
});
it('replaces an existing goal when replace is set', async () => {
const { goals, records } = makeGoalService();
const first = await goals.createGoal({ objective: 'first' });
const second = await goals.createGoal({ objective: 'second', replace: true });
@ -136,8 +139,6 @@ describe('GoalService creation', () => {
describe('GoalService lifecycle', () => {
it('emits typed lifecycle and completion changes', async () => {
const { goals, events } = makeGoalService();
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
expect(events.at(-1)?.change).toBeUndefined();
@ -155,8 +156,6 @@ describe('GoalService lifecycle', () => {
});
it('keeps blocked goals resumable', async () => {
const { goals } = makeGoalService();
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
const blocked = await goals.markBlocked({ reason: 'need creds' });
expect(blocked?.status).toBe('blocked');
@ -168,8 +167,6 @@ describe('GoalService lifecycle', () => {
});
it('pauseOnInterrupt parks active goals and no-ops for stopped goals', async () => {
const { goals } = makeGoalService();
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
const paused = await goals.pauseOnInterrupt({ reason: 'Paused after interruption' });
expect(paused?.status).toBe('paused');
@ -180,13 +177,11 @@ describe('GoalService lifecycle', () => {
});
it('cancelGoal discards the goal and throws when missing', async () => {
const { ctx, goals } = makeGoalService();
await goals.createGoal({ objective: 'work' });
const removed = await goals.cancelGoal();
expect(removed.status).toBe('active');
expect(goals.getGoal()).toEqual({ goal: null });
const reminder = ctx.context.getHistory().at(-1);
const reminder = context.get().at(-1);
expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_cancelled' });
expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders');
await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND });
@ -195,8 +190,6 @@ describe('GoalService lifecycle', () => {
describe('GoalService accounting and budgets', () => {
it('counts tokens and turns only while active', async () => {
const { goals } = makeGoalService();
await goals.createGoal({ objective: 'work' });
await goals.recordTokenUsage(30);
await goals.incrementTurn();
@ -209,8 +202,6 @@ describe('GoalService accounting and budgets', () => {
});
it('sets budget limits through SetGoalBudget-style updates', async () => {
const { goals } = makeGoalService();
await goals.createGoal({ objective: 'work' });
const snapshot = await goals.setBudgetLimits({
budgetLimits: { tokenBudget: 100, turnBudget: 2, wallClockBudgetMs: 1000 },
@ -222,8 +213,6 @@ describe('GoalService accounting and budgets', () => {
});
it('tracks telemetry without goal text', async () => {
const { goals, telemetry } = makeGoalService();
await goals.createGoal({ objective: 'private objective', replace: true });
await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 100 } }, 'model');
await goals.incrementTurn();
@ -251,8 +240,6 @@ describe('GoalService accounting and budgets', () => {
describe('GoalService records', () => {
it('records only replay-relevant create/update/clear fields', async () => {
const { goals, records } = makeGoalService();
await goals.createGoal({ objective: 'work', completionCriterion: 'tests pass' });
await goals.recordTokenUsage(5);
await goals.incrementTurn();
@ -291,9 +278,7 @@ describe('GoalService records', () => {
});
it('restores state from patch records', async () => {
const { ctx, goals } = makeGoalService();
await restoreGoalRecords(ctx, [
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
@ -319,9 +304,7 @@ describe('GoalService records', () => {
});
it('projects restored goal status changes into replay records', async () => {
const { ctx, replay } = makeGoalService();
await restoreGoalRecords(ctx, [
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
@ -346,7 +329,7 @@ describe('GoalService records', () => {
},
]);
expect(replay()).toEqual([
expect(replayBuilder.buildResult()).toEqual([
expect.objectContaining({
type: 'goal_updated',
snapshot: expect.objectContaining({ objective: 'work', status: 'active' }),
@ -382,9 +365,7 @@ describe('GoalService records', () => {
});
it('keeps resume-normalization pauses in core replay records', async () => {
const { ctx, replay } = makeGoalService();
await restoreGoalRecords(ctx, [
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
@ -398,7 +379,7 @@ describe('GoalService records', () => {
},
]);
expect(replay().at(-1)).toMatchObject({
expect(replayBuilder.buildResult().at(-1)).toMatchObject({
type: 'goal_updated',
snapshot: { status: 'paused', terminalReason: 'Paused after agent resume' },
change: {
@ -411,10 +392,8 @@ describe('GoalService records', () => {
});
it('normalizes active replayed goals to paused', async () => {
const { ctx, goals, records } = makeGoalService();
records.length = 0;
await restoreGoalRecords(ctx, [
await restoreGoalRecords(ctx, goals, [
{
type: 'goal.create',
goalId: 'g1',
@ -435,3 +414,4 @@ describe('GoalService records', () => {
]);
});
});
});

View file

@ -1,62 +1,33 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ToolCall } from '@moonshot-ai/kosong';
import { IContextInjector } from '#/contextInjector';
import { IContextMemory } from '#/contextMemory';
import {
GoalInjection,
IDynamicInjector,
IGoalService,
InMemoryWireRecordPersistence,
type DynamicInjectionProvider,
type GoalService,
} from '../../../../src/services/agent';
import { testAgent } from '../harness';
} from '#/goal';
import { IProfileService } from '#/profile';
import {
InMemoryWireRecordPersistence,
createTestAgent,
goalServices,
wireRecordPersistenceServices,
type TestAgentContext,
} from '../harness';
type GoalSnapshot = NonNullable<ReturnType<IGoalService['getGoal']>['goal']>;
type GoalServiceTestManager = IGoalService & GoalService;
type InjectableContextInjector = IContextInjector & { inject(): Promise<void> };
function createGoalInjectionReader(
getGoal: () => GoalSnapshot | null,
enabled?: () => boolean,
): {
read(): Promise<string | undefined>;
dispose(): void;
} {
let provider: DynamicInjectionProvider | undefined;
const dynamicInjector: IDynamicInjector = {
register: (variant, next) => {
expect(variant).toBe('goal');
provider = next;
return { dispose: () => undefined };
},
};
const injection = new GoalInjection({ getGoal, enabled }, dynamicInjector);
return {
read: async () => provider?.({ injectedAt: null }),
dispose: () => injection.dispose(),
};
async function injectDynamic(injector: InjectableContextInjector): Promise<void> {
await injector.inject();
}
async function readGoalReminder(
configure: (goals: GoalServiceTestManager) => Promise<void>,
): Promise<string | undefined> {
const ctx = testAgent();
ctx.configure();
const goals = ctx.get(IGoalService) as GoalServiceTestManager;
await configure(goals);
const reader = createGoalInjectionReader(() => goals.getGoal().goal);
try {
return await reader.read();
} finally {
reader.dispose();
}
}
async function injectDynamic(ctx: ReturnType<typeof testAgent>): Promise<void> {
await (ctx.get(IDynamicInjector) as unknown as { inject(): Promise<void> }).inject();
}
async function registerLookupTool(ctx: ReturnType<typeof testAgent>): Promise<void> {
ctx.configure({ tools: ['Lookup'] });
async function registerLookupTool(
ctx: TestAgentContext,
profile: IProfileService,
): Promise<void> {
profile.update({ activeToolNames: ['Lookup'] });
await ctx.rpc.registerTool({
name: 'Lookup',
description: 'Look up a short test value.',
@ -81,6 +52,34 @@ function lookupCall(): ToolCall {
}
describe('GoalInjection content', () => {
let ctx: TestAgentContext;
let goals: GoalServiceTestManager;
let context: IContextMemory;
let injector: InjectableContextInjector;
beforeEach(() => {
ctx = createTestAgent();
goals = ctx.get(IGoalService) as GoalServiceTestManager;
context = ctx.get(IContextMemory);
injector = ctx.get(IContextInjector) as InjectableContextInjector;
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
async function readGoalReminder(
configure: (goals: GoalServiceTestManager) => Promise<void>,
): Promise<string | undefined> {
await configure(goals);
await injectDynamic(injector);
return lastGoalReminder(context);
}
it('produces no injection when there is no current goal', async () => {
expect(await readGoalReminder(async () => undefined)).toBeUndefined();
});
@ -234,92 +233,119 @@ function goalReminderRecords(persistence: InMemoryWireRecordPersistence) {
);
}
function lastGoalReminder(context: IContextMemory): string | undefined {
const message = context.get().findLast((item) => {
return item.origin?.kind === 'injection' && item.origin.variant === 'goal';
});
if (message === undefined) return undefined;
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
}
describe('GoalInjection integration', () => {
it('main-agent dynamic injection writes a context.splice with origin.variant goal', async () => {
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({
type: 'main',
persistence,
describe('enabled goal injection', () => {
let ctx: TestAgentContext;
let goals: GoalServiceTestManager;
let profile: IProfileService;
let injector: InjectableContextInjector;
let persistence: InMemoryWireRecordPersistence;
beforeEach(() => {
persistence = new InMemoryWireRecordPersistence();
ctx = createTestAgent(wireRecordPersistenceServices(persistence));
goals = ctx.get(IGoalService) as GoalServiceTestManager;
profile = ctx.get(IProfileService);
injector = ctx.get(IContextInjector) as InjectableContextInjector;
});
ctx.configure();
await ctx.get(IGoalService).createGoal({ objective: 'Ship feature X' });
await injectDynamic(ctx);
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
const goalRecords = goalReminderRecords(persistence);
expect(goalRecords).toHaveLength(1);
const text = JSON.stringify(goalRecords[0]);
expect(text).toContain('<untrusted_objective>');
it('main-agent dynamic injection writes a context.splice with origin.variant goal', async () => {
await goals.createGoal({ objective: 'Ship feature X' });
await injectDynamic(injector);
const goalRecords = goalReminderRecords(persistence);
expect(goalRecords).toHaveLength(1);
const text = JSON.stringify(goalRecords[0]);
expect(text).toContain('<untrusted_objective>');
});
it('dynamic injection writes at most once for one turn boundary', async () => {
await goals.createGoal({ objective: 'Ship feature X' });
await injectDynamic(injector);
await injectDynamic(injector);
expect(goalReminderRecords(persistence)).toHaveLength(1);
});
it('injects one goal reminder per turn boundary, not per step', async () => {
await registerLookupTool(ctx, profile);
await goals.createGoal({ objective: 'Ship feature X' });
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
await ctx.untilApproval(true);
const toolCallEvents = ctx.untilToolCall({
content: 'lookup-result',
output: 'lookup-result',
});
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' });
await toolCallEvents;
await ctx.untilTurnEnd();
expect(goalReminderRecords(persistence)).toHaveLength(1);
ctx.mockNextResponse({ type: 'text', text: 'Next turn.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Continue' }] });
await ctx.untilTurnEnd();
expect(goalReminderRecords(persistence)).toHaveLength(2);
});
it('writes no goal record when there is no active goal', async () => {
await injectDynamic(injector);
expect(goalReminderRecords(persistence)).toHaveLength(0);
});
});
it('dynamic injection writes at most once for one turn boundary', async () => {
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({
type: 'main',
persistence,
describe('disabled goal injection', () => {
let ctx: TestAgentContext;
let goals: GoalServiceTestManager;
let injector: InjectableContextInjector;
let persistence: InMemoryWireRecordPersistence;
beforeEach(() => {
persistence = new InMemoryWireRecordPersistence();
ctx = createTestAgent(
wireRecordPersistenceServices(persistence),
goalServices({ enabled: false }),
);
goals = ctx.get(IGoalService) as GoalServiceTestManager;
injector = ctx.get(IContextInjector) as InjectableContextInjector;
});
ctx.configure();
await ctx.get(IGoalService).createGoal({ objective: 'Ship feature X' });
await injectDynamic(ctx);
await injectDynamic(ctx);
expect(goalReminderRecords(persistence)).toHaveLength(1);
});
it('injects one goal reminder per turn boundary, not per step', async () => {
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({
type: 'main',
persistence,
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
await registerLookupTool(ctx);
await ctx.get(IGoalService).createGoal({ objective: 'Ship feature X' });
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
await ctx.untilApproval(true);
const toolCallEvents = ctx.untilToolCall({
content: 'lookup-result',
output: 'lookup-result',
it('subagent dynamic injection does not add a goal reminder', async () => {
await goals.createGoal({ objective: 'Ship feature X' });
await injectDynamic(injector);
expect(goalReminderRecords(persistence)).toHaveLength(0);
});
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' });
await toolCallEvents;
await ctx.untilTurnEnd();
expect(goalReminderRecords(persistence)).toHaveLength(1);
ctx.mockNextResponse({ type: 'text', text: 'Next turn.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Continue' }] });
await ctx.untilTurnEnd();
expect(goalReminderRecords(persistence)).toHaveLength(2);
});
it('writes no goal record when there is no active goal', async () => {
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({
type: 'main',
persistence,
});
ctx.configure();
await injectDynamic(ctx);
expect(goalReminderRecords(persistence)).toHaveLength(0);
});
it('subagent dynamic injection does not add a goal reminder', async () => {
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({
type: 'sub',
persistence,
});
ctx.configure();
await ctx.get(IGoalService).createGoal({ objective: 'Ship feature X' });
await injectDynamic(ctx);
expect(goalReminderRecords(persistence)).toHaveLength(0);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,43 @@
export {
additionalDirServices,
agentService,
agentServices,
backgroundServices,
configServices,
createCommandKaos,
createTestAgent,
coreService,
coreServices,
cronServices,
externalHookServices,
fullCompactionServices,
goalServices,
homeDirServices,
InMemoryWireRecordPersistence,
initializeToolServices,
kaosServices,
llmGenerateServices,
logServices,
mcpServices,
microCompactionServices,
modelProviderOptionServices,
modelProviderServices,
permissionModeServices,
permissionRulesServices,
questionServices,
replayServices,
sessionService,
sessionServices,
skillServices,
subagentHostServices,
telemetryServices,
testAgent,
wireRecordPersistenceServices,
type TestAgentContext,
type TestAgentOptions,
type TestAgentServiceGroup,
type TestAgentServiceOverride,
type TestAgentServiceRegistration,
type WireRecordPersistence,
} from './agent';
export { createScriptedGenerate } from './scripted-generate';
export {

View file

@ -1,141 +1,183 @@
import { emptyUsage } from '@moonshot-ai/kosong';
import type { StreamedMessagePart } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { IModelResolver, ResolvedModel } from '#/modelRuntime';
import { ILLMRequester } from '#/index';
import { testAgent } from '../harness';
import { ILLMRequester } from '#/llmRequester';
import { IProfileService } from '#/profile';
import {
configServices,
createTestAgent,
llmGenerateServices,
type TestAgentContext,
} from '../harness';
describe('LLMRequester service migration coverage', () => {
it('preserves indexed tool-call deltas through LoopService protocol events', async () => {
const ctx = testAgent();
ctx.configure({ tools: ['Lookup'] });
await ctx.rpc.setPermission({ mode: 'auto' });
await ctx.rpc.registerTool({
name: 'Lookup',
description: 'Look up a short test value.',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
describe('tool-call deltas', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
beforeEach(() => {
ctx = createTestAgent();
profile = ctx.get(IProfileService);
profile.update({ activeToolNames: ['Lookup'] });
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('preserves indexed tool-call deltas through LoopService protocol events', async () => {
await ctx.rpc.setPermission({ mode: 'auto' });
await ctx.rpc.registerTool({
name: 'Lookup',
description: 'Look up a short test value.',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
additionalProperties: false,
},
required: ['query'],
additionalProperties: false,
},
});
});
ctx.mockNextProviderResponse({
parts: [
{ type: 'tool_call_part', argumentsPart: '{"query"', index: 0 },
{
type: 'function',
id: 'call_lookup',
name: 'Lookup',
arguments: null,
_streamIndex: 0,
},
{ type: 'tool_call_part', argumentsPart: ':"moon"}', index: 0 },
],
finishReason: 'tool_calls',
});
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
ctx.mockNextProviderResponse({
parts: [
{ type: 'tool_call_part', argumentsPart: '{"query"', index: 0 },
{
type: 'function',
id: 'call_lookup',
name: 'Lookup',
arguments: null,
_streamIndex: 0,
},
{ type: 'tool_call_part', argumentsPart: ':"moon"}', index: 0 },
],
finishReason: 'tool_calls',
});
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
await ctx.untilToolCall({
content: 'moon-result',
output: 'moon-result',
});
await ctx.untilToolCall({
content: 'moon-result',
output: 'moon-result',
});
expect(protocolEvents(ctx, 'tool.call.delta').map((event) => event.args)).toEqual([
{ turnId: 0, toolCallId: 'call_lookup', name: 'Lookup', argumentsPart: undefined },
{ turnId: 0, toolCallId: 'call_lookup', name: 'Lookup', argumentsPart: '{"query"' },
{ turnId: 0, toolCallId: 'call_lookup', name: 'Lookup', argumentsPart: ':"moon"}' },
]);
expect(protocolEvents(ctx, 'toolCall').at(-1)?.args).toEqual({
turnId: 0,
toolCallId: 'call_lookup',
args: { query: 'moon' },
});
expect(protocolEvents(ctx, 'tool.call.delta').map((event) => event.args)).toEqual([
{ turnId: 0, toolCallId: 'call_lookup', name: 'Lookup', argumentsPart: undefined },
{ turnId: 0, toolCallId: 'call_lookup', name: 'Lookup', argumentsPart: '{"query"' },
{ turnId: 0, toolCallId: 'call_lookup', name: 'Lookup', argumentsPart: ':"moon"}' },
]);
expect(protocolEvents(ctx, 'toolCall').at(-1)?.args).toEqual({
turnId: 0,
toolCallId: 'call_lookup',
args: { query: 'moon' },
});
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is moon-result.' });
await ctx.untilTurnEnd();
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is moon-result.' });
await ctx.untilTurnEnd();
});
});
it('emits stream timing and applies the model output budget through ILLMRequester', async () => {
describe('request timing and budget', () => {
let ctx: TestAgentContext;
let llmRequester: ILLMRequester;
let profile: IProfileService;
let requestMaxTokens: unknown;
const ctx = testAgent({
generate: async (provider, _systemPrompt, _tools, _messages, callbacks, options) => {
requestMaxTokens = (
provider as unknown as { readonly modelParameters: Record<string, unknown> }
).modelParameters['max_tokens'];
options?.onRequestStart?.();
await callbacks?.onMessagePart?.({ type: 'text', text: 'timed' });
options?.onStreamEnd?.();
return {
id: 'response-1',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'timed' }],
toolCalls: [],
beforeEach(() => {
requestMaxTokens = undefined;
ctx = createTestAgent(
llmGenerateServices(async (provider, _systemPrompt, _tools, _messages, callbacks, options) => {
requestMaxTokens = (
provider as unknown as { readonly modelParameters: Record<string, unknown> }
).modelParameters['max_tokens'];
options?.onRequestStart?.();
await callbacks?.onMessagePart?.({ type: 'text', text: 'timed' });
options?.onStreamEnd?.();
return {
id: 'response-1',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'timed' }],
toolCalls: [],
},
usage: emptyUsage(),
finishReason: 'completed',
rawFinishReason: 'stop',
};
}),
configServices(() => ({
defaultModel: 'deepseek/deepseek-v4-flash',
providers: {
deepseek: {
type: 'openai',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.example/v1',
},
},
usage: emptyUsage(),
finishReason: 'completed',
rawFinishReason: 'stop',
};
},
modelResolver: stubModelResolver('deepseek/deepseek-v4-flash', {
providerName: 'deepseek',
provider: {
type: 'openai',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.example/v1',
model: 'deepseek-v4-flash',
},
modelCapabilities: {
image_in: false,
video_in: false,
audio_in: false,
thinking: false,
tool_use: true,
max_context_tokens: 1_000_000,
},
maxOutputSize: 384_000,
}),
});
ctx.profile.update({
modelAlias: 'deepseek/deepseek-v4-flash',
systemPrompt: 'system',
thinkingLevel: 'off',
models: {
'deepseek/deepseek-v4-flash': {
provider: 'deepseek',
model: 'deepseek-v4-flash',
maxContextSize: 1_000_000,
maxOutputSize: 384_000,
capabilities: ['tool_use'],
},
},
})),
);
llmRequester = ctx.get(ILLMRequester);
profile = ctx.get(IProfileService);
profile.update({
modelAlias: 'deepseek/deepseek-v4-flash',
systemPrompt: 'system',
thinkingLevel: 'off',
});
});
const events = await collectLLMEvents(ctx.get(ILLMRequester).request());
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
expect(requestMaxTokens).toBe(384_000);
expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'timed' } });
expect(events).toContainEqual({
type: 'usage',
usage: emptyUsage(),
model: 'deepseek/deepseek-v4-flash',
});
expect(events).toContainEqual({
type: 'finish',
providerFinishReason: 'completed',
rawFinishReason: 'stop',
});
expect(events).toContainEqual({
type: 'timing',
firstTokenLatencyMs: expect.any(Number),
streamDurationMs: expect.any(Number),
it('emits stream timing and applies the model output budget through ILLMRequester', async () => {
const events = await collectLLMEvents(llmRequester.request());
expect(requestMaxTokens).toBe(384_000);
expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'timed' } });
expect(events).toContainEqual({
type: 'usage',
usage: emptyUsage(),
model: 'deepseek/deepseek-v4-flash',
});
expect(events).toContainEqual({
type: 'finish',
providerFinishReason: 'completed',
rawFinishReason: 'stop',
});
expect(events).toContainEqual({
type: 'timing',
firstTokenLatencyMs: expect.any(Number),
streamDurationMs: expect.any(Number),
});
});
});
});
type ProtocolEvent = Extract<
ReturnType<typeof testAgent>['allEvents'][number],
TestAgentContext['allEvents'][number],
{ readonly type: '[rpc]' }
>;
function protocolEvents(
ctx: ReturnType<typeof testAgent>,
ctx: TestAgentContext,
eventName: string,
): readonly ProtocolEvent[] {
return ctx.allEvents.filter(
@ -148,15 +190,15 @@ async function collectLLMEvents(
| { readonly type: 'part'; readonly part: StreamedMessagePart }
| { readonly type: 'usage'; readonly usage: ReturnType<typeof emptyUsage>; readonly model?: string }
| {
readonly type: 'finish';
readonly providerFinishReason?: string;
readonly rawFinishReason?: string;
}
readonly type: 'finish';
readonly providerFinishReason?: string;
readonly rawFinishReason?: string;
}
| {
readonly type: 'timing';
readonly firstTokenLatencyMs: number;
readonly streamDurationMs: number;
}
readonly type: 'timing';
readonly firstTokenLatencyMs: number;
readonly streamDurationMs: number;
}
>,
) {
const events: unknown[] = [];
@ -165,19 +207,3 @@ async function collectLLMEvents(
}
return events;
}
function stubModelResolver(
modelAlias: string,
resolved: ResolvedModel,
): IModelResolver {
return {
_serviceBrand: undefined,
defaultModel: modelAlias,
resolve(model) {
if (model !== modelAlias) {
throw new Error(`Unexpected model alias: ${model}`);
}
return resolved;
},
};
}

View file

@ -1,26 +1,41 @@
import type { ToolCall } from '@moonshot-ai/kosong';
import { expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IProfileService } from '#/index';
import { ILoopService } from '#/loop';
import { testAgent } from '../harness';
import { createTestAgent, type TestAgentContext } from '../harness';
it('resolves the loop service from the agent scope by interface', () => {
const ctx = testAgent();
const loop = ctx.get(ILoopService);
describe('Agent loop', () => {
let ctx: TestAgentContext;
let loop: ILoopService;
let profile: IProfileService;
expect(loop).toBe(ctx.get(ILoopService));
});
beforeEach(() => {
ctx = createTestAgent();
loop = ctx.get(ILoopService);
profile = ctx.get(IProfileService);
});
it('runs a text-only agent turn from prompt to completion', async () => {
const ctx = testAgent();
ctx.configure();
ctx.profile.update({ activeToolNames: [] });
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
ctx.mockNextResponse({ type: 'think', think: '<think-1>' }, { type: 'text', text: '<text-1>' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
it('resolves the loop service from the agent scope by interface', () => {
expect(loop).toBeDefined();
});
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
it('runs a text-only agent turn from prompt to completion', async () => {
profile.update({ activeToolNames: [] });
ctx.mockNextResponse({ type: 'think', think: '<think-1>' }, { type: 'text', text: '<text-1>' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] tools.set_active_tools { "names": [], "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
@ -37,26 +52,23 @@ it('runs a text-only agent turn from prompt to completion', async () => {
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "Hello"
`);
});
it('forwards provider finish diagnostics on filtered steps', async () => {
const ctx = testAgent();
ctx.configure();
ctx.mockNextProviderResponse({
parts: [{ type: 'text', text: 'blocked' }],
finishReason: 'filtered',
rawFinishReason: 'content_filter',
});
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
it('forwards provider finish diagnostics on filtered steps', async () => {
ctx.mockNextProviderResponse({
parts: [{ type: 'text', text: 'blocked' }],
finishReason: 'filtered',
rawFinishReason: 'content_filter',
});
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
@ -71,44 +83,43 @@ it('forwards provider finish diagnostics on filtered steps', async () => {
[emit] turn.ended { "turnId": 0, "reason": "filtered" }
`);
const rpcStepEnd = ctx.allEvents.find(
(event) => event.type === '[rpc]' && event.event === 'turn.step.completed',
);
const rpcStepEnd = ctx.allEvents.find(
(event) => event.type === '[rpc]' && event.event === 'turn.step.completed',
);
expect(rpcStepEnd?.args).toMatchObject({
finishReason: 'filtered',
providerFinishReason: 'filtered',
rawFinishReason: 'content_filter',
expect(rpcStepEnd?.args).toMatchObject({
finishReason: 'filtered',
providerFinishReason: 'filtered',
rawFinishReason: 'content_filter',
});
});
});
it('runs an agent turn through registered tool approval and execution', async () => {
const lookupCall: ToolCall = {
type: 'function',
id: 'call_lookup',
name: 'Lookup',
arguments: '{"query":"moon"}',
};
const ctx = testAgent();
ctx.configure({ tools: ['Lookup'] });
await ctx.rpc.registerTool({
name: 'Lookup',
description: 'Look up a short test value.',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
it('runs an agent turn through registered tool approval and execution', async () => {
const lookupCall: ToolCall = {
type: 'function',
id: 'call_lookup',
name: 'Lookup',
arguments: '{"query":"moon"}',
};
profile.update({ activeToolNames: ['Lookup'] });
await ctx.rpc.registerTool({
name: 'Lookup',
description: 'Look up a short test value.',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
additionalProperties: false,
},
required: ['query'],
additionalProperties: false,
},
});
});
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall);
await ctx.rpc.prompt({
input: [{ type: 'text', text: 'Look up moon' }],
});
expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(`
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall);
await ctx.rpc.prompt({
input: [{ type: 'text', text: 'Look up moon' }],
});
expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(`
[wire] tools.register_user_tool { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false }, "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
@ -121,20 +132,20 @@ it('runs an agent turn through registered tool approval and execution', async ()
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_lookup", "toolName": "Lookup", "action": "Approve Lookup", "display": { "kind": "generic", "summary": "Approve Lookup", "detail": { "query": "moon" } } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Lookup
messages:
user: text "Look up moon"
`);
const toolCallEvents = ctx.untilToolCall({
content: 'lookup-result',
output: 'lookup-result',
});
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' });
await toolCallEvents;
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
const toolCallEvents = ctx.untilToolCall({
content: 'lookup-result',
output: 'lookup-result',
});
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' });
await toolCallEvents;
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "lookup-result" } ], "toolCalls": [], "toolCallId": "call_lookup" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_lookup", "output": "lookup-result" }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
@ -148,11 +159,11 @@ it('runs an agent turn through registered tool approval and execution', async ()
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 25, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
messages:
<last>
assistant: text "I will look it up." calls call_lookup:Lookup { "query": "moon" }
tool[call_lookup]: text "lookup-result"
`);
await ctx.expectResumeMatches();
});
});

View file

@ -15,8 +15,9 @@ import { IToolExecutor } from '#/toolExecutor';
import { IToolRegistry } from '#/toolRegistry';
import { ToolRegistryService } from '#/toolRegistry/toolRegistryService';
import { ITurnService } from '#/turn';
import { IProfileService } from '#/profile';
import { testAgent } from '../harness';
import { createTestAgent, mcpServices, type TestAgentContext } from '../harness';
import { stubTurnWithHooks } from '../turn/stubs';
import { discoverTools, executeTool, fakeMcpClient } from './stubs';
@ -470,15 +471,32 @@ describe('McpService', () => {
});
describe('McpService + ProfileService', () => {
let ctx: TestAgentContext;
let manager: FakeMcpManager;
let profile: IProfileService;
beforeEach(() => {
manager = new FakeMcpManager();
ctx = createTestAgent(mcpServices({ manager: manager as unknown as McpConnectionManager }));
const mcp = ctx.get(IMcpService);
mcp.list();
profile = ctx.get(IProfileService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('gates MCP tools by the active profile', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient();
manager.setResolved('local', client, await discoverTools(client));
const ctx = testAgent({ mcp: { manager: manager as unknown as McpConnectionManager } });
ctx.get(IMcpService);
manager.connect('local');
ctx.configure({ tools: ['Read'] });
profile.update({ activeToolNames: ['Read'] });
expect(
ctx.toolsData()
.filter((tool) => tool.source === 'mcp')
@ -488,7 +506,7 @@ describe('McpService + ProfileService', () => {
{ name: 'mcp__local__noop', active: false },
]);
ctx.configure({ tools: ['Read', 'mcp__*'] });
profile.update({ activeToolNames: ['Read', 'mcp__*'] });
expect(
ctx.toolsData()
.filter((tool) => tool.source === 'mcp')
@ -500,25 +518,22 @@ describe('McpService + ProfileService', () => {
});
it('supports server-scoped and exact MCP active-tool patterns', async () => {
const manager = new FakeMcpManager();
const githubClient = fakeMcpClient();
const slackClient = fakeMcpClient();
manager.setResolved('github', githubClient, await discoverTools(githubClient));
manager.setResolved('slack', slackClient, await discoverTools(slackClient));
const ctx = testAgent({ mcp: { manager: manager as unknown as McpConnectionManager } });
ctx.get(IMcpService);
manager.connect('github');
manager.connect('slack');
ctx.configure({ tools: ['mcp__github__*'] });
profile.update({ activeToolNames: ['mcp__github__*'] });
expect(
ctx.toolsData()
.filter((tool) => tool.source === 'mcp' && tool.active)
.map((tool) => tool.name)
.toSorted(),
.toSorted(),
).toEqual(['mcp__github__echo', 'mcp__github__noop']);
ctx.configure({ tools: ['mcp__slack__echo'] });
profile.update({ activeToolNames: ['mcp__slack__echo'] });
expect(
ctx.toolsData()
.filter((tool) => tool.source === 'mcp' && tool.active)

View file

@ -1,62 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFakeKaos } from '../../../tools/fixtures/fake-kaos';
import { IContextInjector } from '#/contextInjector';
import { IContextMemory, type ContextMessage } from '#/contextMemory';
import { IPlanService } from '#/plan';
import {
IDynamicInjector,
IPlanModeService,
type ContextMessage,
} from '../../../../src/services/agent';
import { testAgent } from '../harness';
createTestAgent,
kaosServices,
type TestAgentContext,
} from '../harness';
type InjectableDynamicInjector = {
inject(): Promise<void>;
};
function createPlanAgent({
readText,
}: {
readonly readText?: (path: string) => Promise<string>;
} = {}) {
const readPlanText = readText ?? (async () => '');
const ctx = testAgent({
kaos: createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: readPlanText,
writeText: vi.fn(async (_path: string, content: string) => content.length),
}),
});
ctx.configure();
return ctx;
}
async function enterPlan(
ctx: ReturnType<typeof testAgent>,
plan: IPlanService,
id = 'test-plan',
): Promise<string> {
await ctx.get(IPlanModeService).enter(id, false);
const planFilePath = ctx.get(IPlanModeService).planFilePath;
if (planFilePath === null) {
await plan.enter(id, false);
const status = await plan.status();
if (status === null) {
throw new Error('expected plan file path');
}
return planFilePath;
return status.path;
}
async function injectDynamic(ctx: ReturnType<typeof testAgent>): Promise<void> {
await (ctx.get(IDynamicInjector) as unknown as InjectableDynamicInjector).inject();
async function injectDynamic(injector: InjectableDynamicInjector): Promise<void> {
await injector.inject();
}
function appendAssistantTurn(ctx: ReturnType<typeof testAgent>, text: string): void {
ctx.appendAssistantTurn(ctx.context.getHistory().length, text);
function appendAssistantTurn(
ctx: TestAgentContext,
context: IContextMemory,
text: string,
): void {
ctx.appendAssistantTurn(context.get().length, text);
}
function planReminderMessages(ctx: ReturnType<typeof testAgent>): readonly ContextMessage[] {
return ctx.context.getHistory().filter((message) => {
function planReminderMessages(context: IContextMemory): readonly ContextMessage[] {
return context.get().filter((message) => {
return message.origin?.kind === 'injection' && message.origin.variant === 'plan_mode';
});
}
function lastPlanReminder(ctx: ReturnType<typeof testAgent>): string {
const message = planReminderMessages(ctx).at(-1);
function lastPlanReminder(context: IContextMemory): string {
const message = planReminderMessages(context).at(-1);
if (message === undefined) return '';
return message.content
.map((part) => (part.type === 'text' ? part.text : ''))
@ -64,12 +53,37 @@ function lastPlanReminder(ctx: ReturnType<typeof testAgent>): string {
}
describe('PlanModeService dynamic injection content', () => {
it('injects the full reminder with the current plan file footer', async () => {
const ctx = createPlanAgent();
const planFilePath = await enterPlan(ctx);
let ctx: TestAgentContext;
let context: IContextMemory;
let injector: InjectableDynamicInjector;
let plan: IPlanService;
let readText: (path: string) => Promise<string>;
await injectDynamic(ctx);
const text = lastPlanReminder(ctx);
beforeEach(() => {
readText = async () => '';
ctx = createTestAgent(kaosServices(createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: (path) => readText(path),
writeText: vi.fn(async (_path: string, content: string) => content.length),
})));
context = ctx.get(IContextMemory);
injector = ctx.get(IContextInjector) as unknown as InjectableDynamicInjector;
plan = ctx.get(IPlanService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('injects the full reminder with the current plan file footer', async () => {
const planFilePath = await enterPlan(plan);
await injectDynamic(injector);
const text = lastPlanReminder(context);
expect(text).toContain('Plan mode is active');
expect(text).toContain('current plan file');
@ -80,103 +94,117 @@ describe('PlanModeService dynamic injection content', () => {
});
it('derives a plan file path before injecting the full reminder', async () => {
const ctx = createPlanAgent();
const planFilePath = await enterPlan(ctx, 'derived-plan');
const planFilePath = await enterPlan(plan, 'derived-plan');
await injectDynamic(ctx);
await injectDynamic(injector);
expect(planFilePath).toContain('derived-plan.md');
expect(lastPlanReminder(ctx)).toContain(`Plan file: ${planFilePath}`);
expect(lastPlanReminder(ctx)).not.toContain('Wait for the host to provide a plan file path');
expect(lastPlanReminder(context)).toContain(`Plan file: ${planFilePath}`);
expect(lastPlanReminder(context)).not.toContain('Wait for the host to provide a plan file path');
});
it('injects the exit reminder when plan mode turns off after being active', async () => {
const ctx = createPlanAgent();
await enterPlan(ctx);
await enterPlan(plan);
await injectDynamic(ctx);
ctx.get(IPlanModeService).exit();
await injectDynamic(ctx);
await injectDynamic(injector);
plan.exit();
await injectDynamic(injector);
expect(lastPlanReminder(ctx)).toContain('Plan mode is no longer active');
expect(lastPlanReminder(context)).toContain('Plan mode is no longer active');
});
it('does not inject anything when plan mode is inactive from the start', async () => {
const ctx = createPlanAgent();
await injectDynamic(injector);
await injectDynamic(ctx);
expect(planReminderMessages(ctx)).toHaveLength(0);
expect(ctx.context.getHistory()).toHaveLength(0);
expect(planReminderMessages(context)).toHaveLength(0);
expect(context.get()).toHaveLength(0);
});
it('injects a reentry reminder when restored plan mode already has plan content', async () => {
const ctx = createPlanAgent({
readText: vi.fn(async () => '# Existing Plan\n\n- Keep this context'),
});
readText = vi.fn(async () => '# Existing Plan\n\n- Keep this context');
await ctx.dispatch({
type: 'plan_mode.enter',
id: 'restored-plan',
});
await injectDynamic(ctx);
await injectDynamic(injector);
expect(lastPlanReminder(ctx)).toContain('Re-entering Plan Mode');
expect(lastPlanReminder(ctx)).toContain('Read the existing plan file');
expect(lastPlanReminder(context)).toContain('Re-entering Plan Mode');
expect(lastPlanReminder(context)).toContain('Read the existing plan file');
});
});
describe('PlanModeService dynamic injection cadence', () => {
let ctx: TestAgentContext;
let context: IContextMemory;
let injector: InjectableDynamicInjector;
let plan: IPlanService;
beforeEach(() => {
ctx = createTestAgent(kaosServices(createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
readText: async () => '',
writeText: vi.fn(async (_path: string, content: string) => content.length),
})));
context = ctx.get(IContextMemory);
injector = ctx.get(IContextInjector) as unknown as InjectableDynamicInjector;
plan = ctx.get(IPlanService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('skips reinjection before the assistant-turn threshold', async () => {
const ctx = createPlanAgent();
await enterPlan(ctx);
await enterPlan(plan);
await injectDynamic(ctx);
appendAssistantTurn(ctx, 'assistant one');
await injectDynamic(ctx);
await injectDynamic(injector);
appendAssistantTurn(ctx, context, 'assistant one');
await injectDynamic(injector);
expect(planReminderMessages(ctx)).toHaveLength(1);
expect(planReminderMessages(context)).toHaveLength(1);
});
it('injects the sparse reminder after the short assistant-turn threshold', async () => {
const ctx = createPlanAgent();
const planFilePath = await enterPlan(ctx);
const planFilePath = await enterPlan(plan);
await injectDynamic(ctx);
appendAssistantTurn(ctx, 'assistant one');
appendAssistantTurn(ctx, 'assistant two');
await injectDynamic(ctx);
await injectDynamic(injector);
appendAssistantTurn(ctx, context, 'assistant one');
appendAssistantTurn(ctx, context, 'assistant two');
await injectDynamic(injector);
const text = lastPlanReminder(ctx);
const text = lastPlanReminder(context);
expect(text).toContain('Plan mode still active');
expect(text).toContain('see full instructions earlier');
expect(text).toContain(`Plan file: ${planFilePath}`);
});
it('refreshes the full reminder after the long assistant-turn threshold', async () => {
const ctx = createPlanAgent();
await enterPlan(ctx);
await enterPlan(plan);
await injectDynamic(ctx);
await injectDynamic(injector);
for (let i = 0; i < 5; i += 1) {
appendAssistantTurn(ctx, `assistant ${String(i)}`);
appendAssistantTurn(ctx, context, `assistant ${String(i)}`);
}
await injectDynamic(ctx);
await injectDynamic(injector);
const text = lastPlanReminder(ctx);
const text = lastPlanReminder(context);
expect(text).toContain('Plan mode is active');
expect(text).not.toContain('Plan mode still active');
});
it('refreshes the full reminder if a user message appears after the last injection', async () => {
const ctx = createPlanAgent();
await enterPlan(ctx);
await enterPlan(plan);
await injectDynamic(ctx);
await injectDynamic(injector);
ctx.appendUserMessage([{ type: 'text', text: 'next task' }]);
await injectDynamic(ctx);
await injectDynamic(injector);
const text = lastPlanReminder(ctx);
const text = lastPlanReminder(context);
expect(text).toContain('Plan mode is active');
expect(text).not.toContain('Plan mode still active');
});

View file

@ -1,5 +1,5 @@
import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IPlanService, PlanData } from '#/plan';
import { EnterPlanModeTool } from '#/plan/tools/enter-plan-mode';
@ -12,7 +12,13 @@ import { IToolExecutor } from '#/toolExecutor';
import { executeTool } from '../tools/fixtures/execute-tool';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { testAgent } from '../harness/agent';
import {
createTestAgent,
kaosServices,
permissionModeServices,
telemetryServices,
type TestAgentContext,
} from '../harness/agent';
import {
recordingTelemetry as captureTelemetry,
type TelemetryRecord,
@ -97,40 +103,57 @@ describe('EnterPlanModeTool telemetry', () => {
});
describe('PlanService EnterPlanMode telemetry', () => {
it.each(['manual', 'auto', 'yolo'] as const)(
'enters without approval and tracks auto_approved in %s mode',
async (mode) => {
for (const mode of ['manual', 'auto', 'yolo'] as const) {
describe(`${mode} mode`, () => {
let ctx: TestAgentContext;
let toolExecutor: IToolExecutor;
const records: TelemetryRecord[] = [];
const ctx = testAgent({
kaos: createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
}),
permissionMode: mode,
telemetry: captureTelemetry(records),
});
const call: ToolCall = {
type: 'function',
id: `call_enter_plan_${mode}`,
name: 'EnterPlanMode',
arguments: '{}',
};
const result = await ctx.get(IToolExecutor).execute([call], {
turnId: '1',
signal: new AbortController().signal,
beforeEach(() => {
records.splice(0);
ctx = createTestAgent(
kaosServices(createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
})),
permissionModeServices(mode),
telemetryServices(captureTelemetry(records)),
);
toolExecutor = ctx.get(IToolExecutor);
});
expect(result[0]?.isError).toBeFalsy();
expect(result[0]?.output).toContain('Plan mode is now active');
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'requestApproval'),
).toBe(false);
expect(records).toContainEqual({
event: 'plan_enter_resolved',
properties: { outcome: 'auto_approved' },
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
},
);
it('enters without approval and tracks auto_approved', async () => {
const call: ToolCall = {
type: 'function',
id: `call_enter_plan_${mode}`,
name: 'EnterPlanMode',
arguments: '{}',
};
const result = await toolExecutor.execute([call], {
turnId: '1',
signal: new AbortController().signal,
});
expect(result[0]?.isError).toBeFalsy();
expect(result[0]?.output).toContain('Plan mode is now active');
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'requestApproval'),
).toBe(false);
expect(records).toContainEqual({
event: 'plan_enter_resolved',
properties: { outcome: 'auto_approved' },
});
});
});
}
});
describe('ExitPlanModeTool telemetry', () => {

View file

@ -1,32 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { emptyUsage } from '@moonshot-ai/kosong';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ModelResolver } from '#/modelRuntime';
import { ILLMRequester } from '../../../src/services/agent';
import { stubConfig, stubOAuth } from '../modelRuntime/stubs';
import { testAgent } from './harness';
import { ILLMRequester } from '#/llmRequester';
import { IProfileService } from '#/profile';
import {
configServices,
createTestAgent,
llmGenerateServices,
modelProviderOptionServices,
type TestAgentContext,
} from '../harness';
type TestKimiConfig = ReturnType<Parameters<typeof configServices>[0]>;
type GenerateFn = Parameters<typeof llmGenerateServices>[0];
function defaultGenerate(): ReturnType<GenerateFn> {
throw new Error('generate should not be called');
}
describe('ConfigState model capabilities', () => {
it('computes provider and model capabilities from ModelResolver metadata', () => {
const ctx = testAgent({
modelResolver: new ModelResolver(stubConfig({
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
},
},
models: {
'kimi-code/kimi-for-coding': {
provider: 'kimi',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
capabilities: ['image_in', 'video_in', 'thinking', 'tool_use'],
},
},
}), stubOAuth()),
});
const profile = ctx.profile;
let ctx: TestAgentContext;
let profile: IProfileService;
let requester: ILLMRequester;
let kimiConfig: TestKimiConfig;
let generate: GenerateFn;
beforeEach(() => {
kimiConfig = {
providers: {},
};
generate = defaultGenerate;
ctx = createTestAgent(
configServices(() => kimiConfig),
llmGenerateServices((...args) => generate(...args)),
);
profile = ctx.get(IProfileService);
requester = ctx.get(ILLMRequester);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('computes provider and model capabilities from config metadata', () => {
kimiConfig = {
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
},
},
models: {
'kimi-code/kimi-for-coding': {
provider: 'kimi',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
capabilities: ['image_in', 'video_in', 'thinking', 'tool_use'],
},
},
};
profile.update({ modelAlias: 'kimi-code/kimi-for-coding' });
@ -43,24 +79,21 @@ describe('ConfigState model capabilities', () => {
});
it('does not infer Kimi capabilities from the provider catalogue', () => {
const ctx = testAgent({
modelResolver: new ModelResolver(stubConfig({
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
},
},
models: {
'kimi-code': {
provider: 'kimi',
model: 'kimi-code',
maxContextSize: 128_000,
},
},
}), stubOAuth()),
});
const profile = ctx.profile;
kimiConfig = {
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
},
},
models: {
'kimi-code': {
provider: 'kimi',
model: 'kimi-code',
maxContextSize: 128_000,
},
},
};
profile.update({ modelAlias: 'kimi-code' });
@ -74,75 +107,86 @@ describe('ConfigState model capabilities', () => {
it('uses model max output size as the LLM completion cap', async () => {
let requestMaxTokens: unknown;
const ctx = testAgent({
generate: async (provider) => {
requestMaxTokens = (
provider as unknown as { readonly modelParameters: Record<string, unknown> }
).modelParameters['max_tokens'];
return {
id: 'response-1',
message: { role: 'assistant', content: [], toolCalls: [] },
usage: emptyUsage(),
finishReason: 'completed',
rawFinishReason: 'stop',
};
kimiConfig = {
providers: {
deepseek: {
type: 'openai',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.example/v1',
},
},
modelResolver: new ModelResolver(stubConfig({
providers: {
deepseek: {
type: 'openai',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.example/v1',
},
},
models: {
'deepseek/deepseek-v4-flash': {
provider: 'deepseek',
model: 'deepseek-v4-flash',
maxContextSize: 1_000_000,
maxOutputSize: 384000,
},
},
}), stubOAuth()),
});
models: {
'deepseek/deepseek-v4-flash': {
provider: 'deepseek',
model: 'deepseek-v4-flash',
maxContextSize: 1_000_000,
maxOutputSize: 384000,
},
},
};
generate = async (provider) => {
requestMaxTokens = (
provider as unknown as { readonly modelParameters: Record<string, unknown> }
).modelParameters['max_tokens'];
return {
id: 'response-1',
message: { role: 'assistant', content: [], toolCalls: [] },
usage: emptyUsage(),
finishReason: 'completed',
rawFinishReason: 'stop',
};
};
ctx.profile.update({
profile.update({
modelAlias: 'deepseek/deepseek-v4-flash',
systemPrompt: 'system',
thinkingLevel: 'off',
});
const requester = ctx.get(ILLMRequester);
for await (const _ of requester.request({}, new AbortController().signal)) {
// consume to trigger generate
}
expect(requestMaxTokens).toBe(384000);
});
});
describe('ConfigState prompt cache hint', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let kimiConfig: TestKimiConfig;
beforeEach(() => {
kimiConfig = {
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
},
},
models: {
'kimi-code': {
provider: 'kimi',
model: 'kimi-code',
maxContextSize: 128_000,
},
},
};
ctx = createTestAgent(
configServices(() => kimiConfig),
modelProviderOptionServices({ promptCacheKey: 'session-test' }),
);
profile = ctx.get(IProfileService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('uses session id as a provider prompt cache hint without storing it on Agent', () => {
const ctx = testAgent({
modelResolver: new ModelResolver(
stubConfig({
providers: {
kimi: {
type: 'kimi',
apiKey: 'test-key',
},
},
models: {
'kimi-code': {
provider: 'kimi',
model: 'kimi-code',
maxContextSize: 128_000,
},
},
}),
stubOAuth(),
{ promptCacheKey: 'session-test' },
),
});
const profile = ctx.profile;
profile.update({ modelAlias: 'kimi-code' });
expect(profile.data().provider).toMatchObject({
@ -156,40 +200,50 @@ describe('ConfigState model capabilities', () => {
});
describe('ConfigState thinking clamp for always-thinking models', () => {
function alwaysThinkingAgent() {
return testAgent({
modelResolver: new ModelResolver(stubConfig({
providers: { kimi: { type: 'kimi', apiKey: 'test-key' } },
models: {
'kimi-code/deep': {
provider: 'kimi',
model: 'kimi-deep-coder',
maxContextSize: 128_000,
capabilities: ['thinking', 'always_thinking', 'tool_use'],
},
'kimi-code/toggle': {
provider: 'kimi',
model: 'kimi-for-coding',
maxContextSize: 128_000,
capabilities: ['thinking'],
},
},
}), stubOAuth()),
});
}
let ctx: TestAgentContext;
let profile: IProfileService;
let kimiConfig: TestKimiConfig;
beforeEach(() => {
kimiConfig = {
providers: { kimi: { type: 'kimi', apiKey: 'test-key' } },
models: {
'kimi-code/deep': {
provider: 'kimi',
model: 'kimi-deep-coder',
maxContextSize: 128_000,
capabilities: ['thinking', 'always_thinking', 'tool_use'],
},
'kimi-code/toggle': {
provider: 'kimi',
model: 'kimi-for-coding',
maxContextSize: 128_000,
capabilities: ['thinking'],
},
},
};
ctx = createTestAgent(configServices(() => kimiConfig));
profile = ctx.get(IProfileService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('clamps thinkingLevel off to the configured effort', () => {
const ctx = alwaysThinkingAgent();
ctx.profile.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' });
profile.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' });
expect(ctx.profile.data().thinkingLevel).toBe('high');
expect(profile.data().thinkingLevel).toBe('high');
});
it('builds the provider with thinking enabled even after thinking was set off', () => {
const ctx = alwaysThinkingAgent();
ctx.profile.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' });
profile.update({ modelAlias: 'kimi-code/deep', thinkingLevel: 'off' });
const provider = ctx.profile.getProvider();
const provider = profile.getProvider();
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { type?: unknown } };
};
@ -197,78 +251,77 @@ describe('ConfigState thinking clamp for always-thinking models', () => {
});
it('keeps thinking off working for toggleable models', () => {
const ctx = alwaysThinkingAgent();
ctx.profile.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' });
profile.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' });
expect(ctx.profile.data().thinkingLevel).toBe('off');
expect(profile.data().thinkingLevel).toBe('off');
});
it('re-clamps when switching to an always-on model after thinking was off', () => {
const ctx = alwaysThinkingAgent();
ctx.profile.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' });
expect(ctx.profile.data().thinkingLevel).toBe('off');
profile.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' });
expect(profile.data().thinkingLevel).toBe('off');
ctx.profile.update({ modelAlias: 'kimi-code/deep' });
expect(ctx.profile.data().thinkingLevel).toBe('high');
profile.update({ modelAlias: 'kimi-code/deep' });
expect(profile.data().thinkingLevel).toBe('high');
});
});
describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => {
function kimiAgent() {
return testAgent({
modelResolver: new ModelResolver(stubConfig({
providers: { kimi: { type: 'kimi', apiKey: 'test-key' } },
models: {
'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 },
},
}), stubOAuth()),
});
}
let ctx: TestAgentContext;
let profile: IProfileService;
let kimiConfig: TestKimiConfig;
beforeEach(() => {
kimiConfig = {
providers: { kimi: { type: 'kimi', apiKey: 'test-key' } },
models: {
'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 },
},
};
ctx = createTestAgent(configServices(() => kimiConfig));
profile = ctx.get(IProfileService);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
vi.unstubAllEnvs();
}
});
it('injects KIMI_MODEL_TEMPERATURE into config.provider (the provider compaction also uses)', () => {
vi.stubEnv('KIMI_MODEL_TEMPERATURE', '0.3');
try {
const ctx = kimiAgent();
ctx.profile.update({ modelAlias: 'kimi-code' });
const provider = ctx.profile.getProvider();
expect(Reflect.get(provider as object, '_generationKwargs')).toMatchObject({
temperature: 0.3,
});
} finally {
vi.unstubAllEnvs();
}
profile.update({ modelAlias: 'kimi-code' });
const provider = profile.getProvider();
expect(Reflect.get(provider as object, '_generationKwargs')).toMatchObject({
temperature: 0.3,
});
});
it('injects KIMI_MODEL_THINKING_KEEP into config.provider when thinking is on (so compaction keeps it)', () => {
vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all');
try {
const ctx = kimiAgent();
ctx.profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
const provider = ctx.profile.getProvider();
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { keep?: unknown } };
};
expect(gen.extra_body?.thinking?.keep).toBe('all');
} finally {
vi.unstubAllEnvs();
}
profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' });
const provider = profile.getProvider();
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { keep?: unknown } };
};
expect(gen.extra_body?.thinking?.keep).toBe('all');
});
it('does NOT inject thinking.keep into config.provider when thinking is off', () => {
vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all');
try {
const ctx = kimiAgent();
ctx.profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' });
const provider = ctx.profile.getProvider();
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { keep?: unknown } };
};
expect(gen.extra_body?.thinking?.keep).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' });
const provider = profile.getProvider();
const gen = Reflect.get(provider as object, '_generationKwargs') as {
extra_body?: { thinking?: { keep?: unknown } };
};
expect(gen.extra_body?.thinking?.keep).toBeUndefined();
});
});

View file

@ -3,11 +3,23 @@ import { tmpdir } from 'node:os';
import { join } from 'pathe';
import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IReplayBuilderService } from '#/index';
import { IContextMemory } from '#/contextMemory';
import { IEventSink } from '#/eventSink';
import { IProfileService } from '#/profile';
import { IReplayBuilderService } from '#/replayBuilder';
import { SessionSkillRegistry, type SkillCatalog, type SkillDefinition } from '#/skill';
import { InMemoryWireRecordPersistence, testAgent } from '../harness';
import { IToolRegistry } from '#/toolRegistry';
import {
InMemoryWireRecordPersistence,
createTestAgent,
kaosServices,
skillServices,
telemetryServices,
wireRecordPersistenceServices,
type TestAgentContext,
} from '../harness';
import { recordingTelemetry } from '../telemetry/stubs';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { stubSkill } from './stubs';
@ -43,35 +55,90 @@ function isRecordWithMessages(
}
describe('ToolManager SkillTool registration', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let tools: IToolRegistry;
beforeEach(() => {
ctx = createTestAgent();
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('does not expose Skill when the agent has no skill registry', () => {
const ctx = testAgent();
ctx.configure({ tools: ['Skill'] });
profile.update({ activeToolNames: ['Skill'] });
expect(ctx.toolsData().find((tool) => tool.name === 'Skill')).toBeUndefined();
expect(ctx.tools.resolve('Skill')).toBeUndefined();
expect(tools.resolve('Skill')).toBeUndefined();
});
});
describe('ToolManager SkillTool registration with an empty model skill catalog', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let tools: IToolRegistry;
let skills: SessionSkillRegistry;
beforeEach(() => {
skills = new SessionSkillRegistry();
skills.register(makeSkill('private', { disableModelInvocation: true }));
ctx = createTestAgent(skillServices(skills));
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('does not expose Skill when there are no model-invocable skills', () => {
const skills = new SessionSkillRegistry();
skills.register(makeSkill('private', { disableModelInvocation: true }));
const ctx = testAgent({ skills });
ctx.configure({ tools: ['Skill'] });
profile.update({ activeToolNames: ['Skill'] });
expect(ctx.toolsData().find((tool) => tool.name === 'Skill')).toBeUndefined();
expect(ctx.tools.resolve('Skill')).toBeUndefined();
expect(tools.resolve('Skill')).toBeUndefined();
});
});
describe('ToolManager SkillTool registration with inline skills', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let tools: IToolRegistry;
let skills: SessionSkillRegistry;
beforeEach(() => {
skills = new SessionSkillRegistry();
skills.register(makeSkill('review'));
skills.register(makeSkill('flow-only', { type: 'flow' }));
ctx = createTestAgent(skillServices(skills));
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('exposes Skill when at least one inline skill is model-invocable', () => {
const skills = new SessionSkillRegistry();
skills.register(makeSkill('review'));
skills.register(makeSkill('flow-only', { type: 'flow' }));
const ctx = testAgent({ skills });
ctx.configure({ tools: ['Skill'] });
profile.update({ activeToolNames: ['Skill'] });
const skillInfo = ctx.toolsData().find((tool) => tool.name === 'Skill');
const skillTool = ctx.tools.resolve('Skill');
const skillTool = tools.resolve('Skill');
expect(skillInfo).toMatchObject({ name: 'Skill', active: true, source: 'builtin' });
expect(skillTool).toMatchObject({
@ -79,10 +146,17 @@ describe('ToolManager SkillTool registration', () => {
description: expect.stringContaining('Invoke a registered skill'),
});
});
});
it('accepts a structural skill registry implementation', () => {
describe('ToolManager SkillTool registration with a structural catalog', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let tools: IToolRegistry;
let skills: SkillCatalog;
beforeEach(() => {
const skill = makeSkill('review');
const skills: SkillCatalog = {
skills = {
getSkill: (name) => (name === skill.name ? skill : undefined),
getPluginSkill: () => undefined,
renderSkillPrompt: () => skill.content,
@ -90,21 +164,56 @@ describe('ToolManager SkillTool registration', () => {
getSkillRoots: () => ['/skills/review'],
getModelSkillListing: () => '- review: desc for review',
};
ctx = createTestAgent(skillServices(skills));
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
});
const ctx = testAgent({ skills });
ctx.configure({ tools: ['Skill'] });
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('accepts a structural skill registry implementation', () => {
profile.update({ activeToolNames: ['Skill'] });
expect(skills.getSkillRoots()).toEqual(['/skills/review']);
expect(ctx.tools.resolve('Skill')).toMatchObject({ name: 'Skill' });
expect(tools.resolve('Skill')).toMatchObject({ name: 'Skill' });
});
});
describe('ToolManager SkillTool wire behavior', () => {
let ctx: TestAgentContext;
let context: IContextMemory;
let profile: IProfileService;
let persistence: InMemoryWireRecordPersistence;
let skills: SessionSkillRegistry;
beforeEach(() => {
skills = new SessionSkillRegistry();
skills.register(makeSkill('review'));
persistence = new InMemoryWireRecordPersistence();
ctx = createTestAgent(
skillServices(skills),
wireRecordPersistenceServices(persistence),
);
context = ctx.get(IContextMemory);
profile = ctx.get(IProfileService);
profile.update({ activeToolNames: ['Skill'] });
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('persists model-invoked inline skill reminders through agent wire', async () => {
const skills = new SessionSkillRegistry();
skills.register(makeSkill('review'));
const persistence = new InMemoryWireRecordPersistence();
const ctx = testAgent({ skills, persistence });
ctx.configure({ tools: ['Skill'] });
const skillCall: ToolCall = {
type: 'function',
id: 'call_skill',
@ -152,11 +261,11 @@ describe('ToolManager SkillTool registration', () => {
trigger: 'model-tool',
},
});
expect(ctx.context.getHistory().at(-1)).toMatchObject({
expect(context.get().at(-1)).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'Review skill loaded.' }],
});
expect(ctx.context.getHistory().at(-2)).toMatchObject({
expect(context.get().at(-2)).toMatchObject({
role: 'user',
origin: {
kind: 'skill_activation',
@ -164,17 +273,40 @@ describe('ToolManager SkillTool registration', () => {
},
});
});
});
it('restores skill activation records before the skill service is otherwise used', async () => {
const skills = new SessionSkillRegistry();
describe('ToolManager SkillTool restore behavior', () => {
let ctx: TestAgentContext;
let context: IContextMemory;
let replay: IReplayBuilderService;
let skills: SessionSkillRegistry;
let emit: ReturnType<typeof vi.spyOn>;
let track: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
skills = new SessionSkillRegistry();
skills.register(makeSkill('review'));
const telemetry = recordingTelemetry([]);
const track = vi.spyOn(telemetry, 'track');
const ctx = testAgent({
skills,
telemetry,
});
const emit = vi.spyOn(ctx.events, 'emit');
track = vi.spyOn(telemetry, 'track');
ctx = createTestAgent(
skillServices(skills),
telemetryServices(telemetry),
);
context = ctx.get(IContextMemory);
const events = ctx.get(IEventSink);
replay = ctx.get(IReplayBuilderService);
emit = vi.spyOn(events, 'emit');
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('restores skill activation records before the skill service is otherwise used', async () => {
const origin = {
kind: 'skill_activation' as const,
activationId: 'act_restore_skill',
@ -191,7 +323,7 @@ describe('ToolManager SkillTool registration', () => {
origin,
};
await ctx.runtime.restore([
await ctx.restore([
{ type: 'skill.activate', origin },
{
type: 'context.splice',
@ -214,8 +346,8 @@ describe('ToolManager SkillTool registration', () => {
expect.objectContaining({ type: '[rpc]', event: 'skill.activated' }),
);
expect(track).not.toHaveBeenCalledWith('skill_invoked', expect.anything());
expect(ctx.context.getHistory()).toMatchObject([message]);
expect(ctx.get(IReplayBuilderService).buildResult()).toContainEqual(
expect(context.get()).toMatchObject([message]);
expect(replay.buildResult()).toContainEqual(
expect.objectContaining({
type: 'message',
message: expect.objectContaining({
@ -229,38 +361,56 @@ describe('ToolManager SkillTool registration', () => {
}),
);
});
});
it('exposes session skills after the main agent is created', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'kimi-core-skill-tool-refresh-'));
describe('ToolManager SkillTool workspace refresh', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
let tmp: string;
let tools: IToolRegistry;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-core-skill-tool-refresh-'));
const workDir = join(tmp, 'work');
const skillDir = join(workDir, '.kimi-code', 'skills', 'review');
await mkdir(skillDir, { recursive: true });
await writeFile(
join(skillDir, 'SKILL.md'),
['---', 'name: review', 'description: Review code', '---', '', 'Review body.'].join('\n'),
);
const skills = new SessionSkillRegistry();
const skill = {
...makeSkill('review'),
description: 'Review code',
path: join(skillDir, 'SKILL.md'),
dir: skillDir,
content: 'Review body.',
};
skills.register(skill);
ctx = createTestAgent(
kaosServices(createFakeKaos().withCwd(workDir)),
skillServices(skills),
);
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
profile.update({ activeToolNames: ['Skill'] });
});
afterEach(async () => {
try {
const homeDir = join(tmp, 'home');
const workDir = join(tmp, 'work');
const skillDir = join(workDir, '.kimi-code', 'skills', 'review');
await mkdir(skillDir, { recursive: true });
await writeFile(
join(skillDir, 'SKILL.md'),
['---', 'name: review', 'description: Review code', '---', '', 'Review body.'].join('\n'),
);
const skills = new SessionSkillRegistry();
const skill = {
...makeSkill('review'),
description: 'Review code',
path: join(skillDir, 'SKILL.md'),
dir: skillDir,
content: 'Review body.',
};
skills.register(skill);
const ctx = testAgent({
kaos: createFakeKaos().withCwd(workDir),
skills,
});
ctx.configure({ tools: ['Skill'] });
expect(ctx.tools.resolve('Skill')).toMatchObject({ name: 'Skill' });
await ctx.expectResumeMatches();
} finally {
await rm(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
try {
await ctx.dispose();
} finally {
await rm(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
}
}
});
it('exposes session skills after the main agent is created', () => {
expect(tools.resolve('Skill')).toMatchObject({ name: 'Skill' });
});
});

View file

@ -1,115 +1,137 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IProfileService } from '#/profile';
import type { SessionSubagentHost } from '#/subagentHost';
import { IToolRegistry } from '#/toolRegistry';
import { executeTool } from '../tools/fixtures/execute-tool';
import { testAgent } from '../harness';
import {
createTestAgent,
subagentHostServices,
type TestAgentContext,
} from '../harness';
const signal = new AbortController().signal;
describe('Agent tool service runtime', () => {
it('exposes Agent when a subagent host is available', () => {
const subagentHost = createSubagentHost();
describe('with a default subagent host', () => {
let ctx: TestAgentContext;
let profile: IProfileService;
const ctx = testAgent({ subagentHost });
ctx.configure({ tools: ['Agent'] });
beforeEach(() => {
const subagentHost = createSubagentHost();
ctx = createTestAgent(subagentHostServices(subagentHost));
profile = ctx.get(IProfileService);
profile.update({ activeToolNames: ['Agent'] });
});
expect(ctx.toolsData()).toContainEqual(
expect.objectContaining({
name: 'Agent',
active: true,
source: 'builtin',
}),
);
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('exposes Agent when a subagent host is available', () => {
expect(ctx.toolsData()).toContainEqual(
expect.objectContaining({
name: 'Agent',
active: true,
source: 'builtin',
}),
);
});
});
it('runs foreground Agent calls through the service runtime background manager', async () => {
const subagentHost = createSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: Promise.resolve({ result: 'child summary' }),
}),
});
const ctx = testAgent({ subagentHost });
ctx.configure({ tools: ['Agent'] });
describe('with a resolving subagent host', () => {
let ctx: TestAgentContext;
let subagentHost: SessionSubagentHost;
let profile: IProfileService;
let tools: IToolRegistry;
const tool = ctx.tools.resolve('Agent');
expect(tool).toBeDefined();
await expect(
executeTool(tool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
beforeEach(() => {
subagentHost = createSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: Promise.resolve({ result: 'child summary' }),
}),
});
ctx = createTestAgent(subagentHostServices(subagentHost));
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
profile.update({ activeToolNames: ['Agent'] });
});
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
it('runs foreground Agent calls through the service runtime background manager', async () => {
const tool = tools.resolve('Agent');
expect(tool).toBeDefined();
await expect(
executeTool(tool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
prompt: 'Investigate deeply',
description: 'Investigate deeply',
subagent_type: 'coder',
},
signal,
}),
).resolves.toMatchObject({
output: [
'agent_id: agent-child',
'actual_subagent_type: coder',
'status: completed',
'',
'[summary]',
'child summary',
].join('\n'),
});
expect(subagentHost.spawn).toHaveBeenCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Investigate deeply',
description: 'Investigate deeply',
subagent_type: 'coder',
},
signal,
}),
).resolves.toMatchObject({
output: [
'agent_id: agent-child',
'actual_subagent_type: coder',
'status: completed',
'',
'[summary]',
'child summary',
].join('\n'),
runInBackground: false,
}),
);
});
expect(subagentHost.spawn).toHaveBeenCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Investigate deeply',
description: 'Investigate deeply',
runInBackground: false,
}),
);
});
it('rejects Agent resume calls that also specify a subagent type', async () => {
const subagentHost = createSubagentHost();
const ctx = testAgent({ subagentHost });
ctx.configure({ tools: ['Agent'] });
it('gates Agent background mode on task management tools', async () => {
const agentOnlyTool = tools.resolve('Agent');
expect(agentOnlyTool).toBeDefined();
await expect(
executeTool(agentOnlyTool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
prompt: 'Investigate deeply',
description: 'Investigate deeply',
run_in_background: true,
},
signal,
}),
).resolves.toMatchObject({
isError: true,
output:
'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.',
});
const tool = ctx.tools.resolve('Agent');
expect(tool).toBeDefined();
await expect(
executeTool(tool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
prompt: 'Continue',
description: 'Continue work',
resume: 'agent-child',
subagent_type: 'coder',
},
signal,
}),
).resolves.toMatchObject({
isError: true,
output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.',
});
expect(subagentHost.resume).not.toHaveBeenCalled();
});
await ctx.rpc.setActiveTools({ names: ['Agent', 'TaskList', 'TaskOutput', 'TaskStop'] });
it('gates Agent background mode on task management tools', async () => {
const subagentHost = createSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: Promise.resolve({ result: 'child summary' }),
}),
});
const ctx = testAgent({ subagentHost });
ctx.configure({ tools: ['Agent'] });
const agentOnlyTool = ctx.tools.resolve('Agent');
expect(agentOnlyTool).toBeDefined();
await expect(
executeTool(agentOnlyTool!, {
const managedTool = tools.resolve('Agent');
expect(managedTool).toBeDefined();
const result = await executeTool(managedTool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
@ -118,44 +140,70 @@ describe('Agent tool service runtime', () => {
run_in_background: true,
},
signal,
}),
).resolves.toMatchObject({
isError: true,
output:
'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.',
});
expect(result).toMatchObject({
output: expect.stringContaining('status: running'),
});
expect(result.output).toContain('agent_id: agent-child');
expect(result.output).toContain(
'resume_hint: To continue or recover this same subagent later, call Agent(resume="agent-child", prompt="...").',
);
expect(subagentHost.spawn).toHaveBeenLastCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Investigate deeply',
description: 'Investigate deeply',
runInBackground: true,
}),
);
});
});
describe('with a non-resuming subagent host', () => {
let ctx: TestAgentContext;
let subagentHost: SessionSubagentHost;
let profile: IProfileService;
let tools: IToolRegistry;
beforeEach(() => {
subagentHost = createSubagentHost();
ctx = createTestAgent(subagentHostServices(subagentHost));
profile = ctx.get(IProfileService);
tools = ctx.get(IToolRegistry);
profile.update({ activeToolNames: ['Agent'] });
});
await ctx.rpc.setActiveTools({ names: ['Agent', 'TaskList', 'TaskOutput', 'TaskStop'] });
const managedTool = ctx.tools.resolve('Agent');
expect(managedTool).toBeDefined();
const result = await executeTool(managedTool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
prompt: 'Investigate deeply',
description: 'Investigate deeply',
run_in_background: true,
},
signal,
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
expect(result).toMatchObject({
output: expect.stringContaining('status: running'),
it('rejects Agent resume calls that also specify a subagent type', async () => {
const tool = tools.resolve('Agent');
expect(tool).toBeDefined();
await expect(
executeTool(tool!, {
turnId: '0',
toolCallId: 'call_agent',
args: {
prompt: 'Continue',
description: 'Continue work',
resume: 'agent-child',
subagent_type: 'coder',
},
signal,
}),
).resolves.toMatchObject({
isError: true,
output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.',
});
expect(subagentHost.resume).not.toHaveBeenCalled();
});
expect(result.output).toContain('agent_id: agent-child');
expect(result.output).toContain(
'resume_hint: To continue or recover this same subagent later, call Agent(resume="agent-child", prompt="...").',
);
expect(subagentHost.spawn).toHaveBeenLastCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Investigate deeply',
description: 'Investigate deeply',
runInBackground: true,
}),
);
});
});