mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
fix: agent-core-v2 tests and background persistence options
This commit is contained in:
parent
01509910a7
commit
48e4c3dae7
31 changed files with 3618 additions and 2401 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => { });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue