From 48e4c3dae717057941fc11ac2ca0208b309bfac3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 30 Jun 2026 12:28:42 +0800 Subject: [PATCH] fix: agent-core-v2 tests and background persistence options --- .../src/background/background.ts | 8 - .../src/background/backgroundService.ts | 68 +- .../test/background/agent-timeout.test.ts | 59 +- .../test/background/background.test.ts | 5 +- .../bg-idle-notification-repro.test.ts | 462 +++--- .../background/foreground-persistence.test.ts | 54 +- .../test/background/heartbeat-stale.test.ts | 66 +- .../agent-core-v2/test/background/ids.test.ts | 36 +- .../test/background/manager.test.ts | 31 +- .../test/background/output-access.test.ts | 45 +- .../background/persistence-compat.test.ts | 58 +- .../test/background/reconcile.test.ts | 419 +++-- .../test/background/rpc-events.test.ts | 81 +- .../agent-core-v2/test/background/stubs.ts | 7 +- .../test/contextMemory/context.test.ts | 236 ++- .../test/cron/agent-integration.test.ts | 141 +- .../agent-core-v2/test/cron/cron.e2e.test.ts | 54 +- .../test/cron/manual-tick.test.ts | 354 ++-- .../test/cron/subagent-skip.test.ts | 133 +- packages/agent-core-v2/test/goal/goal.test.ts | 140 +- .../agent-core-v2/test/goal/injection.test.ts | 276 +-- packages/agent-core-v2/test/harness/agent.ts | 1475 ++++++++++++----- packages/agent-core-v2/test/harness/index.ts | 37 +- .../test/llmRequester/kosong-llm.test.ts | 298 ++-- .../agent-core-v2/test/loop/basic.test.ts | 151 +- packages/agent-core-v2/test/mcp/mcp.test.ts | 39 +- .../agent-core-v2/test/plan/injection.test.ts | 202 ++- .../test/plan/plan-tools-telemetry.test.ts | 85 +- .../test/profile/config-state.test.ts | 403 +++-- .../test/skill/skill-tool-manager.test.ts | 294 +++- .../test/subagentHost/agent-tool.test.ts | 302 ++-- 31 files changed, 3618 insertions(+), 2401 deletions(-) diff --git a/packages/agent-core-v2/src/background/background.ts b/packages/agent-core-v2/src/background/background.ts index b1cefc1ac..59d28f498 100644 --- a/packages/agent-core-v2/src/background/background.ts +++ b/packages/agent-core-v2/src/background/background.ts @@ -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; } diff --git a/packages/agent-core-v2/src/background/backgroundService.ts b/packages/agent-core-v2/src/background/backgroundService.ts index 904fd76df..4fa591b28 100644 --- a/packages/agent-core-v2/src/background/backgroundService.ts +++ b/packages/agent-core-v2/src/background/backgroundService.ts @@ -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(); private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); - 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 { 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( + 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 { 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(() => { }); diff --git a/packages/agent-core-v2/test/background/agent-timeout.test.ts b/packages/agent-core-v2/test/background/agent-timeout.test.ts index 554126952..73b0a9617 100644 --- a/packages/agent-core-v2/test/background/agent-timeout.test.ts +++ b/packages/agent-core-v2/test/background/agent-timeout.test.ts @@ -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' }); }); }); diff --git a/packages/agent-core-v2/test/background/background.test.ts b/packages/agent-core-v2/test/background/background.test.ts index a9b2b8fd4..1bbfb0efd 100644 --- a/packages/agent-core-v2/test/background/background.test.ts +++ b/packages/agent-core-v2/test/background/background.test.ts @@ -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', diff --git a/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts b/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts index 41eaaafbd..6c10285f4 100644 --- a/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts +++ b/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts @@ -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 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(' { - 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(' { - 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 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(' { + 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(' { + // 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(' { + // 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(' { - // 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 }); - } + }); }); }); diff --git a/packages/agent-core-v2/test/background/foreground-persistence.test.ts b/packages/agent-core-v2/test/background/foreground-persistence.test.ts index 1b9c8eac7..f4a9ddc4a 100644 --- a/packages/agent-core-v2/test/background/foreground-persistence.test.ts +++ b/packages/agent-core-v2/test/background/foreground-persistence.test.ts @@ -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; 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); diff --git a/packages/agent-core-v2/test/background/heartbeat-stale.test.ts b/packages/agent-core-v2/test/background/heartbeat-stale.test.ts index 8f2c76f85..e69d2784f 100644 --- a/packages/agent-core-v2/test/background/heartbeat-stale.test.ts +++ b/packages/agent-core-v2/test/background/heartbeat-stale.test.ts @@ -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; function runningGhost(taskId: string): Extract { 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); }); diff --git a/packages/agent-core-v2/test/background/ids.test.ts b/packages/agent-core-v2/test/background/ids.test.ts index 47a3fc5ac..ea0bf2dd7 100644 --- a/packages/agent-core-v2/test/background/ids.test.ts +++ b/packages/agent-core-v2/test/background/ids.test.ts @@ -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', diff --git a/packages/agent-core-v2/test/background/manager.test.ts b/packages/agent-core-v2/test/background/manager.test.ts index 1d6274ae9..488d08b30 100644 --- a/packages/agent-core-v2/test/background/manager.test.ts +++ b/packages/agent-core-v2/test/background/manager.test.ts @@ -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, }; } diff --git a/packages/agent-core-v2/test/background/output-access.test.ts b/packages/agent-core-v2/test/background/output-access.test.ts index 97ceeed16..68be5326e 100644 --- a/packages/agent-core-v2/test/background/output-access.test.ts +++ b/packages/agent-core-v2/test/background/output-access.test.ts @@ -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; 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 () => { diff --git a/packages/agent-core-v2/test/background/persistence-compat.test.ts b/packages/agent-core-v2/test/background/persistence-compat.test.ts index 70eab217d..d5c614151 100644 --- a/packages/agent-core-v2/test/background/persistence-compat.test.ts +++ b/packages/agent-core-v2/test/background/persistence-compat.test.ts @@ -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): Promise { - 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; expect(raw['taskId']).toBe('bash-orphan01'); expect(raw['task_id']).toBeUndefined(); diff --git a/packages/agent-core-v2/test/background/reconcile.test.ts b/packages/agent-core-v2/test/background/reconcile.test.ts index 8fbe19373..260e26d38 100644 --- a/packages/agent-core-v2/test/background/reconcile.test.ts +++ b/packages/agent-core-v2/test/background/reconcile.test.ts @@ -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; function persistedProcess( overrides: Partial> = {}, @@ -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([]); }); }); diff --git a/packages/agent-core-v2/test/background/rpc-events.test.ts b/packages/agent-core-v2/test/background/rpc-events.test.ts index d5032c6f5..a5d7be1f3 100644 --- a/packages/agent-core-v2/test/background/rpc-events.test.ts +++ b/packages/agent-core-v2/test/background/rpc-events.test.ts @@ -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['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; } 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 | 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', diff --git a/packages/agent-core-v2/test/background/stubs.ts b/packages/agent-core-v2/test/background/stubs.ts index e552f9dbb..356de1442 100644 --- a/packages/agent-core-v2/test/background/stubs.ts +++ b/packages/agent-core-v2/test/background/stubs.ts @@ -12,12 +12,13 @@ export type BackgroundServiceTestManager = IBackgroundService & { reconcile(): Promise; }; +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, ); diff --git a/packages/agent-core-v2/test/contextMemory/context.test.ts b/packages/agent-core-v2/test/contextMemory/context.test.ts index 912e8ca51..a388bca68 100644 --- a/packages/agent-core-v2/test/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/contextMemory/context.test.ts @@ -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: 'Tool output is empty.' }], @@ -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: '\nskill body\n' }], 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: '\nskill body\n' }, ]); - 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: '\nsecond reminder\n' }, ]); - 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(''); } diff --git a/packages/agent-core-v2/test/cron/agent-integration.test.ts b/packages/agent-core-v2/test/cron/agent-integration.test.ts index d8bdbdfb3..253ffe692 100644 --- a/packages/agent-core-v2/test/cron/agent-integration.test.ts +++ b/packages/agent-core-v2/test/cron/agent-integration.test.ts @@ -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([]); + }); }); }); diff --git a/packages/agent-core-v2/test/cron/cron.e2e.test.ts b/packages/agent-core-v2/test/cron/cron.e2e.test.ts index 61d25a8d2..1b6d5287d 100644 --- a/packages/agent-core-v2/test/cron/cron.e2e.test.ts +++ b/packages/agent-core-v2/test/cron/cron.e2e.test.ts @@ -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; 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', diff --git a/packages/agent-core-v2/test/cron/manual-tick.test.ts b/packages/agent-core-v2/test/cron/manual-tick.test.ts index 983695e56..1c3cf8596 100644 --- a/packages/agent-core-v2/test/cron/manual-tick.test.ts +++ b/packages/agent-core-v2/test/cron/manual-tick.test.ts @@ -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) { - 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(); - } + }); }); }); }); diff --git a/packages/agent-core-v2/test/cron/subagent-skip.test.ts b/packages/agent-core-v2/test/cron/subagent-skip.test.ts index eec991207..223fd16db 100644 --- a/packages/agent-core-v2/test/cron/subagent-skip.test.ts +++ b/packages/agent-core-v2/test/cron/subagent-skip.test.ts @@ -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); + } + }); }); }); diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts index 951f14b17..3943f9134 100644 --- a/packages/agent-core-v2/test/goal/goal.test.ts +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -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; -type AgentEvent = Parameters[0]; +type AgentEvent = Parameters[0]; type GoalUpdatedEvent = Extract; type GoalSnapshot = NonNullable['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, + ctx: TestAgentContext, + goals: IGoalService, records: readonly WireRecord[], ): Promise { - 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', () => { ]); }); }); +}); diff --git a/packages/agent-core-v2/test/goal/injection.test.ts b/packages/agent-core-v2/test/goal/injection.test.ts index 0c42ce0d6..46b417962 100644 --- a/packages/agent-core-v2/test/goal/injection.test.ts +++ b/packages/agent-core-v2/test/goal/injection.test.ts @@ -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['goal']>; type GoalServiceTestManager = IGoalService & GoalService; +type InjectableContextInjector = IContextInjector & { inject(): Promise }; -function createGoalInjectionReader( - getGoal: () => GoalSnapshot | null, - enabled?: () => boolean, -): { - read(): Promise; - 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 { + await injector.inject(); } -async function readGoalReminder( - configure: (goals: GoalServiceTestManager) => Promise, -): Promise { - 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): Promise { - await (ctx.get(IDynamicInjector) as unknown as { inject(): Promise }).inject(); -} - -async function registerLookupTool(ctx: ReturnType): Promise { - ctx.configure({ tools: ['Lookup'] }); +async function registerLookupTool( + ctx: TestAgentContext, + profile: IProfileService, +): Promise { + 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, + ): Promise { + 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(''); + 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(''); + }); + + 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); }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index e71ed688e..a3f805d38 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -3,72 +3,116 @@ import { Readable, type Writable } from 'node:stream'; import { createControlledPromise } from '@antfu/utils'; import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos'; -import type { ContentPart, ModelCapability, ProviderConfig } from '@moonshot-ai/kosong'; -import type { generate as kosongGenerate } from '@moonshot-ai/kosong'; +import type { ContentPart, ModelCapability, ProviderConfig, generate as kosongGenerate } from '@moonshot-ai/kosong'; import { expect, onTestFinished, vi } from 'vitest'; -import type { IConfigService } from '#/config'; -import type { IOAuthService } from '#/auth'; -import { ModelResolver, type IModelResolver } from '#/modelRuntime'; -import type { KimiConfig } from '../../../../src/config'; import { - InstantiationService, - ServiceCollection, - type IDisposable, - type ServiceIdentifier, -} from '../../../../src/di'; -import type { Logger } from '../../../../src/logging'; -import type { AgentAPI } from '../../../../src/rpc/core-api'; -import { - IApprovalService, - ILogService, - IQuestionService, - type ApprovalResponse, - type QuestionResult, -} from '../../../../src/services'; -import { - AgentRuntime, AGENT_WIRE_PROTOCOL_VERSION, + BackgroundService, + CronService, + ExternalHooksService, + FileStorageService, + FullCompactionService, IAgentRPCService, + IAppendLogStore, + IAppendLogStorage, + IApprovalService, + IAtomicDocumentStorage, IBackgroundService, + IBlobStorage, + IBootstrapOptions, + IBootstrapService, + IConfigService, IContextMemory, IContextProjector, IContextSizeService, ICronService, - IEventBus, + IEventSink, + IExternalHooksService, + IFileSystemBackend, + IFullCompaction, + ILLMRequester, + ILogService, + IMcpService, + IMicroCompactionService, + IModelProvider, + IPermissionGate, IPermissionModeService, IPermissionRulesService, - IPermissionService, + IProcessBackend, IProfileService, + IQuestionService, + ISessionContext, + IStorageService, + ISubagentHost, + ITelemetryService, + ITerminalBackend, IToolRegistry, IToolStoreService, IUsageService, IWireRecord, - InMemoryWireRecordPersistence, - createAgentRuntime, - type AgentRuntimeOptions, - type ContextMessage, - type PermissionMode, - type PermissionRule, - type PersistedWireRecord, - type ToolOutput, - type ToolResult, - type WireRecord, - type WireRecordPersistence, -} from '../../../../src/services/agent'; -import type { TelemetryClient } from '../../../../src/telemetry'; -import type { PromisifyMethods } from '../../../../src/utils/types'; -import { testKaos } from '../../../fixtures/test-kaos'; -import { createFakeKaos } from '../../../tools/fixtures/fake-kaos'; + IWorkspaceContext, + LLMRequesterService, + LifecycleScope, + McpService, + MicroCompactionService, + ModelProvider, + PermissionGate, + PermissionRulesService, + ProfileService, + SyncDescriptor, + WireRecordService, + WorkspaceContextService, + bootstrapSeed, + createCoreScope, + resolveBootstrapOptions, + type IDisposable, + type Scope, + type ScopeSeed, + type ServiceIdentifier, +} from '#/index'; +import { Event } from '#/_base/event'; +import { toDisposable } from '#/_base/di'; +import type { PromisifyMethods } from '#/_base/utils/types'; +import type { ApprovalResponse } from '#/approval'; +import { IBlobStoreService, type IBlobStoreService as BlobStoreService } from '#/blobStore'; +import type { ContextMessage } from '#/contextMemory'; +import type { HookEngine } from '#/externalHooks/engine'; +import type { FullCompactionServiceOptions } from '#/fullCompaction'; +import type { ILogger, LogContext, LogLevel } from '#/log'; +import type { McpServiceOptions } from '#/mcp'; +import type { MicroCompactionServiceOptions } from '#/microCompaction'; +import type { ModelProviderOptions } from '#/modelProvider'; +import type { PermissionGateOptions } from '#/permission'; +import type { PermissionMode } from '#/permissionPolicy'; +import type { PermissionRule, PermissionRulesServiceOptions } from '#/permissionRules'; +import { GoalService, IGoalService, type GoalServiceOptions } from '#/goal'; +import { IPlanService } from '#/plan'; +import { + IReplayBuilderService, + ReplayBuilderService, + type ReplayBuilderServiceOptions, +} from '#/replayBuilder'; +import type { AgentAPI } from '#/rpc/core-api'; +import { + AgentSkillService, + IAgentSkillService, + type AgentSkillServiceOptions, + type SkillCatalog, +} from '#/skill'; +import { SubagentHostService, type SessionSubagentHost } from '#/subagentHost'; +import type { ToolOutput, ToolResult } from '#/toolRegistry'; +import type { PersistedWireRecord, WireRecord, WireRecordServiceOptions } from '#/wireRecord'; +import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createScriptedGenerate } from './scripted-generate'; import { DEFAULT_TEST_SYSTEM_PROMPT, - eventSnapshot, + type EventSnapshot, type EventSnapshotEntry, - type RpcSnapshotEntry, type WireSnapshotEntry, } from './snapshots'; +import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events'; const TEST_OS_ENV: Environment = { osKind: 'Linux', @@ -84,17 +128,87 @@ const MOCK_PROVIDER = { model: 'mock-model', } as const; -const RPC_RESPONSE = Symbol('rpcResponse'); +type QuestionResult = string; + +interface KimiConfig { + readonly providers: Record; + readonly models?: Record; + readonly defaultProvider?: string; + readonly defaultModel?: string; + readonly [domain: string]: unknown; +} + +interface ModelConfigForConfig { + readonly provider: string; + readonly model: string; + readonly maxContextSize: number; + readonly capabilities?: readonly string[]; +} + +interface ProviderConfigForConfig { + readonly type: ProviderConfig['type']; + readonly apiKey?: string; + readonly baseUrl?: string; + readonly oauth?: { + readonly storage: 'file' | 'keyring'; + readonly key: string; + readonly oauthHost?: string; + }; +} + +interface Logger { + info(message: string, payload?: unknown): void; + warn(message: string, payload?: unknown): void; + error(message: string, payload?: unknown): void; + debug(message: string, payload?: unknown): void; + createChild?(bindings: LogContext): Logger; + child?(bindings: LogContext): Logger; +} + +export interface WireRecordPersistence { + readonly records: readonly PersistedWireRecord[]; + read(): AsyncIterable; + append(event: PersistedWireRecord): void; + rewrite(records: readonly PersistedWireRecord[]): void; + flush(): Promise; + close(): Promise; +} + +export class InMemoryWireRecordPersistence implements WireRecordPersistence { + readonly records: PersistedWireRecord[]; + + constructor(records: readonly PersistedWireRecord[] = []) { + this.records = records.map(cloneRecord); + } + + async *read(): AsyncIterable { + for (const record of this.records) { + yield cloneRecord(record); + } + } + + append(event: PersistedWireRecord): void { + this.records.push(cloneRecord(event)); + } + + rewrite(records: readonly PersistedWireRecord[]): void { + this.records.splice(0, this.records.length, ...records.map(cloneRecord)); + } + + flush(): Promise { + return Promise.resolve(); + } + + close(): Promise { + return Promise.resolve(); + } +} type RpcPromise = Promise & { resolve(value: T): void; reject(reason?: unknown): void; }; -type RpcLogEntry = RpcSnapshotEntry & { - readonly [RPC_RESPONSE]?: RpcPromise; -}; - type PromiseAgentAPI = PromisifyMethods; type GenerateFn = typeof kosongGenerate; @@ -115,50 +229,10 @@ interface ResumeStateSnapshot { readonly history: readonly ContextMessage[]; readonly tokenCount: number; }; - readonly permission: ReturnType; + readonly permission: ReturnType; readonly tools: ReturnType; readonly toolStore: ReturnType; - readonly usage: ReturnType; -} - -export interface TestAgentOptions { - readonly kaos?: Kaos | undefined; - readonly runtime?: AgentRuntimeOptions['toolServices'] | undefined; - readonly toolServices?: AgentRuntimeOptions['toolServices'] | undefined; - readonly microCompaction?: AgentRuntimeOptions['microCompaction']; - readonly fullCompaction?: AgentRuntimeOptions['fullCompaction']; - readonly generate?: GenerateFn | undefined; - readonly hookEngine?: AgentRuntimeOptions['hookEngine']; - readonly type?: AgentRuntimeOptions['type']; - readonly permission?: AgentRuntimeOptions['permission']; - readonly permissionMode?: PermissionMode; - readonly permissionRules?: readonly PermissionRule[]; - readonly goal?: AgentRuntimeOptions['goal']; - readonly pluginSessionStarts?: AgentRuntimeOptions['pluginSessionStarts']; - readonly modelResolver?: IModelResolver; - readonly initialConfig?: KimiConfig; - readonly modelResolverOverrides?: { - readonly kimiRequestHeaders?: Record; - readonly promptCacheKey?: string; - }; - readonly sessionId?: string; - readonly agentId?: string; - readonly subagentHost?: AgentRuntimeOptions['subagentHost']; - readonly onEvent?: ((event: PersistedWireRecord) => PersistedWireRecord | undefined) | undefined; - readonly persistence?: WireRecordPersistence | undefined; - readonly homedir?: AgentRuntimeOptions['homedir']; - readonly telemetry?: TelemetryClient | undefined; - readonly log?: Logger; - readonly questionService?: IQuestionService; - readonly experimentalFlags?: AgentRuntimeOptions['experimentalFlags']; - readonly background?: AgentRuntimeOptions['background']; - readonly cron?: AgentRuntimeOptions['cron']; - readonly mcp?: AgentRuntimeOptions['mcp']; - readonly skills?: AgentRuntimeOptions['skills']; - readonly additionalDirs?: AgentRuntimeOptions['additionalDirs']; - readonly userTool?: AgentRuntimeOptions['userTool']; - readonly initializeTools?: AgentRuntimeOptions['initializeTools']; - readonly replay?: AgentRuntimeOptions['replay']; + readonly usage: ReturnType; } interface ConfigureOptions { @@ -169,6 +243,246 @@ interface ConfigureOptions { export type TestAgentContext = AgentTestContext; +type MutableScopeSeed = Array, unknown]>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyCtor = new (...args: any[]) => T; +type TestAgentServiceScope = 'core' | 'session' | 'agent'; + +export interface TestAgentServiceRegistration { + define(id: ServiceIdentifier, ctor: AnyCtor): void; + defineDescriptor(id: ServiceIdentifier, descriptor: SyncDescriptor): void; + defineInstance(id: ServiceIdentifier, instance: T): void; + definePartialInstance(id: ServiceIdentifier, instance: Partial): void; +} + +export type TestAgentServiceGroup = (reg: TestAgentServiceRegistration) => void; + +interface TestAgentScopedServiceOverride { + readonly scope: TestAgentServiceScope; + register(reg: TestAgentServiceRegistration): void; +} + +export type TestAgentServiceOverride = + | TestAgentScopedServiceOverride + | readonly TestAgentServiceOverride[]; + +export function coreServices(group: TestAgentServiceGroup): TestAgentServiceOverride { + return scopedServices('core', group); +} + +export function sessionServices(group: TestAgentServiceGroup): TestAgentServiceOverride { + return scopedServices('session', group); +} + +export function agentServices(group: TestAgentServiceGroup): TestAgentServiceOverride { + return scopedServices('agent', group); +} + +export function coreService( + id: ServiceIdentifier, + value: T | SyncDescriptor, +): TestAgentServiceOverride { + return coreServices((reg) => defineServiceValue(reg, id, value)); +} + +export function sessionService( + id: ServiceIdentifier, + value: T | SyncDescriptor, +): TestAgentServiceOverride { + return sessionServices((reg) => defineServiceValue(reg, id, value)); +} + +export function agentService( + id: ServiceIdentifier, + value: T | SyncDescriptor, +): TestAgentServiceOverride { + return agentServices((reg) => defineServiceValue(reg, id, value)); +} + +function scopedServices( + scope: TestAgentServiceScope, + register: TestAgentServiceGroup, +): TestAgentScopedServiceOverride { + return { scope, register }; +} + +function defineServiceValue( + reg: TestAgentServiceRegistration, + id: ServiceIdentifier, + value: T | SyncDescriptor, +): void { + if (value instanceof SyncDescriptor) { + reg.defineDescriptor(id, value); + } else { + reg.defineInstance(id, value); + } +} + +export function kaosServices(kaos: Kaos): TestAgentServiceOverride { + return sessionServices((reg) => { + reg.defineInstance(IFileSystemBackend, createFileSystemBackend(kaos)); + reg.defineInstance(IProcessBackend, createProcessBackend(kaos)); + reg.defineInstance(IWorkspaceContext, new WorkspaceContextService(kaos.getcwd())); + }); +} + +export function homeDirServices(homeDir: string | undefined): TestAgentServiceOverride { + return coreServices((reg) => { + if (homeDir !== undefined) { + reg.defineInstance( + IBootstrapOptions, + resolveBootstrapOptions({ homeDir, cwd: process.cwd(), env: process.env }), + ); + const file = (): SyncDescriptor => + new SyncDescriptor(FileStorageService, [homeDir], true); + reg.defineDescriptor(IStorageService, file()); + reg.defineDescriptor(IAppendLogStorage, file()); + reg.defineDescriptor(IAtomicDocumentStorage, file()); + reg.defineDescriptor(IBlobStorage, file()); + } + }); +} + +export function additionalDirServices(additionalDirs: readonly string[]): TestAgentServiceOverride { + return sessionServices((reg) => { + const workspace = new WorkspaceContextService(process.cwd()); + for (const dir of additionalDirs) { + workspace.addAdditionalDir(dir); + } + reg.defineInstance(IWorkspaceContext, workspace); + }); +} + +export function initializeToolServices(initializeTools: () => void): TestAgentServiceOverride { + return agentServices((reg) => { + reg.defineDescriptor( + IProfileService, + new SyncDescriptor(ProfileService, [{ + cwd: () => process.cwd(), + chdir: async () => {}, + initializeBuiltinTools: initializeTools, + }]), + ); + }); +} + +export function modelProviderServices(modelProvider: IModelProvider): TestAgentServiceOverride { + return sessionService(IModelProvider, modelProvider); +} + +export function modelProviderOptionServices( + options: Omit, +): TestAgentServiceOverride { + return sessionService(IModelProvider, new SyncDescriptor(ConfigBackedModelProvider, [options])); +} + +export function configServices(readConfig: () => KimiConfig): TestAgentServiceOverride { + return coreService(IConfigService, configService(readConfig)); +} + +export function wireRecordPersistenceServices( + persistence: WireRecordPersistence, + onRead: (event: PersistedWireRecord) => void = () => {}, +): TestAgentServiceOverride { + return coreService( + IAppendLogStore, + new PersistenceAppendLogStore(persistence, () => {}, onRead), + ); +} + +export function logServices(logger: Logger): TestAgentServiceOverride { + return coreService(ILogService, createLogService(logger)); +} + +export function llmGenerateServices(generate: GenerateFn): TestAgentServiceOverride { + return agentService(ILLMRequester, new SyncDescriptor(LLMRequesterService, [{ generate }])); +} + +export function telemetryServices(telemetry: ITelemetryService): TestAgentServiceOverride { + return coreService(ITelemetryService, telemetry); +} + +export function questionServices(service: IQuestionService): TestAgentServiceOverride { + return sessionService(IQuestionService, service); +} + +export function externalHookServices( + hookEngine: Pick | undefined, +): TestAgentServiceOverride { + return agentService( + IExternalHooksService, + new SyncDescriptor(ExternalHooksService, [hookEngine === undefined ? {} : { hookEngine }]), + ); +} + +export function microCompactionServices( + options: MicroCompactionServiceOptions, +): TestAgentServiceOverride { + return agentService( + IMicroCompactionService, + new SyncDescriptor(MicroCompactionService, [options]), + ); +} + +export function fullCompactionServices( + options: FullCompactionServiceOptions, +): TestAgentServiceOverride { + return agentService(IFullCompaction, new SyncDescriptor(FullCompactionService, [options])); +} + +export function permissionModeServices(mode: PermissionMode): TestAgentServiceOverride { + return agentService( + IPermissionGate, + new SyncDescriptor(PermissionGate, [{ initialMode: mode } satisfies PermissionGateOptions]), + ); +} + +export function permissionRulesServices(rules: readonly PermissionRule[]): TestAgentServiceOverride { + return agentService( + IPermissionRulesService, + new SyncDescriptor(PermissionRulesService, [ + { initialRules: rules } satisfies PermissionRulesServiceOptions, + ]), + ); +} + +export function backgroundServices(): TestAgentServiceOverride { + return agentService(IBackgroundService, new SyncDescriptor(BackgroundService)); +} + +export function cronServices( + options: ConstructorParameters[0], +): TestAgentServiceOverride { + return agentService(ICronService, new SyncDescriptor(CronService, [options])); +} + +export function mcpServices(options: McpServiceOptions): TestAgentServiceOverride { + return agentService(IMcpService, new SyncDescriptor(McpService, [options])); +} + +export function skillServices(input: AgentSkillServiceOptions | SkillCatalog): TestAgentServiceOverride { + const options: AgentSkillServiceOptions = + isSkillCatalog(input) ? { catalog: input } : input; + return agentService(IAgentSkillService, new SyncDescriptor(AgentSkillService, [options])); +} + +function isSkillCatalog(input: AgentSkillServiceOptions | SkillCatalog): input is SkillCatalog { + return 'getSkill' in input; +} + +export function subagentHostServices(host: SessionSubagentHost): TestAgentServiceOverride { + return agentService(ISubagentHost, new SyncDescriptor(SubagentHostService, [host])); +} + +export function goalServices(options: GoalServiceOptions): TestAgentServiceOverride { + return agentService(IGoalService, new SyncDescriptor(GoalService, [options])); +} + +export function replayServices(options: ReplayBuilderServiceOptions = {}): TestAgentServiceOverride { + return agentService(IReplayBuilderService, new SyncDescriptor(ReplayBuilderService, [options])); +} + export function createCommandKaos(stdout: string): Kaos { function createProcess(): KaosProcess { return { @@ -190,25 +504,196 @@ export function createCommandKaos(stdout: string): Kaos { }); } -export function testAgent(options: TestAgentOptions = {}): AgentTestContext { - return new AgentTestContext(options); +export function testAgent(...overrides: readonly TestAgentServiceOverride[]): AgentTestContext { + return new AgentTestContext(overrides, { + autoCloseOnTestFinished: true, + autoConfigure: false, + }); +} + +export function createTestAgent(...overrides: readonly TestAgentServiceOverride[]): AgentTestContext { + return new AgentTestContext(overrides, { + autoCloseOnTestFinished: false, + autoConfigure: true, + }); +} + +function flattenServiceOverrides( + overrides: readonly TestAgentServiceOverride[], +): TestAgentScopedServiceOverride[] { + const flattened: TestAgentScopedServiceOverride[] = []; + for (const override of overrides) { + if (Array.isArray(override)) { + flattened.push(...flattenServiceOverrides(override)); + } else { + flattened.push(override as TestAgentScopedServiceOverride); + } + } + return flattened; +} + +function collectScopeSeed( + baseGroups: readonly TestAgentServiceGroup[], + overrides: readonly TestAgentScopedServiceOverride[], + scope: TestAgentServiceScope, +): ScopeSeed { + const seed: MutableScopeSeed = []; + const indexes = new Map, number>(); + + const register = ( + id: ServiceIdentifier, + value: T | Partial | SyncDescriptor, + overwrite: boolean, + ): void => { + const key = id as ServiceIdentifier; + const entry = [key, value] as const; + const existing = indexes.get(key); + if (existing !== undefined) { + if (overwrite) { + seed[existing] = entry; + } + return; + } + indexes.set(key, seed.length); + seed.push(entry); + }; + + const baseReg: TestAgentServiceRegistration = { + define: (id, ctor) => register(id, new SyncDescriptor(ctor), false), + defineDescriptor: (id, descriptor) => register(id, descriptor, false), + defineInstance: (id, instance) => register(id, instance, false), + definePartialInstance: (id, instance) => register(id, instance, false), + }; + for (const group of baseGroups) { + group(baseReg); + } + + const additionalReg: TestAgentServiceRegistration = { + define: (id, ctor) => register(id, new SyncDescriptor(ctor), true), + defineDescriptor: (id, descriptor) => register(id, descriptor, true), + defineInstance: (id, instance) => register(id, instance, true), + definePartialInstance: (id, instance) => register(id, instance, true), + }; + for (const override of overrides) { + if (override.scope === scope) { + override.register(additionalReg); + } + } + + return seed; +} + +class PersistenceAppendLogStore implements IAppendLogStore { + declare readonly _serviceBrand: undefined; + + constructor( + private readonly persistence: WireRecordPersistence, + private readonly onAppend: (event: PersistedWireRecord) => void, + private readonly onRead: (event: PersistedWireRecord) => void, + ) {} + + append(_scope: string, _key: string, record: R): void { + const event = record as PersistedWireRecord; + this.onAppend(event); + this.persistence.append(event); + } + + async *read(_scope: string, _key: string): AsyncIterable { + for await (const event of this.persistence.read()) { + this.onRead(event); + yield event as R; + } + } + + rewrite(_scope: string, _key: string, records: readonly R[]): Promise { + this.persistence.rewrite(records as readonly PersistedWireRecord[]); + return Promise.resolve(); + } + + flush(): Promise { + return this.persistence.flush(); + } + + close(): Promise { + return this.persistence.close(); + } + + acquire(_scope: string, _key: string): IDisposable { + return toDisposable(() => {}); + } +} + +class AgentRuntime { + constructor( + private readonly core: Scope, + readonly session: Scope, + readonly agent: Scope, + readonly modelProvider: IModelProvider, + ) {} + + get(id: ServiceIdentifier): T { + return this.agent.accessor.get(id); + } + + async restore(records?: readonly PersistedWireRecord[]): Promise { + const wireRecord = this.get(IWireRecord); + await wireRecord.restore(records); + } + + async close(_reason?: string): Promise { + const wireRecord = this.get(IWireRecord); + await wireRecord.flush(); + await wireRecord.close(); + this.core.dispose(); + } +} + +class ConfigBackedModelProvider extends ModelProvider { + constructor( + options: Omit = {}, + @IConfigService config: IConfigService, + ) { + super({ config, ...options }); + } +} + +interface AgentTestContextLifecycle { + readonly autoCloseOnTestFinished: boolean; + readonly autoConfigure: boolean; +} + +class RecordingWireRecordService extends WireRecordService { + constructor( + options: WireRecordServiceOptions, + private readonly onAppend: (record: PersistedWireRecord) => void, + @IBlobStoreService blobStore?: BlobStoreService, + @IAppendLogStore log?: IAppendLogStore, + ) { + super(options, blobStore, log); + } + + override append(record: WireRecord): void { + const stamped: WireRecord = + record.time !== undefined ? record : ({ ...record, time: Date.now() } as WireRecord); + this.onAppend(stamped); + super.append(stamped); + } } export class AgentTestContext { - private readonly options: TestAgentOptions; + private readonly serviceOverrides: readonly TestAgentScopedServiceOverride[]; private readonly scriptedGenerate = createScriptedGenerate(); - private readonly recordHistory: PersistedWireRecord[] = []; - private readonly root: InstantiationService; + readonly recordHistory: PersistedWireRecord[] = []; + private readonly root: Scope; private readonly disposables: IDisposable[] = []; private suppressWireSnapshot = false; - private lastEventCount = 0; - private readonly uuidLabels = new Map(); - private kimiConfig: KimiConfig; + kimiConfig: KimiConfig; private cwd = process.cwd(); private closed = false; + readonly snapshots = recordAgentEvents(); readonly emitter = new EventEmitter(); - readonly allEvents: EventSnapshotEntry[] = []; + readonly allEvents: EventSnapshotEntry[] = this.snapshots.entries; readonly runtime: AgentRuntime; readonly rpc: PromiseAgentAPI; readonly llmCalls = this.scriptedGenerate.calls; @@ -217,127 +702,176 @@ export class AgentTestContext { readonly mockNextResponse = this.scriptedGenerate.mockNextResponse; readonly mockNextProviderResponse = this.scriptedGenerate.mockNextProviderResponse; - readonly profile: IProfileService; - readonly context: IContextMemory; - readonly contextSize: IContextSizeService; - readonly projector: IContextProjector; - readonly wireRecord: IWireRecord; - readonly events: IEventBus; - readonly rpcMethods: IAgentRPCService; - readonly permission: IPermissionService; - readonly permissionMode: IPermissionModeService; - readonly permissionRules: IPermissionRulesService; - readonly tools: IToolRegistry; - readonly toolStore: IToolStoreService; - readonly background: IBackgroundService; - readonly cron: ICronService; - readonly usage: IUsageService; - - constructor(options: TestAgentOptions = {}) { - this.options = options; + constructor( + overrides: readonly TestAgentServiceOverride[] = [], + lifecycle: AgentTestContextLifecycle = { + autoCloseOnTestFinished: true, + autoConfigure: false, + }, + ) { + this.serviceOverrides = flattenServiceOverrides(overrides); this.emitter.on('error', () => {}); - this.kimiConfig = options.initialConfig ?? emptyConfig(); + this.kimiConfig = emptyConfig(); - const kaos = options.kaos ?? testKaos; - const toolServices = options.toolServices ?? options.runtime; - const modelResolver = options.modelResolver ?? new ModelResolver( - configService(() => this.kimiConfig), - stubOAuth(), - { - promptCacheKey: options.sessionId, - ...options.modelResolverOverrides, - }, - ); - const persistence = this.wrapPersistence( - options.persistence ?? new InMemoryWireRecordPersistence(), - ); + const kaos = createFakeKaos(); + const sessionId = 'test-session'; + const agentId = 'main'; + const persistence = new InMemoryWireRecordPersistence(); - const rootServices = new ServiceCollection(); - rootServices.set(IApprovalService, this.createApprovalService()); - rootServices.set(IQuestionService, options.questionService ?? this.createQuestionService()); - rootServices.set(ILogService, createLogService(options.log)); - this.root = new InstantiationService(rootServices); + const coreSeeds = collectScopeSeed([ + (reg) => { + for (const [id, value] of bootstrapSeed({ + homeDir: '/tmp/kimi-code-agent-core-v2-test', + cwd: this.cwd, + osHomeDir: kaos.gethome(), + env: process.env, + })) { + reg.defineInstance(id, value); + } + reg.defineInstance(IConfigService, configService(() => this.kimiConfig)); + reg.defineInstance( + IAppendLogStore, + new PersistenceAppendLogStore( + persistence, + () => {}, + (event) => { + this.recordHistory.push(cloneRecord(event)); + }, + ), + ); + reg.defineInstance(ILogService, createLogService(undefined)); + }, + ], this.serviceOverrides, 'core'); + this.root = createCoreScope({ extra: coreSeeds }); - this.runtime = createAgentRuntime(this.root, { - sessionId: options.sessionId, - agentId: options.agentId, - type: options.type, - homedir: options.homedir, - cwd: () => this.cwd, - chdir: async (nextCwd) => { - this.cwd = nextCwd; - await kaos.chdir(nextCwd); - }, - kaos, - config: () => this.kimiConfig, - modelResolver: modelResolver, - generate: options.generate ?? this.scriptedGenerate.generate, - toolServices, - mcp: options.mcp, - subagentHost: options.subagentHost, - telemetry: options.telemetry, - hookEngine: options.hookEngine, - experimentalFlags: options.experimentalFlags, - microCompaction: options.microCompaction, - fullCompaction: options.fullCompaction, - permission: options.permission, - permissionRules: options.permissionRules, - permissionMode: options.permissionMode, - skills: options.skills, - pluginSessionStarts: options.pluginSessionStarts, - additionalDirs: options.additionalDirs, - wireRecord: { persistence }, - replay: options.replay, - background: options.background, - cron: - options.cron === undefined - ? options.type === 'sub' - ? { autoStart: false } - : { autoStart: true } - : options.cron, - goal: options.goal, - userTool: { - executeUserTool: - options.userTool?.executeUserTool ?? - ((input, callOptions) => this.requestUserTool(input, callOptions)), - }, - initializeTools: options.initializeTools, + const bootstrap = this.root.accessor.get(IBootstrapService); + const session = this.root.createChild(LifecycleScope.Session, sessionId, { + extra: collectScopeSeed([ + (reg) => { + reg.defineInstance(ISessionContext, { + _serviceBrand: undefined, + sessionId, + workspaceId: 'test-workspace', + sessionDir: `${bootstrap.sessionsDir}/test-workspace/${sessionId}`, + metaScope: `sessions/test-workspace/${sessionId}/session-meta`, + }); + reg.defineInstance(IApprovalService, this.createApprovalService()); + reg.defineInstance(IQuestionService, this.createQuestionService()); + reg.defineInstance(IFileSystemBackend, createFileSystemBackend(kaos)); + reg.defineInstance(IProcessBackend, createProcessBackend(kaos)); + reg.defineInstance(ITerminalBackend, createTerminalBackend()); + reg.defineDescriptor(IWorkspaceContext, new SyncDescriptor(WorkspaceContextService, [this.cwd])); + reg.defineDescriptor(IModelProvider, new SyncDescriptor(ConfigBackedModelProvider, [{}])); + }, + ], this.serviceOverrides, 'session'), + }); + const workspace = session.accessor.get(IWorkspaceContext); + + const agent = session.createChild(LifecycleScope.Agent, agentId, { + extra: collectScopeSeed([ + (reg) => { + reg.defineDescriptor(IWireRecord, new SyncDescriptor(RecordingWireRecordService, [ + { homedir: bootstrap.homeDir }, + (event: PersistedWireRecord) => this.captureRecord(event), + ])); + reg.defineDescriptor(IProfileService, new SyncDescriptor(ProfileService, [{ + cwd: () => this.cwd, + chdir: async (nextCwd: string) => { + this.cwd = nextCwd; + workspace.setWorkDir(nextCwd); + }, + }])); + reg.defineDescriptor( + ILLMRequester, + new SyncDescriptor(LLMRequesterService, [{ generate: this.scriptedGenerate.generate }]), + ); + reg.defineDescriptor(IExternalHooksService, new SyncDescriptor(ExternalHooksService, [{}])); + reg.defineDescriptor(IMicroCompactionService, new SyncDescriptor(MicroCompactionService, [{}])); + reg.defineDescriptor(IFullCompaction, new SyncDescriptor(FullCompactionService, [{}])); + reg.defineDescriptor( + IPermissionRulesService, + new SyncDescriptor(PermissionRulesService, [{} satisfies PermissionRulesServiceOptions]), + ); + reg.defineDescriptor( + IPermissionGate, + new SyncDescriptor(PermissionGate, [{ + sessionId, + agentId, + agentType: 'main', + cwd: this.cwd, + additionalDirs: [], + } satisfies PermissionGateOptions]), + ); + reg.defineDescriptor(ICronService, new SyncDescriptor(CronService, [{ autoStart: true }])); + reg.defineDescriptor(IBackgroundService, new SyncDescriptor(BackgroundService)); + reg.defineDescriptor(IMcpService, new SyncDescriptor(McpService, [{}])); + reg.defineDescriptor(IReplayBuilderService, new SyncDescriptor(ReplayBuilderService, [{}])); + reg.defineDescriptor(IGoalService, new SyncDescriptor(GoalService, [{}])); + reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService, [{}])); + reg.defineDescriptor( + ISubagentHost, + new SyncDescriptor(SubagentHostService, [unavailableSubagentHost()]), + ); + }, + ], this.serviceOverrides, 'agent'), }); - this.profile = this.get(IProfileService); - this.context = this.get(IContextMemory); - this.contextSize = this.get(IContextSizeService); - this.projector = this.get(IContextProjector); - this.wireRecord = this.get(IWireRecord); - this.events = this.get(IEventBus); - this.rpcMethods = this.get(IAgentRPCService); - this.permission = this.get(IPermissionService); - this.permissionMode = this.get(IPermissionModeService); - this.permissionRules = this.get(IPermissionRulesService); - this.tools = this.get(IToolRegistry); - this.toolStore = this.get(IToolStoreService); - this.background = this.get(IBackgroundService); - this.cron = this.get(ICronService); - this.usage = this.get(IUsageService); + this.runtime = new AgentRuntime(this.root, session, agent, session.accessor.get(IModelProvider)); + this.initializeRestorableServices(); + + const events = this.get(IEventSink); this.disposables.push( - this.events.on((event) => { + events.on((event) => { const { type, ...args } = event; this.recordRpc(type, args); }), ); - this.rpc = this.createPromiseAgentApi(this.rpcMethods); + const rpcMethods = this.get(IAgentRPCService); + this.rpc = this.createPromiseAgentApi(rpcMethods); - onTestFinished(async () => { - await this.close(); - }); + if (lifecycle.autoConfigure) { + this.configure(); + } + + if (lifecycle.autoCloseOnTestFinished) { + onTestFinished(async () => { + await this.close(); + }); + } } get(id: ServiceIdentifier): T { return this.runtime.get(id); } + private initializeRestorableServices(): void { + const context = this.get(IContextMemory); + const contextSize = this.get(IContextSizeService); + const usage = this.get(IUsageService); + const toolStore = this.get(IToolStoreService); + const background = this.get(IBackgroundService); + const permission = this.get(IPermissionGate); + const permissionMode = this.get(IPermissionModeService); + const permissionRules = this.get(IPermissionRulesService); + const cron = this.get(ICronService); + const plan = this.get(IPlanService); + + context.get(); + const microCompaction = this.get(IMicroCompactionService); + microCompaction; + contextSize.getStatus(); + usage.status(); + toolStore.data(); + background.list(false); + permission.data(); + void permissionMode.mode; + void permissionRules.rules; + cron.list(); + void plan.status(); + } + service(id: ServiceIdentifier): T { return this.get(id); } @@ -348,7 +882,8 @@ export class AgentTestContext { modelCapabilities, }: ConfigureOptions = {}): void { this.configureRuntimeModel(provider, modelCapabilities); - this.profile.update({ + const profile = this.get(IProfileService); + profile.update({ cwd: process.cwd(), modelAlias: provider.model, systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT, @@ -356,42 +891,48 @@ export class AgentTestContext { }); if (tools.length > 0) { - this.profile.update({ activeToolNames: [...tools] }); + profile.update({ activeToolNames: [...tools] }); } - this.lastEventCount = this.allEvents.length; + this.snapshots.drain(); } configureRuntimeModel( provider: ProviderConfig, modelCapabilities?: ModelCapability | undefined, ): void { - if (this.options.modelResolver === undefined) { - this.kimiConfig = configWithProvider(this.kimiConfig, provider, modelCapabilities); - } - this.profile.update({ modelAlias: provider.model }); + this.kimiConfig = configWithProvider(this.kimiConfig, provider, modelCapabilities); + const profile = this.get(IProfileService); + profile.update({ modelAlias: provider.model }); } - contextData(): { readonly history: readonly ContextMessage[]; readonly tokenCount: number } { + contextData(): { readonly history: readonly ContextMessage[]; readonly tokenCount: number; } { + const context = this.get(IContextMemory); + const contextSize = this.get(IContextSizeService); return { - history: this.context.getHistory(), - tokenCount: this.contextSize.getStatus().contextTokens, + history: context.get(), + tokenCount: contextSize.getStatus().contextTokens, }; } - project(messages: readonly ContextMessage[] = this.context.getHistory()) { - return this.projector.project(messages); + project(messages?: readonly ContextMessage[]) { + const context = this.get(IContextMemory); + const projector = this.get(IContextProjector); + return projector.project(messages ?? context.get()); } - toolsData(): Array[number] & { readonly active: boolean }> { - return this.tools.list().map((tool) => ({ + toolsData(): Array[number] & { readonly active: boolean; }> { + const profile = this.get(IProfileService); + const toolRegistry = this.get(IToolRegistry); + return toolRegistry.list().map((tool) => ({ ...tool, - active: this.profile.isToolActive(tool.name, tool.source), + active: profile.isToolActive(tool.name, tool.source), })); } toolStoreData(): ReturnType { - return this.toolStore.data(); + const toolStore = this.get(IToolStoreService); + return toolStore.data(); } appendUserMessage(content: readonly ContentPart[]): void { @@ -425,41 +966,39 @@ export class AgentTestContext { } clearContext(): void { - void this.rpcMethods.clearContext({}); + const rpcMethods = this.get(IAgentRPCService); + void rpcMethods.clearContext({}); } undoHistory(count: number): number { - return this.rpcMethods.undoHistory({ count }) as unknown as number; + const rpcMethods = this.get(IAgentRPCService); + return rpcMethods.undoHistory({ count }) as unknown as number; } - newEvents(): ReturnType { - const events = this.allEvents.slice(this.lastEventCount); - this.lastEventCount = this.allEvents.length; - return eventSnapshot(events, this.uuidLabels); + newEvents(): EventSnapshot { + return this.snapshots.drain(); } - untilTurnEnd(): Promise> { - return this.takeUntilRpc('turn.ended').then(({ events }) => events); + untilTurnEnd(): Promise { + return this.snapshots.until('turn.ended'); } - untilApprovalRequest(): Promise> { - return this.takeUntilRpc('requestApproval').then(({ events }) => events); + untilApprovalRequest(): Promise { + return this.snapshots.until('requestApproval'); } async takeApprovalRequest(): Promise<{ - events: ReturnType; + events: EventSnapshot; respond(response: ApprovalResponse): void; }> { - const { event, events } = await this.takeUntilRpc('requestApproval'); + const approval = await this.snapshots.take('requestApproval'); return { - events, - respond: (response) => { - this.resolveRpcRequest(event, response); - }, + events: approval.events, + respond: approval.respond, }; } - async untilApproval(approved: boolean): Promise> { + async untilApproval(approved: boolean): Promise { const { event, events } = await this.takeUntilRpc('requestApproval'); this.resolveRpcRequest(event, { decision: approved ? 'approved' : 'rejected', @@ -468,17 +1007,17 @@ export class AgentTestContext { return events; } - untilQuestionRequest(): Promise> { - return this.takeUntilRpc('requestQuestion').then(({ events }) => events); + untilQuestionRequest(): Promise { + return this.snapshots.until('requestQuestion'); } - async untilQuestion(result: QuestionResult): Promise> { + async untilQuestion(result: QuestionResult): Promise { const { event, events } = await this.takeUntilRpc('requestQuestion'); this.resolveRpcRequest(event, result); return events; } - async untilToolCall(result: TestToolResult): Promise> { + async untilToolCall(result: TestToolResult): Promise { const { event, events } = await this.takeUntilRpc('toolCall'); this.resolveRpcRequest(event, result); return events; @@ -494,22 +1033,24 @@ export class AgentTestContext { } } + async restore(records: readonly PersistedWireRecord[]): Promise { + this.suppressWireSnapshot = true; + try { + await this.runtime.restore(records); + } finally { + this.suppressWireSnapshot = false; + } + for (const record of records) { + this.captureRecord(record); + } + } + once(type: string): Promise { - return new Promise((resolve) => { - this.emitter.once(type, () => { - resolve(); - }); - }); + return this.snapshots.once(type); } onceAny(types: readonly string[]): Promise { - return new Promise((resolve) => { - for (const type of types) { - this.emitter.once(type, () => { - resolve(type); - }); - } - }); + return this.snapshots.onceAny(types); } appendExchange( @@ -625,31 +1166,26 @@ export class AgentTestContext { this.appendToolResult('call_open_one', 'one result'); } - compactHistory(): Array<{ readonly role: string; readonly text: string }> { - return this.context.getHistory().map((message) => ({ + compactHistory(): Array<{ readonly role: string; readonly text: string; }> { + const context = this.get(IContextMemory); + return context.get().map((message) => ({ role: message.role, text: message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''), })); } async expectResumeMatches(): Promise { - const resumed = testAgent({ - kaos: createResumeNoSideEffectKaos(this.profile.data().cwd), - runtime: this.options.runtime, - toolServices: this.options.toolServices, - modelResolver: this.options.modelResolver, - initialConfig: this.kimiConfig, - modelResolverOverrides: this.options.modelResolverOverrides, - generate: failOnResumeGenerate, - microCompaction: this.options.microCompaction, - fullCompaction: this.options.fullCompaction, - subagentHost: this.options.subagentHost, - experimentalFlags: this.options.experimentalFlags, - pluginSessionStarts: this.options.pluginSessionStarts, - persistence: new InMemoryWireRecordPersistence( + await this.drainWirePersistence(); + const profile = this.get(IProfileService); + const resumed = testAgent( + ...this.serviceOverrides, + kaosServices(createResumeNoSideEffectKaos(profile.data().cwd)), + configServices(() => this.kimiConfig), + agentService(ILLMRequester, new SyncDescriptor(LLMRequesterService, [{ generate: failOnResumeGenerate }])), + wireRecordPersistenceServices(new InMemoryWireRecordPersistence( withMetadata(this.recordHistory.map(cloneRecord)), - ), - }); + )), + ); await resumed.runtime.restore(); @@ -657,6 +1193,14 @@ export class AgentTestContext { expect(resumeStateSnapshot(resumed)).toEqual(resumeStateSnapshot(this)); } + private async drainWirePersistence(): Promise { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + const wireRecord = this.get(IWireRecord); + await wireRecord.flush(); + } + async close(reason = 'Agent runtime test closed'): Promise { if (this.closed) return; this.closed = true; @@ -664,78 +1208,35 @@ export class AgentTestContext { disposable.dispose(); } await this.runtime.close(reason); - this.root.dispose(); + } + + async dispose(): Promise { + await this.close(); } private takeUntilRpc(method: string): Promise<{ - event: RpcLogEntry; - events: ReturnType; + event: RecordedEventEntry; + events: EventSnapshot; }> { - const ready = this.findRpcFromCursor(method); - if (ready !== undefined) return Promise.resolve(this.takeThrough(ready)); - - const promise = createControlledPromise<{ - event: RpcLogEntry; - events: ReturnType; - }>(); - - const onEvent = () => { - const event = this.findRpcFromCursor(method); - if (event === undefined) return; - this.emitter.off('event', onEvent); - promise.resolve(this.takeThrough(event)); - }; - this.emitter.on('event', onEvent); - - return promise; - } - - private takeThrough(match: { event: RpcLogEntry; index: number }): { - event: RpcLogEntry; - events: ReturnType; - } { - const events = this.allEvents.slice(this.lastEventCount, match.index + 1); - this.lastEventCount = match.index + 1; - return { - event: match.event, - events: eventSnapshot(events, this.uuidLabels), - }; - } - - private findRpcFromCursor(method: string): { event: RpcLogEntry; index: number } | undefined { - const index = this.allEvents.findIndex((entry, eventIndex) => { - return eventIndex >= this.lastEventCount && entry.type === '[rpc]' && entry.event === method; - }); - if (index === -1) return undefined; - - const event = this.allEvents[index]!; - return { event: event as RpcLogEntry, index }; + return this.snapshots.take(method); } private recordWire(event: PersistedWireRecord): WireSnapshotEntry { - const { type, ...args } = event; - const entry: WireSnapshotEntry = { - type: '[wire]', - event: type, - args, - }; - this.allEvents.push(entry); - this.emitter.emit(type, entry); + const entry = this.snapshots.recordWire(event); + this.emitter.emit(entry.event, entry); this.emitter.emit('event', entry); return entry; } - private recordRpc(method: string, args: unknown, response?: RpcPromise): RpcLogEntry { - const event: RpcLogEntry = { - type: '[rpc]', - event: method, - args, - ...(response !== undefined ? { [RPC_RESPONSE]: response } : {}), - }; - this.allEvents.push(event); - this.emitter.emit(method, event); - this.emitter.emit('event', event); - return event; + private recordRpc( + method: string, + args: unknown, + response?: RpcPromise, + ): RecordedEventEntry { + const entry = this.snapshots.recordEmit(method, args, response); + this.emitter.emit(method, entry); + this.emitter.emit('event', entry); + return entry; } private createRpcPromise(signal?: AbortSignal): RpcPromise { @@ -753,24 +1254,12 @@ export class AgentTestContext { return promise; } - private resolveRpcRequest(event: RpcLogEntry, result: unknown): void { - const response = event[RPC_RESPONSE]; - if (response === undefined) { - throw new Error(`RPC ${event.event} does not have a pending response`); - } - response.resolve(result); + private resolveRpcRequest(event: RecordedEventEntry, result: unknown): void { + this.snapshots.respond(event, result); } private resolvePendingRpc(method: string, id: string, result: unknown): void { - const event = this.allEvents.find((entry) => { - if (entry.type !== '[rpc]' || entry.event !== method) return false; - if ((entry as RpcLogEntry)[RPC_RESPONSE] === undefined) return false; - return rpcCorrelationId(entry.args) === id; - }); - if (event === undefined) { - throw new Error(`No pending ${method} RPC with id ${id}`); - } - this.resolveRpcRequest(event as RpcLogEntry, result); + this.snapshots.respondPending(method, id, result); } private createApprovalService(): IApprovalService { @@ -782,7 +1271,13 @@ export class AgentTestContext { this.recordRpc('requestApproval', payload, promise); return promise; }, - resolve: (id, response) => { + enqueue: (request) => { + const id = request.id ?? request.toolCallId ?? `${request.toolName}:test`; + const { sessionId: _sessionId, agentId: _agentId, ...payload } = { ...request, id }; + this.recordRpc('requestApproval', payload); + return { ...request, id }; + }, + decide: (id, response) => { this.resolvePendingRpc('requestApproval', id, response); }, listPending: () => [], @@ -793,69 +1288,27 @@ export class AgentTestContext { return { _serviceBrand: undefined, request: (request) => { - const { sessionId: _sessionId, agentId: _agentId, ...payload } = request; const promise = this.createRpcPromise(); - this.recordRpc('requestQuestion', payload, promise); + this.recordRpc('requestQuestion', request, promise); return promise; }, - resolve: (id, response) => { - this.resolvePendingRpc('requestQuestion', id, response); + enqueue: (request) => { + this.recordRpc('requestQuestion', request); + return request; }, - dismiss: (id) => { - this.resolvePendingRpc('requestQuestion', id, null); + answer: (id, response) => { + this.resolvePendingRpc('requestQuestion', id, response); }, listPending: () => [], }; } - private requestUserTool( - input: { - readonly turnId: number; - readonly toolCallId: string; - readonly args: unknown; - }, - options?: { readonly signal?: AbortSignal }, - ): Promise { - const promise = this.createRpcPromise(options?.signal); - this.recordRpc('toolCall', input, promise); - options?.signal?.throwIfAborted(); - return promise; - } - - private wrapPersistence(persistence: WireRecordPersistence): WireRecordPersistence { - return { - read: () => this.readAndCapturePersistence(persistence), - append: (event) => { - this.captureRecord(event); - persistence.append(event); - }, - rewrite: (records) => { - persistence.rewrite(records); - }, - flush: () => persistence.flush(), - close: () => persistence.close(), - }; - } - - private async *readAndCapturePersistence( - persistence: WireRecordPersistence, - ): AsyncIterable { - for await (const event of persistence.read()) { - this.recordHistory.push(cloneRecord(event)); - yield event; - } - } - private captureRecord(event: PersistedWireRecord): void { const cloned = cloneRecord(event); this.recordHistory.push(cloned); if (this.suppressWireSnapshot) return; this.recordWire(cloned); - const response = this.options.onEvent?.(cloned); - if (response !== undefined && response.type !== 'metadata') { - void this.dispatch(response); - } } private createPromiseAgentApi(agent: IAgentRPCService): PromiseAgentAPI { @@ -899,7 +1352,8 @@ export class AgentTestContext { private appendMessage(...messages: ContextMessage[]): void { if (messages.length === 0) return; - this.context.spliceHistory(this.context.getHistory().length, 0, messages); + const context = this.get(IContextMemory); + context.splice(context.get().length, 0, messages); } private coverUsage(tokenTotal: number | undefined): void { @@ -912,12 +1366,83 @@ export class AgentTestContext { }; // Persist both the context-size measurement and turn-scoped usage so resume // rebuilds size and usage the same way the real loop does. - this.contextSize.measure(this.context.getHistory().length, tokenTotal); - this.usage.record(this.profile.data().modelAlias ?? 'mock-model', usage, 'turn'); - this.usage.endTurn(); + const context = this.get(IContextMemory); + const contextSize = this.get(IContextSizeService); + contextSize.measured(context.get().length, tokenTotal); + const profile = this.get(IProfileService); + const usageService = this.get(IUsageService); + usageService.record(profile.data().modelAlias ?? 'mock-model', usage, { + type: 'turn', + turnId: context.get().length, + }); } } +function createFileSystemBackend(kaos: Kaos): IFileSystemBackend { + return { + _serviceBrand: undefined, + readText: (absPath) => kaos.readText(absPath), + writeText: (absPath, data) => kaos.writeText(absPath, data).then(() => undefined), + readBytes: (absPath) => kaos.readBytes(absPath), + writeBytes: (absPath, data) => kaos.writeBytes(absPath, Buffer.from(data)).then(() => undefined), + stat: async (absPath) => { + const stat = await kaos.stat(absPath); + const value = stat as unknown as { + isFile?: boolean | (() => boolean); + isDirectory?: boolean | (() => boolean); + size?: number; + }; + return { + isFile: typeof value.isFile === 'function' ? value.isFile() : value.isFile === true, + isDirectory: + typeof value.isDirectory === 'function' + ? value.isDirectory() + : value.isDirectory === true, + size: value.size ?? 0, + }; + }, + readdir: (absPath) => collectAsync(kaos.iterdir(absPath)), + glob: (absDir, pattern) => collectAsync(kaos.glob(absDir, pattern)), + mkdir: (absPath) => kaos.mkdir(absPath).then(() => undefined), + }; +} + +function createProcessBackend(kaos: Kaos): IProcessBackend { + return { + _serviceBrand: undefined, + spawn: (args, options) => kaos.withCwd(options.cwd).execWithEnv([...args], options.env), + }; +} + +function createTerminalBackend(): ITerminalBackend { + return { + _serviceBrand: undefined, + spawn: async () => ({ + onData: Event.None as Event, + onExit: Event.None as Event<{ exitCode: number | null; }>, + write: () => {}, + resize: () => {}, + kill: () => {}, + }), + }; +} + +function unavailableSubagentHost(): SessionSubagentHost { + const fail = async (): Promise => { + throw new Error('Subagent host is not configured in this test.'); + }; + return { + getSwarmItem: () => undefined, + startBtw: fail, + spawn: fail, + resume: fail, + retry: fail, + getProfileName: async () => undefined, + markActiveChildDetached: () => {}, + runQueued: async () => [], + }; +} + const failOnResumeGenerate: GenerateFn = async () => { throw new Error('Resume replay unexpectedly called the LLM'); }; @@ -955,17 +1480,29 @@ function createResumeNoSideEffectKaos(initialCwd: string): Kaos { } function resumeStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot { + const background = ctx.get(IBackgroundService); + const usage = ctx.get(IUsageService); + const toolStore = ctx.get(IToolStoreService); + const permission = ctx.get(IPermissionGate); return { - background: ctx.background.list(false), + background: background.list(false), config: configStateSnapshot(ctx), context: resumeContextSnapshot(ctx), - permission: ctx.permission.data(), + permission: permission.data(), tools: ctx.toolsData(), - toolStore: ctx.toolStore.data(), - usage: ctx.usage.data(), + toolStore: toolStore.data(), + usage: usage.status(), }; } +async function collectAsync(items: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of items) { + result.push(item); + } + return result; +} + function resumeContextSnapshot(ctx: AgentTestContext) { const context = ctx.contextData(); return { @@ -984,7 +1521,8 @@ function isSystemReminderMessage(message: ContextMessage): boolean { } function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config'] { - const data = ctx.profile.data(); + const profile = ctx.get(IProfileService); + const data = profile.data(); return { cwd: data.cwd, provider: data.provider, @@ -999,14 +1537,15 @@ function emptyConfig(): KimiConfig { } function configService(readConfig: () => KimiConfig): IConfigService { + const effectiveConfig = () => configWithEnvOverrides(readConfig()); return { _serviceBrand: undefined, ready: Promise.resolve(), onDidChange: () => ({ dispose: () => {} }), onDidSectionChange: () => ({ dispose: () => {} }), - get: (domain: string) => (readConfig() as Record)[domain] as T, + get: (domain: string) => (effectiveConfig() as Record)[domain] as T, inspect: (domain: string) => { - const value = (readConfig() as Record)[domain]; + const value = (effectiveConfig() as Record)[domain]; return { value, defaultValue: undefined, @@ -1014,7 +1553,7 @@ function configService(readConfig: () => KimiConfig): IConfigService { memoryValue: value, }; }, - getAll: () => readConfig() as never, + getAll: () => effectiveConfig() as never, set: () => Promise.resolve(), replace: () => Promise.resolve(), reload: () => Promise.resolve(), @@ -1022,17 +1561,64 @@ function configService(readConfig: () => KimiConfig): IConfigService { } as unknown as IConfigService; } -function stubOAuth(): IOAuthService { +function configWithEnvOverrides(config: KimiConfig): KimiConfig { + const maxCompletionTokens = + parseEnvCompletionTokens(process.env['KIMI_MODEL_MAX_COMPLETION_TOKENS']) ?? + parseEnvCompletionTokens(process.env['KIMI_MODEL_MAX_TOKENS']); + const cron = cronEnvOverrides(asMutableRecord(config['cron'])); + if (maxCompletionTokens === undefined && cron === undefined) return config; + const modelOverrides = asMutableRecord(config['modelOverrides']); return { - _serviceBrand: undefined, - startLogin: () => Promise.reject(new Error('not implemented')), - getFlow: () => undefined, - cancelLogin: () => Promise.reject(new Error('not implemented')), - logout: () => Promise.reject(new Error('not implemented')), - status: () => Promise.resolve({ loggedIn: false }), - resolveTokenProvider: () => undefined, - getCachedAccessToken: () => Promise.resolve(undefined), - } as unknown as IOAuthService; + ...config, + cron: cron ?? config['cron'], + modelOverrides: + maxCompletionTokens === undefined + ? modelOverrides + : { + ...modelOverrides, + maxCompletionTokens, + }, + }; +} + +function cronEnvOverrides(base: Record): Record | undefined { + const next = { ...base }; + let changed = false; + const setBoolean = (key: string, envName: string) => { + const value = parseEnvBoolean(process.env[envName]); + if (value === undefined) return; + next[key] = value; + changed = true; + }; + setBoolean('debug', 'KIMI_CRON_DEBUG'); + setBoolean('noJitter', 'KIMI_CRON_NO_JITTER'); + setBoolean('noStale', 'KIMI_CRON_NO_STALE'); + setBoolean('disabled', 'KIMI_DISABLE_CRON'); + setBoolean('manualTick', 'KIMI_CRON_MANUAL_TICK'); + if (process.env['KIMI_CRON_CLOCK'] !== undefined) { + next['clock'] = process.env['KIMI_CRON_CLOCK']; + changed = true; + } + return changed ? next : undefined; +} + +function parseEnvBoolean(raw: string | undefined): boolean | undefined { + if (raw === undefined) return undefined; + return raw === '1'; +} + +function parseEnvCompletionTokens(raw: string | undefined): number | undefined { + const value = raw?.trim(); + if (value === undefined || value.length === 0) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return undefined; + return parsed; +} + +function asMutableRecord(value: unknown): Record { + return value !== null && typeof value === 'object' + ? { ...(value as Record) } + : {}; } function configWithProvider( @@ -1058,6 +1644,8 @@ function configWithProvider( capabilities: capabilityNames(modelCapabilities), }, }, + defaultProvider: providerName, + defaultModel: provider.model, }; } @@ -1098,47 +1686,54 @@ function contentPartsFromToolOutput(output: ToolOutput): ContentPart[] { return [{ type: 'text', text: output }]; } -function createLogService(logger: Logger | undefined): ILogService { +function createLogService( + logger: Logger | undefined, + bindings: LogContext = {}, +): ILogService { + let level: LogLevel = 'debug'; return { _serviceBrand: undefined, - info: (obj, msg) => { - writeLog(logger, 'info', obj, msg); + get level() { + return level; }, - warn: (obj, msg) => { - writeLog(logger, 'warn', obj, msg); + setLevel: (next) => { + level = next; }, - error: (obj, msg) => { - writeLog(logger, 'error', obj, msg); + info: (message, payload) => { + writeLog(logger, 'info', message, payload, bindings); }, - debug: (obj, msg) => { - writeLog(logger, 'debug', obj, msg); + warn: (message, payload) => { + writeLog(logger, 'warn', message, payload, bindings); }, - child: (bindings: any) => createLogService(logger?.createChild(bindings)), + error: (message, payload) => { + writeLog(logger, 'error', message, payload, bindings); + }, + debug: (message, payload) => { + writeLog(logger, 'debug', message, payload, bindings); + }, + child: (childBindings) => createLogService( + logger?.child?.(childBindings) ?? logger?.createChild?.(childBindings) ?? logger, + { ...bindings, ...childBindings }, + ), + flush: () => Promise.resolve(), }; } function writeLog( logger: Logger | undefined, level: 'info' | 'warn' | 'error' | 'debug', - obj: object | string, - msg: string | undefined, + message: string, + payload: unknown, + bindings: LogContext, ): void { if (logger === undefined) return; - if (typeof obj === 'string') { - logger[level](msg === undefined ? obj : `${msg}: ${obj}`); - return; - } - logger[level](msg ?? 'agent runtime log', obj); -} - -function rpcCorrelationId(args: unknown): string | undefined { - if (args === null || typeof args !== 'object') return undefined; - const record = args as { readonly toolCallId?: unknown; readonly turnId?: unknown }; - if (typeof record.toolCallId === 'string') return record.toolCallId; - if (typeof record.turnId === 'string' || typeof record.turnId === 'number') { - return String(record.turnId); - } - return undefined; + const hasBindings = Object.keys(bindings).length > 0; + const mergedPayload = hasBindings + ? payload === undefined + ? bindings + : { ...bindings, payload } + : payload; + logger[level](message, mergedPayload); } function cloneRecord(event: T): T { diff --git a/packages/agent-core-v2/test/harness/index.ts b/packages/agent-core-v2/test/harness/index.ts index 1a742a7b2..e8a730477 100644 --- a/packages/agent-core-v2/test/harness/index.ts +++ b/packages/agent-core-v2/test/harness/index.ts @@ -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 { diff --git a/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts b/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts index aeb13d151..e32ae48c9 100644 --- a/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts +++ b/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts @@ -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 } - ).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 } + ).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['allEvents'][number], + TestAgentContext['allEvents'][number], { readonly type: '[rpc]' } >; function protocolEvents( - ctx: ReturnType, + 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; 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; - }, - }; -} diff --git a/packages/agent-core-v2/test/loop/basic.test.ts b/packages/agent-core-v2/test/loop/basic.test.ts index c249a8ae6..cd6b71ab4 100644 --- a/packages/agent-core-v2/test/loop/basic.test.ts +++ b/packages/agent-core-v2/test/loop/basic.test.ts @@ -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: '' }, { type: 'text', text: '' }); - 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: '' }, { type: 'text', text: '' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + + expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] tools.set_active_tools { "names": [], "time": "