From 9fed2af01c37d1badf8478758a0384523a0ac3e2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 1 Jul 2026 10:54:13 +0800 Subject: [PATCH 1/3] fix(agent-core-v2): fix tests --- .../scripts/check-domain-layers.mjs | 3 + .../src/filestore/fileStoreService.ts | 13 +- .../src/interaction/interaction.ts | 8 +- .../agent-core-v2/src/model/envOverlay.ts | 67 +- .../src/shellTools/shellToolsService.ts | 7 +- .../src/shellTools/tools/bash.ts | 25 +- .../agent-core-v2/src/userTool/userTool.ts | 2 +- .../src/userTool/userToolService.ts | 50 +- .../test/_base/tools/input-schema.test.ts | 77 ++ packages/agent-core-v2/test/goal/goal.test.ts | 2 + .../agent-core-v2/test/goal/injection.test.ts | 20 +- packages/agent-core-v2/test/harness/agent.ts | 235 ++++-- packages/agent-core-v2/test/loop/fixtures.ts | 576 +++++++++++++++ .../agent-core-v2/test/loop/retry.test.ts | 70 ++ .../agent-core-v2/test/loop/run-turn.test.ts | 484 ++++++++++++ .../agent-core-v2/test/model/model.test.ts | 13 + .../test/plan/plan-tools-telemetry.test.ts | 185 +++++ .../test/profile/config-state.test.ts | 17 +- .../test/shellTools/bash.test.ts | 6 +- .../test/shellTools/result-builder.test.ts | 152 ++++ .../test/shellTools/shellToolsService.test.ts | 6 +- .../test/skill/plugin-session-start.test.ts | 345 ++++++--- .../agent-core-v2/test/skill/skill.test.ts | 196 ++++- .../agent-core-v2/test/snapshot/events.ts | 140 ++++ .../test/subagentHost/agent-tool.test.ts | 689 +++++++++++++++++- .../agent-core-v2/test/swarm/swarm.test.ts | 541 +++++++++++++- .../test/todoList/todo-list.test.ts | 176 +++++ packages/agent-core-v2/test/tool/tool.test.ts | 116 +-- .../test/toolDedup/tool-dedup.test.ts | 295 +++++--- packages/agent-core-v2/test/turn/turn.test.ts | 6 +- 30 files changed, 4151 insertions(+), 371 deletions(-) create mode 100644 packages/agent-core-v2/test/_base/tools/input-schema.test.ts create mode 100644 packages/agent-core-v2/test/loop/fixtures.ts create mode 100644 packages/agent-core-v2/test/loop/retry.test.ts create mode 100644 packages/agent-core-v2/test/loop/run-turn.test.ts create mode 100644 packages/agent-core-v2/test/shellTools/result-builder.test.ts create mode 100644 packages/agent-core-v2/test/snapshot/events.ts create mode 100644 packages/agent-core-v2/test/todoList/todo-list.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index fe624516a..b984b7d21 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -155,6 +155,8 @@ const V1_PACKAGE = '@moonshot-ai/agent-core'; * storage backend bindings). * * - `permissionGate>approval` : permissionGate(Agent) requests approval(Session broker). + * - `userTool>interaction` : userTool(Agent) requests host-side execution + * through the Session interaction broker. * - `skill>turn` : skill activate starts a turn (same Agent scope intent). * - `turn>agent-lifecycle` : turn cancels sub-agents via lifecycle handle. * - `swarm>agent-lifecycle`: swarm spawns/manages sub-agents. @@ -177,6 +179,7 @@ const V1_PACKAGE = '@moonshot-ai/agent-core'; const ALLOWED_EXCEPTIONS = new Set([ 'bootstrap>skill', 'permissionGate>approval', + 'userTool>interaction', 'skill>turn', 'turn>agent-lifecycle', 'swarm>agent-lifecycle', diff --git a/packages/agent-core-v2/src/filestore/fileStoreService.ts b/packages/agent-core-v2/src/filestore/fileStoreService.ts index f3f86c651..ddba5f64f 100644 --- a/packages/agent-core-v2/src/filestore/fileStoreService.ts +++ b/packages/agent-core-v2/src/filestore/fileStoreService.ts @@ -28,6 +28,7 @@ import { const BLOB_SCOPE = 'files'; const INDEX_SCOPE = 'filestore'; const INDEX_KEY = 'index.json'; +const FILE_ID_REGEX = /^f_[A-Za-z0-9][A-Za-z0-9_-]*$/; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -37,12 +38,16 @@ interface IndexFile { readonly files: FileMeta[]; } +function isFileId(value: string): boolean { + return FILE_ID_REGEX.test(value); +} + function isFileMeta(value: unknown): value is FileMeta { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; const meta = value as Record; return ( typeof meta['id'] === 'string' && - meta['id'].startsWith('f_') && + isFileId(meta['id']) && typeof meta['name'] === 'string' && typeof meta['media_type'] === 'string' && typeof meta['size'] === 'number' && @@ -97,6 +102,9 @@ export class FileStoreService implements IFileStore { } async get(fileId: string): Promise { + if (!isFileId(fileId)) { + throw fileNotFoundError(fileId); + } await this.ensureIndex(); const meta = this.indexCache!.get(fileId); if (meta === undefined) { @@ -114,6 +122,9 @@ export class FileStoreService implements IFileStore { } async delete(fileId: string): Promise { + if (!isFileId(fileId)) { + throw fileNotFoundError(fileId); + } await this.ensureIndex(); if (!this.indexCache!.has(fileId)) { throw fileNotFoundError(fileId); diff --git a/packages/agent-core-v2/src/interaction/interaction.ts b/packages/agent-core-v2/src/interaction/interaction.ts index 5f403cffd..f973eac68 100644 --- a/packages/agent-core-v2/src/interaction/interaction.ts +++ b/packages/agent-core-v2/src/interaction/interaction.ts @@ -6,15 +6,15 @@ * response primitive (`request` → `respond`) with change notification * (`onDidChange`), a non-blocking enqueue (`enqueue`) for callers that observe * the outcome through the `onDidResolve` stream, and a `listPending` view. - * `approval` and `question` are typed specializations layered on top of this - * kernel; the kernel itself is domain-agnostic. Session-scoped — the pending - * set is keyed by session and dies with it. + * `approval`, `question`, and user-tool execution are typed specializations + * layered on top of this kernel; the kernel itself is domain-agnostic. + * Session-scoped — the pending set is keyed by session and dies with it. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; -export type InteractionKind = 'approval' | 'question'; +export type InteractionKind = 'approval' | 'question' | 'user_tool'; export interface InteractionOrigin { readonly agentId?: string; diff --git a/packages/agent-core-v2/src/model/envOverlay.ts b/packages/agent-core-v2/src/model/envOverlay.ts index 9c2ed6ad8..927961d0c 100644 --- a/packages/agent-core-v2/src/model/envOverlay.ts +++ b/packages/agent-core-v2/src/model/envOverlay.ts @@ -104,7 +104,31 @@ function withoutKey(value: unknown, key: string): unknown { export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { apply(effective, getEnv, validate) { const model = trimmed(getEnv('KIMI_MODEL_NAME')); - if (model === undefined) return []; + const temperature = parseFloatEnv( + getEnv('KIMI_MODEL_TEMPERATURE'), + 'KIMI_MODEL_TEMPERATURE', + ); + const topP = parseFloatEnv(getEnv('KIMI_MODEL_TOP_P'), 'KIMI_MODEL_TOP_P'); + const thinkingKeep = trimmed(getEnv('KIMI_MODEL_THINKING_KEEP')); + const maxCompletionTokens = + parseCompletionTokens(getEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS')) ?? + parseCompletionTokens(getEnv('KIMI_MODEL_MAX_TOKENS')); + + const changed: string[] = []; + + if (model === undefined) { + const modelOverrides = collectModelOverrides({ + temperature, + topP, + thinkingKeep, + maxCompletionTokens, + }); + if (modelOverrides !== undefined) { + effective['modelOverrides'] = modelOverrides; + changed.push('modelOverrides'); + } + return changed; + } const maxContextRaw = trimmed(getEnv('KIMI_MODEL_MAX_CONTEXT_SIZE')); const maxContextSize = @@ -136,18 +160,6 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { if (reasoningKey !== undefined) alias['reasoningKey'] = reasoningKey; if (adaptiveThinking !== undefined) alias['adaptiveThinking'] = adaptiveThinking; - const temperature = parseFloatEnv( - getEnv('KIMI_MODEL_TEMPERATURE'), - 'KIMI_MODEL_TEMPERATURE', - ); - const topP = parseFloatEnv(getEnv('KIMI_MODEL_TOP_P'), 'KIMI_MODEL_TOP_P'); - const thinkingKeep = trimmed(getEnv('KIMI_MODEL_THINKING_KEEP')); - const maxCompletionTokens = - parseCompletionTokens(getEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS')) ?? - parseCompletionTokens(getEnv('KIMI_MODEL_MAX_TOKENS')); - - const changed: string[] = []; - const models = asRecord(effective['models']); const nextModels = { ...models, [ENV_MODEL_ALIAS_KEY]: alias }; effective['models'] = validate('models', nextModels); @@ -156,12 +168,13 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { effective['defaultModel'] = ENV_MODEL_ALIAS_KEY; changed.push('defaultModel'); - const modelOverrides: Record = {}; - if (temperature !== undefined) modelOverrides['temperature'] = temperature; - if (topP !== undefined) modelOverrides['topP'] = topP; - if (thinkingKeep !== undefined) modelOverrides['thinkingKeep'] = thinkingKeep; - if (maxCompletionTokens !== undefined) modelOverrides['maxCompletionTokens'] = maxCompletionTokens; - if (Object.keys(modelOverrides).length > 0) { + const modelOverrides = collectModelOverrides({ + temperature, + topP, + thinkingKeep, + maxCompletionTokens, + }); + if (modelOverrides !== undefined) { effective['modelOverrides'] = modelOverrides; changed.push('modelOverrides'); } @@ -183,3 +196,19 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { } }, }; + +function collectModelOverrides(input: { + readonly temperature: number | undefined; + readonly topP: number | undefined; + readonly thinkingKeep: string | undefined; + readonly maxCompletionTokens: number | undefined; +}): Record | undefined { + const modelOverrides: Record = {}; + if (input.temperature !== undefined) modelOverrides['temperature'] = input.temperature; + if (input.topP !== undefined) modelOverrides['topP'] = input.topP; + if (input.thinkingKeep !== undefined) modelOverrides['thinkingKeep'] = input.thinkingKeep; + if (input.maxCompletionTokens !== undefined) { + modelOverrides['maxCompletionTokens'] = input.maxCompletionTokens; + } + return Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined; +} diff --git a/packages/agent-core-v2/src/shellTools/shellToolsService.ts b/packages/agent-core-v2/src/shellTools/shellToolsService.ts index 45135520c..657005841 100644 --- a/packages/agent-core-v2/src/shellTools/shellToolsService.ts +++ b/packages/agent-core-v2/src/shellTools/shellToolsService.ts @@ -12,6 +12,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IBackgroundService } from '#/background'; import { IKaos } from '#/kaos'; import { IProcessRunner } from '#/process'; +import { IProfileService } from '#/profile'; import { IToolRegistry } from '#/toolRegistry'; import { IShellToolsService } from './shellTools'; @@ -25,8 +26,12 @@ export class ShellToolsService implements IShellToolsService { @IProcessRunner runner: IProcessRunner, @IKaos kaos: IKaos, @IBackgroundService background: IBackgroundService, + @IProfileService profile: IProfileService, ) { - toolRegistry.register(new BashTool(runner, kaos, background)); + toolRegistry.register(new BashTool(runner, kaos, background, { + allowBackground: () => + profile.isToolActive('TaskOutput') && profile.isToolActive('TaskStop'), + })); } } diff --git a/packages/agent-core-v2/src/shellTools/tools/bash.ts b/packages/agent-core-v2/src/shellTools/tools/bash.ts index 746425adf..0b05f8808 100644 --- a/packages/agent-core-v2/src/shellTools/tools/bash.ts +++ b/packages/agent-core-v2/src/shellTools/tools/bash.ts @@ -157,25 +157,30 @@ function withoutBackgroundDescription(description: string): string { export class BashTool implements BuiltinTool { readonly name = 'Bash' as const; - readonly description: string; readonly parameters: Record = toInputJsonSchema(BashInputSchema); private readonly isWindowsBash: boolean; - private readonly allowBackground: boolean; + private readonly renderedDescription: string; + private readonly allowBackground: () => boolean; constructor( private readonly runner: IProcessRunner, private readonly kaos: IKaos, private readonly background: IBackgroundService, options?: { - allowBackground?: boolean; + allowBackground?: () => boolean; }, ) { this.isWindowsBash = this.kaos.osEnv.osKind === 'Windows'; - this.allowBackground = options?.allowBackground ?? true; - const rendered = renderBashDescription(this.kaos.osEnv.shellName); - this.description = this.allowBackground ? rendered : withoutBackgroundDescription(rendered); + this.allowBackground = options?.allowBackground ?? (() => true); + this.renderedDescription = renderBashDescription(this.kaos.osEnv.shellName); + } + + get description(): string { + return this.allowBackground() + ? this.renderedDescription + : withoutBackgroundDescription(this.renderedDescription); } resolveExecution(args: BashInput): ToolExecution { @@ -315,7 +320,7 @@ export class BashTool implements BuiltinTool { if (signal.aborted) return { isError: true, output: 'Aborted before command started' }; if (args.command.length === 0) return { isError: true, output: 'Command cannot be empty.' }; if (args.run_in_background !== true) return undefined; - if (!this.allowBackground) { + if (!this.allowBackground()) { return { isError: true, output: @@ -404,14 +409,16 @@ export class BashTool implements BuiltinTool { // The user explicitly moved a foreground call to the background to avoid // blocking the current turn. Steer the model away from waiting on it. // Only mention TaskOutput when the tool is actually available. - const avoid = this.allowBackground ? 'do NOT wait, poll, or call TaskOutput on it' : 'do NOT wait or poll'; + const avoid = this.allowBackground() + ? 'do NOT wait, poll, or call TaskOutput on it' + : 'do NOT wait or poll'; return ( 'next_step: The task now runs in the background. You will be automatically notified ' + `when it completes — ${avoid}; continue with your current work.\n` ); } // background_started: the model chose to launch in the background. - if (!this.allowBackground) { + if (!this.allowBackground()) { return 'next_step: You will be automatically notified when it completes.\n'; } return ( diff --git a/packages/agent-core-v2/src/userTool/userTool.ts b/packages/agent-core-v2/src/userTool/userTool.ts index e9204ac23..4173f360d 100644 --- a/packages/agent-core-v2/src/userTool/userTool.ts +++ b/packages/agent-core-v2/src/userTool/userTool.ts @@ -1,4 +1,4 @@ -import { createDecorator } from "#/_base/di"; +import { createDecorator } from '#/_base/di'; export interface UserToolRegistration { readonly name: string; diff --git a/packages/agent-core-v2/src/userTool/userToolService.ts b/packages/agent-core-v2/src/userTool/userToolService.ts index b464e8916..e57ebb897 100644 --- a/packages/agent-core-v2/src/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/userTool/userToolService.ts @@ -1,15 +1,17 @@ import { Disposable, type IDisposable, -} from "#/_base/di"; +} from '#/_base/di'; +import { abortable } from '#/_base/utils/abort'; import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult, + ToolResult, } from '#/tool'; +import { IInteractionService } from '#/interaction'; import { IProfileService } from '#/profile'; import { IToolRegistry } from '#/toolRegistry'; -import type { ToolResult } from '#/tool'; import { IWireRecord } from '#/wireRecord'; import { IUserToolService, @@ -18,6 +20,13 @@ import { import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +interface UserToolExecutionRequest { + readonly turnId?: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; +} + declare module '#/wireRecord' { interface WireRecordMap { 'tools.register_user_tool': UserToolRegistration; @@ -36,6 +45,7 @@ export class UserToolService extends Disposable implements IUserToolService { @IToolRegistry private readonly registry: IToolRegistry, @IProfileService private readonly profile: IProfileService, @IWireRecord private readonly wireRecord: IWireRecord, + @IInteractionService private readonly interaction: IInteractionService, ) { super(); this._register( @@ -86,14 +96,42 @@ export class UserToolService extends Disposable implements IUserToolService { } private async executeUserTool( - _context: ExecutableToolContext, - _name: string, - _args: unknown, + context: ExecutableToolContext, + name: string, + args: unknown, ): Promise { - throw new Error('TODO'); + const request = this.interaction.request({ + id: context.toolCallId, + kind: 'user_tool', + payload: { + turnId: numericTurnId(context.turnId), + toolCallId: context.toolCallId, + name, + args, + }, + origin: { + turnId: numericTurnId(context.turnId), + }, + }); + try { + return await abortable(request, context.signal); + } catch (error) { + if (context.signal.aborted) { + this.interaction.respond(context.toolCallId, { + output: `User tool "${name}" was aborted.`, + isError: true, + }); + } + throw error; + } } } +function numericTurnId(turnId: string): number | undefined { + const parsed = Number(turnId); + return Number.isFinite(parsed) ? parsed : undefined; +} + function toExecutableToolResult(result: ToolResult): ExecutableToolResult { if (result.isError === true) { return { diff --git a/packages/agent-core-v2/test/_base/tools/input-schema.test.ts b/packages/agent-core-v2/test/_base/tools/input-schema.test.ts new file mode 100644 index 000000000..b646c0f06 --- /dev/null +++ b/packages/agent-core-v2/test/_base/tools/input-schema.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + compileToolArgsValidator, + validateToolArgs, +} from '#/_base/tools/args-validator'; +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; + +function collectRequired(schema: unknown, acc: string[] = []): string[] { + if (Array.isArray(schema)) { + for (const item of schema) collectRequired(item, acc); + return acc; + } + if (typeof schema !== 'object' || schema === null) return acc; + for (const [key, value] of Object.entries(schema)) { + if (key === 'required' && Array.isArray(value)) { + for (const name of value) if (typeof name === 'string') acc.push(name); + } else { + collectRequired(value, acc); + } + } + return acc; +} + +describe('tool input JSON Schema', () => { + const inputSchema = z + .object({ + mode: z.enum(['read', 'write']).default('read'), + items: z + .array( + z + .object({ + label: z.string(), + description: z.string().default(''), + }) + .strict(), + ) + .default([]), + }) + .strict(); + + it('keeps defaulted fields out of `required`', () => { + const schema = toInputJsonSchema(inputSchema); + const required = collectRequired(schema); + + expect(required).not.toContain('mode'); + expect(required).not.toContain('items'); + expect(required).not.toContain('description'); + expect(required).toContain('label'); + }); + + it('accepts an empty object through runtime argument validation', () => { + const schema = toInputJsonSchema(inputSchema); + const validator = compileToolArgsValidator(schema); + + expect(validateToolArgs(validator, {})).toBeNull(); + }); + + it('rejects an unknown top-level argument through runtime validation', () => { + const schema = toInputJsonSchema(inputSchema); + const validator = compileToolArgsValidator(schema); + + expect(validateToolArgs(validator, { bogus: true })).not.toBeNull(); + }); + + it('rejects an unknown nested argument through runtime validation', () => { + const schema = toInputJsonSchema(inputSchema); + const validator = compileToolArgsValidator(schema); + + expect( + validateToolArgs(validator, { + items: [{ label: 'A', bogus: true }], + }), + ).not.toBeNull(); + }); +}); diff --git a/packages/agent-core-v2/test/goal/goal.test.ts b/packages/agent-core-v2/test/goal/goal.test.ts index 3943f9134..fc534e9a6 100644 --- a/packages/agent-core-v2/test/goal/goal.test.ts +++ b/packages/agent-core-v2/test/goal/goal.test.ts @@ -126,6 +126,7 @@ describe('GoalService creation', () => { it('replaces an existing goal when replace is set', async () => { const first = await goals.createGoal({ objective: 'first' }); const second = await goals.createGoal({ objective: 'second', replace: true }); + await ctx.wireRecord.flush(); expect(second.goalId).not.toBe(first.goalId); expect(goals.getGoal().goal?.objective).toBe('second'); @@ -246,6 +247,7 @@ describe('GoalService records', () => { await goals.setBudgetLimits({ budgetLimits: { turnBudget: 2 } }, 'model'); await goals.markBlocked({ reason: 'stuck' }); await goals.cancelGoal(); + await ctx.wireRecord.flush(); const recordsWithoutMetadata = goalRecords(records); expect(recordsWithoutMetadata).toEqual([ diff --git a/packages/agent-core-v2/test/goal/injection.test.ts b/packages/agent-core-v2/test/goal/injection.test.ts index 46b417962..088f25f5c 100644 --- a/packages/agent-core-v2/test/goal/injection.test.ts +++ b/packages/agent-core-v2/test/goal/injection.test.ts @@ -233,6 +233,14 @@ function goalReminderRecords(persistence: InMemoryWireRecordPersistence) { ); } +async function flushedGoalReminderRecords( + ctx: TestAgentContext, + persistence: InMemoryWireRecordPersistence, +) { + await ctx.wireRecord.flush(); + return goalReminderRecords(persistence); +} + function lastGoalReminder(context: IContextMemory): string | undefined { const message = context.get().findLast((item) => { return item.origin?.kind === 'injection' && item.origin.variant === 'goal'; @@ -270,7 +278,7 @@ describe('GoalInjection integration', () => { await injectDynamic(injector); - const goalRecords = goalReminderRecords(persistence); + const goalRecords = await flushedGoalReminderRecords(ctx, persistence); expect(goalRecords).toHaveLength(1); const text = JSON.stringify(goalRecords[0]); expect(text).toContain(''); @@ -282,7 +290,7 @@ describe('GoalInjection integration', () => { await injectDynamic(injector); await injectDynamic(injector); - expect(goalReminderRecords(persistence)).toHaveLength(1); + await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(1); }); it('injects one goal reminder per turn boundary, not per step', async () => { @@ -300,19 +308,19 @@ describe('GoalInjection integration', () => { await toolCallEvents; await ctx.untilTurnEnd(); - expect(goalReminderRecords(persistence)).toHaveLength(1); + await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.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); + await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(2); }); it('writes no goal record when there is no active goal', async () => { await injectDynamic(injector); - expect(goalReminderRecords(persistence)).toHaveLength(0); + await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(0); }); }); @@ -345,7 +353,7 @@ describe('GoalInjection integration', () => { await injectDynamic(injector); - expect(goalReminderRecords(persistence)).toHaveLength(0); + await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(0); }); }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 94cfd44fe..21eb34626 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -10,6 +10,7 @@ import { type ChatProvider, type ContentPart, type GenerateOptions, + KimiChatProvider, type Message as KosongMessage, type ModelCapability, type ProviderConfig, @@ -44,6 +45,7 @@ import { IExternalHooksService, IFileToolsService, IFullCompaction, + IInteractionService, IKaos, ILLMRequester, ILogService, @@ -109,6 +111,11 @@ import { IPromptService } from '#/prompt/prompt'; import { GoalService, IGoalService, type GoalServiceOptions } from '#/goal'; import { IPlanService } from '#/plan'; import { IQuestionService, type QuestionResult } from '#/question/question'; +import type { + Interaction, + InteractionRequest, + InteractionResolution, +} from '#/interaction'; import { IReplayBuilderService, ReplayBuilderService, @@ -122,7 +129,6 @@ import { ModelSkillTool } from '#/skill/tools/modelSkill'; import type { SkillCatalog } from '#/skill/types'; import { SubagentHostService, type SessionSubagentHost } from '#/subagentHost'; import type { ExecutableToolOutput as ToolOutput, ToolResult } from '#/tool'; -import type { UserToolExecutionHandler } from '#/userTool'; import type { PersistedWireRecord, WireRecord, @@ -263,6 +269,12 @@ type TestToolResult = ToolResult & { readonly content?: unknown; }; +interface UserToolInteractionPayload { + readonly turnId?: number; + readonly toolCallId: string; + readonly args: unknown; +} + interface ResumeStateSnapshot { readonly background: ReturnType; readonly config: { @@ -847,6 +859,7 @@ export class AgentTestContext { sessionDir: `${bootstrap.sessionsDir}/test-workspace/${sessionId}`, metaScope: `sessions/test-workspace/${sessionId}/session-meta`, }); + reg.defineInstance(IInteractionService, this.createInteractionService()); reg.defineInstance(IApprovalService, this.createApprovalService()); reg.defineInstance(IQuestionService, this.createQuestionService()); reg.defineInstance(IKaos, createIKaos(kaos)); @@ -894,13 +907,7 @@ export class AgentTestContext { reg.defineDescriptor(IReplayBuilderService, new SyncDescriptor(ReplayBuilderService, [{}])); reg.defineDescriptor(IGoalService, new SyncDescriptor(GoalService, [{}])); reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService)); - reg.defineDescriptor( - IUserToolService, - new SyncDescriptor(UserToolService, [{ - execute: (request: Parameters[0]) => - this.executeUserTool(request), - }]), - ); + reg.defineDescriptor(IUserToolService, new SyncDescriptor(UserToolService)); reg.defineDescriptor( ISubagentHost, new SyncDescriptor(SubagentHostService, [unavailableSubagentHost()]), @@ -986,6 +993,7 @@ export class AgentTestContext { const plan = this.get(IPlanService); const fileTools = this.get(IFileToolsService); const shellTools = this.get(IShellToolsService); + const userTools = this.get(IUserToolService); const swarm = this.get(ISwarmService); context.get(); @@ -993,6 +1001,7 @@ export class AgentTestContext { void microCompaction; void fileTools._serviceBrand; void shellTools._serviceBrand; + void userTools._serviceBrand; void swarm.isActive; contextSize.getStatus(); usage.status(); @@ -1398,6 +1407,73 @@ export class AgentTestContext { this.snapshots.respondPending(method, id, result); } + private createInteractionService(): IInteractionService { + const pending = new Map(); + function createTestInteraction( + request: InteractionRequest, + ): Interaction { + return { + id: request.id ?? 'interaction:test', + kind: request.kind, + payload: request.payload, + origin: request.origin ?? {}, + createdAt: Date.now(), + }; + } + return { + _serviceBrand: undefined, + request: (request: InteractionRequest) => { + if (request.kind !== 'user_tool') { + throw new Error(`Unsupported test interaction kind: ${request.kind}`); + } + const interaction = createTestInteraction(request); + pending.set(interaction.id, interaction); + const payload = request.payload as UserToolInteractionPayload; + const promise = this.createRpcPromise(); + promise.then( + () => pending.delete(interaction.id), + () => pending.delete(interaction.id), + ); + this.recordRpc( + 'toolCall', + { + turnId: payload.turnId, + toolCallId: payload.toolCallId, + args: payload.args, + }, + promise, + ); + return promise as unknown as Promise; + }, + enqueue: (request: InteractionRequest): Interaction => { + const interaction = createTestInteraction(request); + pending.set(interaction.id, interaction); + if (request.kind === 'user_tool') { + const payload = request.payload as UserToolInteractionPayload; + this.recordRpc('toolCall', { + turnId: payload.turnId, + toolCallId: payload.toolCallId, + args: payload.args, + }); + } + return interaction; + }, + respond: (id, response) => { + pending.delete(id); + this.resolvePendingRpc('toolCall', id, response); + }, + listPending: (kind) => { + const interactions = [...pending.values()]; + return kind === undefined + ? interactions + : interactions.filter((interaction) => interaction.kind === kind); + }, + isRecentlyResolved: () => false, + onDidChange: Event.None as Event, + onDidResolve: Event.None as Event, + }; + } + private createApprovalService(): IApprovalService { return { _serviceBrand: undefined, @@ -1444,21 +1520,6 @@ export class AgentTestContext { }; } - private executeUserTool: UserToolExecutionHandler = (request) => { - const turnId = Number(request.turnId); - const promise = this.createRpcPromise(request.signal); - this.recordRpc( - 'toolCall', - { - turnId: Number.isFinite(turnId) ? turnId : undefined, - toolCallId: request.toolCallId, - args: request.args, - }, - promise, - ); - return promise; - }; - private captureRecord(event: PersistedWireRecord): void { const cloned = cloneRecord(event); this.recordHistory.push(cloned); @@ -1824,19 +1885,32 @@ function configWithEnvOverrides(config: KimiConfig): KimiConfig { const maxCompletionTokens = parseEnvCompletionTokens(process.env['KIMI_MODEL_MAX_COMPLETION_TOKENS']) ?? parseEnvCompletionTokens(process.env['KIMI_MODEL_MAX_TOKENS']); + const temperature = parseEnvFloat(process.env['KIMI_MODEL_TEMPERATURE']); + const topP = parseEnvFloat(process.env['KIMI_MODEL_TOP_P']); + const thinkingKeep = process.env['KIMI_MODEL_THINKING_KEEP']?.trim(); const cron = cronEnvOverrides(asMutableRecord(config['cron'])); - if (maxCompletionTokens === undefined && cron === undefined) return config; + if ( + maxCompletionTokens === undefined && + temperature === undefined && + topP === undefined && + (thinkingKeep === undefined || thinkingKeep.length === 0) && + cron === undefined + ) { + return config; + } const modelOverrides = asMutableRecord(config['modelOverrides']); + if (temperature !== undefined) modelOverrides['temperature'] = temperature; + if (topP !== undefined) modelOverrides['topP'] = topP; + if (thinkingKeep !== undefined && thinkingKeep.length > 0) { + modelOverrides['thinkingKeep'] = thinkingKeep; + } + if (maxCompletionTokens !== undefined) { + modelOverrides['maxCompletionTokens'] = maxCompletionTokens; + } return { ...config, cron: cron ?? config['cron'], - modelOverrides: - maxCompletionTokens === undefined - ? modelOverrides - : { - ...modelOverrides, - maxCompletionTokens, - }, + modelOverrides, }; } @@ -1888,6 +1962,13 @@ function parseEnvCompletionTokens(raw: string | undefined): number | undefined { return parsed; } +function parseEnvFloat(raw: string | undefined): number | undefined { + const value = raw?.trim(); + if (value === undefined || value.length === 0) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + function asMutableRecord(value: unknown): Record { return value !== null && typeof value === 'object' ? { ...(value as Record) } @@ -1995,11 +2076,39 @@ function createLogService( function createGenerateBackedChatProviderFactory(generate: GenerateFn): IChatProviderFactory { return { _serviceBrand: undefined, - create: (config) => new GenerateBackedChatProvider(config, generate), + create: (config) => + config.type === 'kimi' + ? new GenerateBackedKimiChatProvider(config, generate) + : new GenerateBackedChatProvider(config, generate), register: () => {}, }; } +class GenerateBackedKimiChatProvider extends KimiChatProvider { + constructor( + config: Extract, + private readonly generateFn: GenerateFn, + ) { + super(config); + } + + override async generate( + systemPrompt: string, + tools: KosongTool[], + history: KosongMessage[], + options?: GenerateOptions, + ): Promise { + return generateBackedResponse( + this, + this.generateFn, + systemPrompt, + tools, + history, + options, + ); + } +} + class GenerateBackedChatProvider implements ChatProvider { readonly name: string; readonly modelName: string; @@ -2020,32 +2129,13 @@ class GenerateBackedChatProvider implements ChatProvider { history: KosongMessage[], options?: GenerateOptions, ): Promise { - const parts: StreamedMessagePart[] = []; - const result = await this.generateFn( + return generateBackedResponse( this, + this.generateFn, systemPrompt, tools, history, - { - onMessagePart: (part) => { - parts.push(structuredClone(part)); - }, - }, - { - signal: options?.signal, - auth: options?.auth, - }, - ); - return createStreamedMessage( - parts.length > 0 - ? normalizeProviderStreamParts(parts) - : partsFromGeneratedMessage(result.message), - { - id: result.id, - usage: result.usage, - finishReason: result.finishReason, - rawFinishReason: result.rawFinishReason, - }, + options, ); } @@ -2071,6 +2161,43 @@ class GenerateBackedChatProvider implements ChatProvider { } } +async function generateBackedResponse( + provider: ChatProvider, + generateFn: GenerateFn, + systemPrompt: string, + tools: KosongTool[], + history: KosongMessage[], + options?: GenerateOptions, +): Promise { + const parts: StreamedMessagePart[] = []; + const result = await generateFn( + provider, + systemPrompt, + tools, + history, + { + onMessagePart: (part) => { + parts.push(structuredClone(part)); + }, + }, + { + signal: options?.signal, + auth: options?.auth, + }, + ); + return createStreamedMessage( + parts.length > 0 + ? normalizeProviderStreamParts(parts) + : partsFromGeneratedMessage(result.message), + { + id: result.id, + usage: result.usage, + finishReason: result.finishReason, + rawFinishReason: result.rawFinishReason, + }, + ); +} + function modelParametersFromConfig(config: ProviderConfig): Record { return { model: modelNameFromConfig(config), diff --git a/packages/agent-core-v2/test/loop/fixtures.ts b/packages/agent-core-v2/test/loop/fixtures.ts new file mode 100644 index 000000000..c038883f8 --- /dev/null +++ b/packages/agent-core-v2/test/loop/fixtures.ts @@ -0,0 +1,576 @@ +import { emptyUsage, type Message, type ModelCapability, type TextPart, type ThinkPart, type TokenUsage, type ToolCall } from '@moonshot-ai/kosong'; + +import { + ToolAccesses, + type ExecutableTool, + type ExecutableToolResult, + type ToolDidExecuteContext, + type ToolExecution, + type ToolResult, + type ToolUpdate, + type ToolWillExecuteContext, +} from '#/tool'; +import { OrderedHookSlot } from '#/hooks'; +import type { ILogger as Logger } from '#/log'; +import { createLoopEventDispatcher, runTurn as runTurnImpl, type LLM, type LLMChatParams, type LLMChatResponse, type LoopEvent, type LoopHooks, type LoopLiveEventEmitter, type LoopMessageBuilder, type LoopRecordedEvent, type LoopStepStopReason, type RunTurnInput, type TurnResult } from '#/loop'; +import type { IToolExecutor } from '#/toolExecutor'; + +export type FakeOutputPart = TextPart | ThinkPart; + +export interface FakeLLMResponse extends LLMChatResponse { + readonly contentParts?: readonly FakeOutputPart[] | undefined; + readonly textDeltas?: readonly string[] | undefined; + readonly thinkDeltas?: readonly string[] | undefined; + readonly toolCallDeltas?: + | ReadonlyArray<{ readonly toolCallId: string; readonly name?: string; readonly argumentsPart?: string }> + | undefined; +} + +export interface FakeLLMOptions { + readonly responses: readonly FakeLLMResponse[]; + readonly throwOnIndex?: { readonly index: number; readonly error: unknown } | undefined; + readonly abortOnIndex?: + | { readonly index: number; readonly controller: AbortController } + | undefined; + readonly delayMs?: number | undefined; + readonly modelName?: string | undefined; + readonly capability?: ModelCapability | undefined; + readonly systemPrompt?: string | undefined; + readonly isRetryableError?: ((error: unknown) => boolean) | undefined; +} + +export class FakeLLM implements LLM { + readonly systemPrompt: string; + readonly modelName: string; + readonly capability?: ModelCapability | undefined; + readonly isRetryableError?: ((error: unknown) => boolean) | undefined; + readonly calls: LLMChatParams[] = []; + + private index = 0; + private readonly responses: readonly FakeLLMResponse[]; + private readonly throwOnIndex: FakeLLMOptions['throwOnIndex']; + private readonly abortOnIndex: FakeLLMOptions['abortOnIndex']; + private readonly delayMs: number; + + constructor(opts: FakeLLMOptions) { + this.systemPrompt = opts.systemPrompt ?? 'fake system prompt'; + this.modelName = opts.modelName ?? 'fake-model'; + this.capability = opts.capability; + this.responses = opts.responses; + this.throwOnIndex = opts.throwOnIndex; + this.abortOnIndex = opts.abortOnIndex; + this.delayMs = opts.delayMs ?? 0; + this.isRetryableError = opts.isRetryableError; + } + + async chat(params: LLMChatParams): Promise { + this.calls.push(params); + const current = this.index; + this.index += 1; + + if (this.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + } + + if (this.abortOnIndex !== undefined && this.abortOnIndex.index === current) { + this.abortOnIndex.controller.abort(); + } + + if (params.signal.aborted) { + const err = new Error('aborted'); + err.name = 'AbortError'; + throw err; + } + + if (this.throwOnIndex !== undefined && this.throwOnIndex.index === current) { + throw this.throwOnIndex.error; + } + + const response = this.responses[current]; + if (response === undefined) { + throw new Error(`FakeLLM ran out of responses at call ${String(current + 1)}`); + } + + for (const delta of response.textDeltas ?? []) { + params.onTextDelta?.(delta); + } + for (const delta of response.thinkDeltas ?? []) { + params.onThinkDelta?.(delta); + } + for (const delta of response.toolCallDeltas ?? []) { + params.onToolCallDelta?.(delta); + } + for (const part of response.contentParts ?? []) { + if (part.type === 'text') { + await params.onTextPart?.(part); + } else { + await params.onThinkPart?.(part); + } + } + + return response; + } + + get callCount(): number { + return this.calls.length; + } +} + +export type AppendCall = + | { kind: 'appendStepBegin'; input: Extract } + | { kind: 'appendStepEnd'; input: Extract } + | { kind: 'appendContentPart'; input: Extract } + | { kind: 'appendToolCall'; input: Extract } + | { kind: 'appendToolResult'; input: Extract }; + +export class RecordingContext { + readonly calls: AppendCall[] = []; + readonly buildMessagesCalls: number[] = []; + + private messages: Message[]; + + constructor(messages: Message[] = []) { + this.messages = messages; + } + + readonly buildMessages: LoopMessageBuilder = () => { + this.buildMessagesCalls.push(this.calls.length); + return this.messages; + }; + + readonly appendTranscriptRecord = async (record: LoopRecordedEvent): Promise => { + switch (record.type) { + case 'step.begin': + this.calls.push({ kind: 'appendStepBegin', input: record }); + return; + case 'step.end': + this.calls.push({ kind: 'appendStepEnd', input: record }); + return; + case 'content.part': + this.calls.push({ kind: 'appendContentPart', input: record }); + return; + case 'tool.call': + this.calls.push({ kind: 'appendToolCall', input: record }); + return; + case 'tool.result': + this.calls.push({ kind: 'appendToolResult', input: record }); + } + }; + + kinds(): AppendCall['kind'][] { + return this.calls.map((call) => call.kind); + } + + ofKind(kind: K): Extract[] { + return this.calls.filter((call): call is Extract => call.kind === kind); + } + + stepBegins(): Array> { + return this.ofKind('appendStepBegin').map((call) => call.input); + } + + stepEnds(): Array> { + return this.ofKind('appendStepEnd').map((call) => call.input); + } + + contentParts(): Array> { + return this.ofKind('appendContentPart').map((call) => call.input); + } + + toolCalls(): Array> { + return this.ofKind('appendToolCall').map((call) => call.input); + } + + toolResults(): Array> { + return this.ofKind('appendToolResult').map((call) => call.input); + } +} + +export type SinkErrorMode = + | { kind: 'none' } + | { kind: 'sync-throw'; onlyAt?: number } + | { kind: 'async-reject'; onlyAt?: number } + | { kind: 'every-call-throws' }; + +export class CollectingSink { + readonly events: LoopEvent[] = []; + private callCount = 0; + + constructor(private mode: SinkErrorMode = { kind: 'none' }) { } + + readonly emit: LoopLiveEventEmitter = (event) => { + const callIndex = this.callCount; + this.callCount += 1; + + if (this.mode.kind === 'every-call-throws') { + this.events.push(event); + throw new Error('sink fails on every emit'); + } + + if ( + this.mode.kind === 'sync-throw' && + (this.mode.onlyAt === undefined || this.mode.onlyAt === callIndex) + ) { + throw new Error(`sink sync throw at call ${String(callIndex)}`); + } + + if ( + this.mode.kind === 'async-reject' && + (this.mode.onlyAt === undefined || this.mode.onlyAt === callIndex) + ) { + const rejected = Promise.reject(new Error(`sink async reject at call ${String(callIndex)}`)); + this.events.push(event); + return rejected as unknown as void; + } + + this.events.push(event); + }; + + typesIn(): LoopEvent['type'][] { + return this.events.map((event) => event.type); + } + + count(type: LoopEvent['type']): number { + return this.events.filter((event) => event.type === type).length; + } + + byType(type: T): Array> { + return this.events.filter((event): event is Extract => event.type === type); + } +} + +export interface RunTurnOptions { + readonly responses: readonly FakeLLMResponse[]; + readonly tools?: readonly ExecutableTool[] | undefined; + readonly hooks?: LoopHooks | undefined; + readonly log?: Logger | undefined; + readonly maxSteps?: number | undefined; + readonly turnId?: string | undefined; + readonly signal?: AbortSignal | undefined; + readonly emitLiveEvent?: LoopLiveEventEmitter | undefined; + readonly llmThrowOnIndex?: { index: number; error: unknown } | undefined; + readonly llmAbortOnIndex?: { index: number; controller: AbortController } | undefined; + readonly llmDelayMs?: number | undefined; + readonly systemPrompt?: string | undefined; + readonly sinkErrorMode?: SinkErrorMode | undefined; + readonly recordStepUsage?: RunTurnInput['recordStepUsage'] | undefined; + readonly toolExecutor?: IToolExecutor | undefined; +} + +export interface RunTurnResult { + readonly result: TurnResult; + readonly llm: FakeLLM; + readonly context: RecordingContext; + readonly sink: CollectingSink; + readonly toolExecutor: IToolExecutor; +} + +export async function runTurn(opts: RunTurnOptions): Promise { + const llm = new FakeLLM({ + responses: opts.responses, + throwOnIndex: opts.llmThrowOnIndex, + abortOnIndex: opts.llmAbortOnIndex, + delayMs: opts.llmDelayMs, + systemPrompt: opts.systemPrompt, + }); + const context = new RecordingContext(); + const fallback = new CollectingSink(opts.sinkErrorMode); + const toolExecutor = opts.toolExecutor ?? new InlineToolExecutor(opts.tools ?? []); + const input: RunTurnInput = { + turnId: opts.turnId ?? 'turn-1', + signal: opts.signal ?? new AbortController().signal, + llm, + buildMessages: context.buildMessages, + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: opts.emitLiveEvent ?? fallback.emit, + }), + tools: opts.tools, + hooks: opts.hooks, + log: opts.log, + maxSteps: opts.maxSteps, + recordStepUsage: opts.recordStepUsage, + toolExecutor, + }; + const result = await runTurnImpl(input); + return { result, llm, context, sink: fallback, toolExecutor }; +} + +export async function runTurnExpectingThrow(opts: RunTurnOptions): Promise<{ + readonly error: unknown; + readonly llm: FakeLLM; + readonly context: RecordingContext; + readonly sink: CollectingSink; +}> { + const llm = new FakeLLM({ + responses: opts.responses, + throwOnIndex: opts.llmThrowOnIndex, + abortOnIndex: opts.llmAbortOnIndex, + delayMs: opts.llmDelayMs, + systemPrompt: opts.systemPrompt, + }); + const context = new RecordingContext(); + const fallback = new CollectingSink(opts.sinkErrorMode); + const toolExecutor = opts.toolExecutor ?? new InlineToolExecutor(opts.tools ?? []); + const input: RunTurnInput = { + turnId: opts.turnId ?? 'turn-1', + signal: opts.signal ?? new AbortController().signal, + llm, + buildMessages: context.buildMessages, + dispatchEvent: createLoopEventDispatcher({ + appendTranscriptRecord: context.appendTranscriptRecord, + emitLiveEvent: opts.emitLiveEvent ?? fallback.emit, + }), + tools: opts.tools, + hooks: opts.hooks, + log: opts.log, + maxSteps: opts.maxSteps, + recordStepUsage: opts.recordStepUsage, + toolExecutor, + }; + try { + await runTurnImpl(input); + } catch (error) { + return { + error, + llm, + context, + sink: fallback, + }; + } + throw new Error('runTurnExpectingThrow: expected throw, got resolution'); +} + +export function makeTextParts(text: string): FakeOutputPart[] { + return text.length > 0 ? [{ type: 'text', text }] : []; +} + +export function makeThinkingParts(thinking: string, text = '', signature?: string): FakeOutputPart[] { + const parts: FakeOutputPart[] = + signature !== undefined + ? [{ type: 'think', think: thinking, encrypted: signature }] + : [{ type: 'think', think: thinking }]; + if (text.length > 0) parts.push({ type: 'text', text }); + return parts; +} + +export function makeEndTurnResponse(text: string, usage: Partial = {}): FakeLLMResponse { + return { + toolCalls: [], + providerFinishReason: 'completed', + usage: zeroUsage(usage), + contentParts: makeTextParts(text), + }; +} + +export function makeMaxTokensResponse(text: string, usage: Partial = {}): FakeLLMResponse { + return { + toolCalls: [], + providerFinishReason: 'truncated', + usage: zeroUsage(usage), + contentParts: makeTextParts(text), + }; +} + +export function makeToolUseResponse(toolCalls: ToolCall[], usage: Partial = {}): FakeLLMResponse { + return { + toolCalls, + providerFinishReason: 'tool_calls', + usage: zeroUsage(usage), + }; +} + +export function makeResponse( + contentParts: readonly FakeOutputPart[], + toolCalls: ToolCall[], + stopReason: LoopStepStopReason, + usage: Partial = {}, +): FakeLLMResponse { + return { + contentParts, + toolCalls, + providerFinishReason: providerFinishReasonForStopReason(stopReason), + usage: zeroUsage(usage), + }; +} + +export function zeroUsage(partial: Partial = {}): TokenUsage { + return { ...emptyUsage(), ...partial }; +} + +export function makeToolCall(name: string, args: unknown, id = `call_${name}`): ToolCall { + return { + type: 'function', + id, + name, + arguments: JSON.stringify(args), + }; +} + +export class EchoTool implements ExecutableTool<{ text: string }> { + readonly name: string; + readonly description = 'Return the input text unchanged.'; + readonly parameters = { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + additionalProperties: false, + }; + readonly calls: Array<{ readonly id: string; readonly args: { text: string }; readonly turnId: string }> = []; + + constructor(name = 'echo') { + this.name = name; + } + + resolveExecution(args: { text: string }): ToolExecution { + return { + approvalRule: this.name, + execute: async (ctx): Promise => { + this.calls.push({ id: ctx.toolCallId, args, turnId: ctx.turnId }); + return { output: args.text }; + }, + }; + } +} + +export class ControlledTool implements ExecutableTool> { + readonly description = 'Controlled test tool.'; + readonly parameters = { type: 'object', additionalProperties: true }; + readonly calls: Array<{ readonly id: string; readonly args: Record; readonly signal: AbortSignal }> = []; + readonly started: Promise; + private resolveStarted: () => void = () => { }; + private resolveResult: (value: ExecutableToolResult) => void = () => { }; + private rejectResult: (error: unknown) => void = () => { }; + private readonly result: Promise; + + constructor( + readonly name: string, + private readonly accesses: ToolAccesses = ToolAccesses.all(), + ) { + this.started = new Promise((resolve) => { + this.resolveStarted = resolve; + }); + this.result = new Promise((resolve, reject) => { + this.resolveResult = resolve; + this.rejectResult = reject; + }); + } + + resolveExecution(args: Record): ToolExecution { + return { + approvalRule: this.name, + accesses: this.accesses, + execute: async (ctx): Promise => { + this.calls.push({ id: ctx.toolCallId, args, signal: ctx.signal }); + this.resolveStarted(); + if (ctx.signal.aborted) { + const error = new Error('aborted before start'); + error.name = 'AbortError'; + throw error; + } + const onAbort = (): void => { + const error = new Error('tool aborted'); + error.name = 'AbortError'; + this.rejectResult(error); + }; + ctx.signal.addEventListener('abort', onAbort, { once: true }); + try { + return await this.result; + } finally { + ctx.signal.removeEventListener('abort', onAbort); + } + }, + }; + } + + resolve(output = `${this.name} result`): void { + this.resolveResult({ output }); + } + + reject(error: unknown): void { + this.rejectResult(error); + } +} + +class InlineToolExecutor implements IToolExecutor { + declare readonly _serviceBrand: undefined; + readonly hooks = { + onWillExecuteTool: new OrderedHookSlot(), + onDidExecuteTool: new OrderedHookSlot(), + }; + + private readonly tools: Map; + + constructor(tools: readonly ExecutableTool[]) { + this.tools = new Map(tools.map((tool) => [tool.name, tool])); + } + + async execute(calls: ToolCall[], options: Parameters[1] = {}): Promise { + const results: ToolResult[] = []; + for (const call of calls) { + const parsedArgs = typeof call.arguments === 'string' ? JSON.parse(call.arguments) as unknown : call.arguments; + const tool = this.tools.get(call.name); + await options.dispatchEvent?.({ + type: 'tool.call', + uuid: call.id, + turnId: options.turnId ?? '', + step: options.stepNumber ?? 0, + stepUuid: options.stepUuid ?? '', + toolCallId: call.id, + name: call.name, + args: parsedArgs, + }); + if (tool === undefined) { + const result = { output: `Tool "${call.name}" not found`, isError: true }; + await options.dispatchEvent?.({ + type: 'tool.result', + parentUuid: call.id, + toolCallId: call.id, + result, + }); + results.push(result); + continue; + } + const execution = await tool.resolveExecution(parsedArgs); + const rawResult = + execution.isError === true + ? execution + : await execution.execute({ + turnId: options.turnId ?? '', + toolCallId: call.id, + signal: options.signal ?? new AbortController().signal, + onUpdate: (update: ToolUpdate) => options.onProgress?.(call.id, update), + }); + const result: ToolResult = { + output: rawResult.output, + isError: rawResult.isError, + stopTurn: rawResult.stopTurn, + }; + await options.dispatchEvent?.({ + type: 'tool.result', + parentUuid: call.id, + toolCallId: call.id, + result, + }); + results.push(result); + } + return results; + } +} + +function providerFinishReasonForStopReason(reason: LoopStepStopReason): FakeLLMResponse['providerFinishReason'] { + switch (reason) { + case 'end_turn': + return 'completed'; + case 'tool_use': + return 'tool_calls'; + case 'max_tokens': + return 'truncated'; + case 'filtered': + return 'filtered'; + case 'paused': + return 'paused'; + case 'unknown': + return 'other'; + } +} diff --git a/packages/agent-core-v2/test/loop/retry.test.ts b/packages/agent-core-v2/test/loop/retry.test.ts new file mode 100644 index 000000000..c8683f8a0 --- /dev/null +++ b/packages/agent-core-v2/test/loop/retry.test.ts @@ -0,0 +1,70 @@ +import { APIConnectionError, emptyUsage } from '@moonshot-ai/kosong'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { LLM, LLMChatParams, LLMChatResponse } from '#/loop'; +import { chatWithRetry } from '#/loop/retry'; + +function okResponse(): LLMChatResponse { + return { toolCalls: [], usage: emptyUsage() }; +} + +function makeInput(llm: LLM, signal: AbortSignal): Parameters[0] { + return { + llm, + params: { messages: [], tools: [], signal }, + dispatchEvent: async () => { }, + turnId: 'turn-1', + currentStep: 1, + stepUuid: 'step-1', + }; +} + +describe('chatWithRetry: terminated stream drops', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries an APIConnectionError("terminated") and succeeds on a later attempt', async () => { + vi.useFakeTimers(); + + let calls = 0; + const llm: LLM = { + systemPrompt: '', + modelName: 'mock', + isRetryableError: (error) => + error instanceof APIConnectionError && error.message === 'terminated', + async chat(_params: LLMChatParams): Promise { + calls += 1; + if (calls === 1) throw new APIConnectionError('terminated'); + return okResponse(); + }, + }; + + const responsePromise = chatWithRetry(makeInput(llm, new AbortController().signal)); + await vi.runAllTimersAsync(); + + await expect(responsePromise).resolves.toEqual(okResponse()); + expect(calls).toBe(2); + }); + + it('does NOT retry when the signal is aborted (user ESC), surfacing a clean AbortError', async () => { + let calls = 0; + const controller = new AbortController(); + controller.abort(); + const llm: LLM = { + systemPrompt: '', + modelName: 'mock', + isRetryableError: (error) => + error instanceof APIConnectionError && error.message === 'terminated', + async chat(_params: LLMChatParams): Promise { + calls += 1; + throw new APIConnectionError('terminated'); + }, + }; + + await expect(chatWithRetry(makeInput(llm, controller.signal))).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(calls).toBe(1); + }); +}); diff --git a/packages/agent-core-v2/test/loop/run-turn.test.ts b/packages/agent-core-v2/test/loop/run-turn.test.ts new file mode 100644 index 000000000..cb00e80a0 --- /dev/null +++ b/packages/agent-core-v2/test/loop/run-turn.test.ts @@ -0,0 +1,484 @@ +import { inputTotal } from '@moonshot-ai/kosong'; +import { describe, expect, it, vi } from 'vitest'; + +import { ErrorCodes, KimiError } from '#/errors'; + +import { + CollectingSink, + EchoTool, + makeEndTurnResponse, + makeMaxTokensResponse, + makeResponse, + makeTextParts, + makeThinkingParts, + makeToolCall, + makeToolUseResponse, + runTurn, + runTurnExpectingThrow, +} from './fixtures'; + +describe('runTurn turn lifecycle', () => { + it('returns max_tokens when the LLM signals it', async () => { + const { result, sink } = await runTurn({ + responses: [makeMaxTokensResponse('partial...', { inputOther: 10, output: 20 })], + }); + + expect(result.stopReason).toBe('max_tokens'); + expect(result.steps).toBe(1); + expect(result.usage).toEqual({ + inputOther: 10, + output: 20, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + expect(sink.count('turn.interrupted')).toBe(0); + }); + + it('treats provider tool_calls without tool call structure as unknown', async () => { + const { result } = await runTurn({ + responses: [makeResponse(makeTextParts('done'), [], 'tool_use')], + }); + + expect(result.stopReason).toBe('unknown'); + }); + + it('derives tool_use from tool call structure when provider reports completed', async () => { + const echo = new EchoTool(); + const { result, llm } = await runTurn({ + tools: [echo], + responses: [ + makeResponse([], [makeToolCall('echo', { text: 'hi' }, 'tc-completed')], 'end_turn'), + makeEndTurnResponse('done'), + ], + }); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.callCount).toBe(2); + expect(echo.calls.map((call) => call.id)).toEqual(['tc-completed']); + }); + + it('does not execute tool calls when provider reports a terminal diagnostic', async () => { + const echo = new EchoTool(); + const { result, sink } = await runTurn({ + tools: [echo], + responses: [ + makeResponse( + makeTextParts('blocked'), + [makeToolCall('echo', { text: 'should-not-run' }, 'tc-filtered')], + 'filtered', + ), + ], + }); + + expect(result.stopReason).toBe('filtered'); + expect(echo.calls).toEqual([]); + expect(sink.count('tool.call')).toBe(0); + expect(sink.count('tool.result')).toBe(0); + }); + + it('does not enforce a max step limit when maxSteps is 0', async () => { + const echo = new EchoTool(); + const { result } = await runTurn({ + maxSteps: 0, + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: '1' }, 'a')]), + makeToolUseResponse([makeToolCall('echo', { text: '2' }, 'b')]), + makeEndTurnResponse('done'), + ], + }); + + expect(result.stopReason).toBe('end_turn'); + expect(result.steps).toBe(3); + expect(echo.calls).toEqual([ + { id: 'a', turnId: 'turn-1', args: { text: '1' } }, + { id: 'b', turnId: 'turn-1', args: { text: '2' } }, + ]); + }); + + it('does not enforce a max step limit when maxSteps is omitted', async () => { + const echo = new EchoTool(); + const { result } = await runTurn({ + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: '1' }, 'a')]), + makeToolUseResponse([makeToolCall('echo', { text: '2' }, 'b')]), + makeToolUseResponse([makeToolCall('echo', { text: '3' }, 'c')]), + makeToolUseResponse([makeToolCall('echo', { text: '4' }, 'd')]), + makeEndTurnResponse('done'), + ], + }); + + expect(result.stopReason).toBe('end_turn'); + expect(result.steps).toBe(5); + expect(echo.calls).toEqual([ + { id: 'a', turnId: 'turn-1', args: { text: '1' } }, + { id: 'b', turnId: 'turn-1', args: { text: '2' } }, + { id: 'c', turnId: 'turn-1', args: { text: '3' } }, + { id: 'd', turnId: 'turn-1', args: { text: '4' } }, + ]); + }); + + it('aggregates usage across steps including cache fields', async () => { + const echo = new EchoTool(); + const { result } = await runTurn({ + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: 'a' })], { + inputOther: 70, + output: 50, + inputCacheRead: 10, + inputCacheCreation: 20, + }), + makeEndTurnResponse('done', { + inputOther: 4, + output: 3, + inputCacheRead: 1, + inputCacheCreation: 2, + }), + ], + }); + + expect(inputTotal(result.usage)).toBe(107); + expect(result.usage.output).toBe(53); + expect(result.usage.inputCacheRead).toBe(11); + expect(result.usage.inputCacheCreation).toBe(22); + }); +}); + +describe('runTurn abort and error paths', () => { + it('returns aborted without throwing when signal is already aborted on entry', async () => { + const controller = new AbortController(); + controller.abort(); + + const { result, llm, sink } = await runTurn({ + signal: controller.signal, + responses: [makeEndTurnResponse('should not run')], + }); + + expect(result).toMatchObject({ stopReason: 'aborted', steps: 0 }); + expect(llm.callCount).toBe(0); + expect(sink.byType('turn.interrupted')).toEqual([ + expect.objectContaining({ reason: 'aborted', attemptedSteps: 0 }), + ]); + }); + + it('preserves usage already recorded by an earlier step when later steps abort', async () => { + const controller = new AbortController(); + const echo = new EchoTool(); + + const { result } = await runTurn({ + signal: controller.signal, + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: 'first' }, 'tc-1')], { + inputOther: 3, + output: 5, + }), + makeEndTurnResponse('should abort'), + ], + llmAbortOnIndex: { index: 1, controller }, + }); + + expect(result.stopReason).toBe('aborted'); + expect(result.steps).toBe(2); + expect(result.usage).toEqual({ + inputOther: 3, + output: 5, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + }); + + it('throws KimiError(loop.max_steps_exceeded) with turn.interrupted before the throw', async () => { + const echo = new EchoTool(); + const { error, sink } = await runTurnExpectingThrow({ + maxSteps: 2, + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: '1' }, 'a')]), + makeToolUseResponse([makeToolCall('echo', { text: '2' }, 'b')]), + ], + }); + + expect(error).toBeInstanceOf(KimiError); + expect((error as KimiError).code).toBe(ErrorCodes.LOOP_MAX_STEPS_EXCEEDED); + expect((error as KimiError).details).toEqual({ maxSteps: 2 }); + expect(sink.byType('turn.interrupted')).toEqual([ + expect.objectContaining({ reason: 'max_steps', attemptedSteps: 2 }), + ]); + }); + + it('rethrows non-abort LLM errors with turn.interrupted{reason:"error"}', async () => { + const error = new Error('llm failed'); + const result = await runTurnExpectingThrow({ + responses: [makeEndTurnResponse('unused')], + llmThrowOnIndex: { index: 0, error }, + }); + + expect(result.error).toBe(error); + expect(result.sink.byType('turn.interrupted')).toEqual([ + expect.objectContaining({ + reason: 'error', + attemptedSteps: 1, + activeStep: 1, + message: 'llm failed', + }), + ]); + expect(result.context.stepEnds()).toEqual([]); + }); + + it('AbortError thrown by a hook converges to stopReason="aborted"', async () => { + const abortError = new Error('aborted from hook'); + abortError.name = 'AbortError'; + + const { result, llm, sink } = await runTurn({ + responses: [makeEndTurnResponse('unused')], + hooks: { + beforeStep: async () => { + throw abortError; + }, + }, + }); + + expect(result.stopReason).toBe('aborted'); + expect(llm.callCount).toBe(0); + expect(sink.byType('turn.interrupted')).toEqual([ + expect.objectContaining({ reason: 'aborted', attemptedSteps: 1, activeStep: 1 }), + ]); + }); + + it('logs non-abort LLM request failures without request payloads or stacks', async () => { + const entries: unknown[] = []; + const log = { + warn: (_message: string, payload?: unknown) => entries.push(payload), + }; + + await runTurnExpectingThrow({ + responses: [makeEndTurnResponse('unused')], + llmThrowOnIndex: { index: 0, error: new Error('temporary provider failure') }, + log: log as never, + }); + + expect(entries).toEqual([ + expect.objectContaining({ + turnStep: 'turn-1.1', + attempt: '1/3', + model: 'fake-model', + errorName: 'Error', + errorMessage: 'temporary provider failure', + }), + ]); + expect(JSON.stringify(entries)).not.toContain('messages'); + expect(JSON.stringify(entries)).not.toContain('stack'); + }); +}); + +describe('runTurn hooks', () => { + it('beforeStep passes through when the hook returns undefined', async () => { + const beforeStep = vi.fn(async () => undefined); + const { result, llm } = await runTurn({ + responses: [makeEndTurnResponse('ok')], + hooks: { beforeStep }, + }); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.callCount).toBe(1); + expect(beforeStep).toHaveBeenCalledWith( + expect.objectContaining({ turnId: 'turn-1', stepNumber: 1, llm: expect.any(Object) }), + ); + }); + + it('beforeStep block prevents the LLM call and rethrows through the loop error path', async () => { + const { error, llm, sink } = await runTurnExpectingThrow({ + responses: [makeEndTurnResponse('unused')], + hooks: { + beforeStep: async () => ({ block: true, reason: 'policy says no' }), + }, + }); + + expect(error).toMatchObject({ message: 'policy says no' }); + expect(llm.callCount).toBe(0); + expect(sink.byType('turn.interrupted')).toEqual([ + expect.objectContaining({ reason: 'error', activeStep: 1, message: 'policy says no' }), + ]); + }); + + it('afterStep runs after step.end and observes the step result', async () => { + const seen: unknown[] = []; + const { context } = await runTurn({ + responses: [makeEndTurnResponse('ok', { inputOther: 2, output: 3 })], + hooks: { + afterStep: async (ctx) => { + seen.push({ + stopReason: ctx.stopReason, + usage: ctx.usage, + }); + }, + }, + }); + + expect(seen).toEqual([ + { + stopReason: 'end_turn', + usage: { + inputOther: 2, + output: 3, + inputCacheRead: 0, + inputCacheCreation: 0, + }, + }, + ]); + expect(context.kinds()).toEqual(['appendStepBegin', 'appendContentPart', 'appendStepEnd']); + }); + + it('errors thrown by afterStep are swallowed after the step is sealed', async () => { + const afterStep = vi.fn(async () => { + throw new Error('observer failed'); + }); + + const { result, sink } = await runTurn({ + responses: [makeEndTurnResponse('ok')], + hooks: { afterStep }, + }); + + expect(result.stopReason).toBe('end_turn'); + expect(afterStep).toHaveBeenCalledTimes(1); + expect(sink.count('turn.interrupted')).toBe(0); + }); + + it('shouldContinueAfterStop can request another step after a non-tool stop', async () => { + const shouldContinueAfterStop = vi + .fn() + .mockResolvedValueOnce({ continue: true }) + .mockResolvedValueOnce({ continue: false }); + + const { result, llm } = await runTurn({ + responses: [makeEndTurnResponse('first'), makeEndTurnResponse('second')], + hooks: { shouldContinueAfterStop }, + }); + + expect(result.stopReason).toBe('end_turn'); + expect(llm.callCount).toBe(2); + expect(shouldContinueAfterStop).toHaveBeenCalledTimes(2); + }); + + it('shouldContinueAfterStop is not consulted between tool_use steps', async () => { + const echo = new EchoTool(); + const shouldContinueAfterStop = vi.fn(async () => ({ continue: false })); + + await runTurn({ + tools: [echo], + responses: [ + makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]), + makeEndTurnResponse('done'), + ], + hooks: { shouldContinueAfterStop }, + }); + + expect(shouldContinueAfterStop).toHaveBeenCalledTimes(1); + expect(shouldContinueAfterStop).toHaveBeenCalledWith( + expect.objectContaining({ stopReason: 'end_turn', stepNumber: 2 }), + ); + }); +}); + +describe('runTurn streaming callbacks', () => { + it('routes streaming deltas into live events', async () => { + const { sink } = await runTurn({ + responses: [ + { + ...makeEndTurnResponse('done'), + textDeltas: ['hel', 'lo'], + thinkDeltas: ['thinking'], + toolCallDeltas: [ + { toolCallId: 'call_1', name: 'Lookup', argumentsPart: '{"q":' }, + { toolCallId: 'call_1', argumentsPart: '"moon"}' }, + ], + }, + ], + }); + + expect(sink.byType('text.delta').map((event) => event.delta)).toEqual(['hel', 'lo']); + expect(sink.byType('thinking.delta').map((event) => event.delta)).toEqual(['thinking']); + expect(sink.byType('tool.call.delta')).toEqual([ + expect.objectContaining({ + toolCallId: 'call_1', + name: 'Lookup', + argumentsPart: '{"q":', + }), + expect.objectContaining({ + toolCallId: 'call_1', + argumentsPart: '"moon"}', + }), + ]); + }); + + it('persists completed text and thinking parts before step.end', async () => { + const { context } = await runTurn({ + responses: [ + { + ...makeEndTurnResponse('done'), + contentParts: [ + ...makeThinkingParts('private thought', '', 'encrypted-signature'), + ...makeTextParts('visible text'), + ], + }, + ], + }); + + expect(context.kinds()).toEqual([ + 'appendStepBegin', + 'appendContentPart', + 'appendContentPart', + 'appendStepEnd', + ]); + expect(context.contentParts().map((event) => event.part)).toEqual([ + { type: 'think', think: 'private thought', encrypted: 'encrypted-signature' }, + { type: 'text', text: 'visible text' }, + ]); + }); +}); + +describe('LoopEventDispatcher live event containment', () => { + it('contains synchronous emit throws', async () => { + const { result, sink } = await runTurn({ + responses: [makeEndTurnResponse('ok')], + sinkErrorMode: { kind: 'sync-throw', onlyAt: 0 }, + }); + + expect(result.stopReason).toBe('end_turn'); + expect(sink.count('step.end')).toBe(1); + }); + + it('contains async-rejected emit returns', async () => { + const { result, sink } = await runTurn({ + responses: [makeEndTurnResponse('ok')], + sinkErrorMode: { kind: 'async-reject', onlyAt: 0 }, + }); + + expect(result.stopReason).toBe('end_turn'); + expect(sink.events.length).toBeGreaterThan(0); + }); + + it('a misbehaving sink does not starve host-owned fan-out', async () => { + const bad = new CollectingSink({ kind: 'every-call-throws' }); + const good = new CollectingSink(); + + const { result } = await runTurn({ + responses: [makeEndTurnResponse('ok')], + emitLiveEvent: (event) => { + try { + bad.emit(event); + } catch { + // Host-owned fan-out isolates each sink before forwarding to the next. + } + good.emit(event); + }, + }); + + expect(result.stopReason).toBe('end_turn'); + expect(bad.events.length).toBeGreaterThan(0); + expect(good.events.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/agent-core-v2/test/model/model.test.ts b/packages/agent-core-v2/test/model/model.test.ts index 31dc9a179..9897eea03 100644 --- a/packages/agent-core-v2/test/model/model.test.ts +++ b/packages/agent-core-v2/test/model/model.test.ts @@ -140,6 +140,19 @@ describe('kimiModelEnvOverlay', () => { expect(result.effective).toEqual(effective); }); + it('applies request overrides when KIMI_MODEL_NAME is absent', () => { + const { changed, effective } = applyKimiModelEnvOverlay({ + KIMI_MODEL_TEMPERATURE: '0.3', + KIMI_MODEL_THINKING_KEEP: 'all', + }); + + expect(changed).toEqual(['modelOverrides']); + expect(effective['modelOverrides']).toEqual({ + temperature: 0.3, + thinkingKeep: 'all', + }); + }); + it('synthesizes an env model alias and default model from the minimal env set', () => { const { changed, effective } = applyKimiModelEnvOverlay({ KIMI_MODEL_NAME: 'kimi-for-coding', diff --git a/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts b/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts index 6ea262161..e34c5704c 100644 --- a/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts +++ b/packages/agent-core-v2/test/plan/plan-tools-telemetry.test.ts @@ -77,6 +77,110 @@ function planService({ } describe('EnterPlanModeTool telemetry', () => { + it('has name, description, parameters, and a stable execution description', async () => { + const { telemetry } = recordingTelemetry(); + const tool = new EnterPlanModeTool(planService({ status: null }), telemetry); + + expect(tool.name).toBe('EnterPlanMode'); + expect(tool.description).toContain('EnterPlanMode'); + expect(tool.description).toContain('non-trivial implementation task'); + expect(tool.parameters).toMatchObject({ + type: 'object', + properties: {}, + additionalProperties: false, + }); + + const execution = tool.resolveExecution({}); + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Requesting to enter plan mode'); + }); + + it('returns an error when plan mode is already active', async () => { + const { telemetry } = recordingTelemetry(); + + const result = await executeTool(new EnterPlanModeTool(planService(), telemetry), { + turnId: '0', + toolCallId: 'call_enter_plan', + args: {}, + signal: new AbortController().signal, + }); + + expect(result).toMatchObject({ + isError: true, + output: 'Plan mode is already active. Use ExitPlanMode when the plan is ready.', + }); + }); + + it('uses inline guidance when no plan file path is available', async () => { + const planMode = planService({ + status: null, + enter: vi.fn(async () => {}), + }); + vi.mocked(planMode.status).mockResolvedValue(null); + const { telemetry } = recordingTelemetry(); + + const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { + turnId: '0', + toolCallId: 'call_enter_plan', + args: {}, + signal: new AbortController().signal, + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain('Wait for the host to provide a plan file path'); + expect(result.output).toContain('no plan file path is available'); + }); + + it('uses plan-file guidance when the host provides a plan file path', async () => { + let active = false; + const planMode = planService({ + status: null, + enter: vi.fn(async () => { + active = true; + }), + }); + vi.mocked(planMode.status).mockImplementation(async () => (active ? ACTIVE_PLAN : null)); + const { telemetry } = recordingTelemetry(); + + const result = await executeTool(new EnterPlanModeTool(planMode, telemetry), { + turnId: '0', + toolCallId: 'call_enter_plan', + args: {}, + signal: new AbortController().signal, + }); + + expect(result.isError).toBeFalsy(); + expect(result.output).toContain(`Plan file: ${ACTIVE_PLAN.path}`); + expect(result.output).toContain('Write the plan to the plan file with Write or Edit'); + }); + + it('returns an error when entering plan mode fails', async () => { + const { telemetry } = recordingTelemetry(); + + const result = await executeTool( + new EnterPlanModeTool( + planService({ + status: null, + enter: vi.fn(async () => { + throw new Error('cannot prepare plan directory'); + }), + }), + telemetry, + ), + { + turnId: '0', + toolCallId: 'call_enter_plan', + args: {}, + signal: new AbortController().signal, + }, + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Failed to enter plan mode: cannot prepare plan directory', + }); + }); + it('tracks direct entry as auto_approved', async () => { let active = false; const planMode = planService({ @@ -157,6 +261,87 @@ describe('PlanService EnterPlanMode telemetry', () => { }); describe('ExitPlanModeTool telemetry', () => { + it('has name, description, parameters, and a stable execution description', async () => { + const { telemetry } = recordingTelemetry(); + const tool = new ExitPlanModeTool(planService(), telemetry); + + expect(tool.name).toBe('ExitPlanMode'); + expect(tool.description).toContain('ExitPlanMode'); + expect(tool.description).toContain('ready for user approval'); + expect(tool.parameters).toMatchObject({ + type: 'object', + additionalProperties: false, + properties: { + options: expect.objectContaining({ type: 'array' }), + }, + }); + + const execution = await tool.resolveExecution({}); + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Presenting plan and exiting plan mode'); + }); + + it('refuses to exit when plan mode is inactive', async () => { + const { telemetry } = recordingTelemetry(); + + const result = await executeTool(new ExitPlanModeTool(planService({ status: null }), telemetry), { + turnId: '7', + toolCallId: 'call_exit_plan', + args: {}, + signal: new AbortController().signal, + }); + + expect(result).toMatchObject({ + isError: true, + output: + 'ExitPlanMode can only be called while plan mode is active. Use EnterPlanMode (or /plan) first.', + }); + }); + + it('does not use inline plan fallback when no plan file exists', async () => { + const { telemetry } = recordingTelemetry(); + const status = { + id: 'test-plan', + content: '', + path: undefined, + } as unknown as NonNullable; + + const result = await executeTool( + new ExitPlanModeTool(planService({ status }), telemetry), + { + turnId: '7', + toolCallId: 'call_exit_plan', + args: {}, + signal: new AbortController().signal, + }, + ); + + expect(result).toMatchObject({ + isError: true, + output: + 'No plan file found. Write the plan to the current plan file first, then call ExitPlanMode.', + }); + }); + + it('exposes options[].description as optional with a default of empty string', () => { + const { telemetry } = recordingTelemetry(); + const parameters = new ExitPlanModeTool(planService(), telemetry).parameters as { + properties: { + options: { + items: { + properties: Record; + required?: string[]; + }; + }; + }; + }; + const optionSchema = parameters.properties.options.items; + + expect(optionSchema.properties.description).toMatchObject({ default: '' }); + expect(optionSchema.required).toContain('label'); + expect(optionSchema.required).not.toContain('description'); + }); + it('tracks submitted without options and auto approval', async () => { const exit = vi.fn(); const { telemetry, track } = recordingTelemetry(); diff --git a/packages/agent-core-v2/test/profile/config-state.test.ts b/packages/agent-core-v2/test/profile/config-state.test.ts index 12493510f..d717c628f 100644 --- a/packages/agent-core-v2/test/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/profile/config-state.test.ts @@ -266,7 +266,7 @@ describe('ConfigState thinking clamp for always-thinking models', () => { }); describe('ConfigState.provider applies global KIMI_MODEL_* request config', () => { - let ctx: TestAgentContext; + let ctx: TestAgentContext | undefined; let profile: IProfileService; let kimiConfig: TestKimiConfig; @@ -277,21 +277,26 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = 'kimi-code': { provider: 'kimi', model: 'kimi-code', maxContextSize: 128_000 }, }, }; - ctx = createTestAgent(configServices(() => kimiConfig)); - profile = ctx.get(IProfileService); }); afterEach(async () => { try { - await ctx.expectResumeMatches(); + await ctx?.expectResumeMatches(); } finally { - await ctx.dispose(); + await ctx?.dispose(); + ctx = undefined; vi.unstubAllEnvs(); } }); + function createAgentWithEnv(): void { + ctx = createTestAgent(configServices(() => kimiConfig)); + profile = ctx.get(IProfileService); + } + it('injects KIMI_MODEL_TEMPERATURE into config.provider (the provider compaction also uses)', () => { vi.stubEnv('KIMI_MODEL_TEMPERATURE', '0.3'); + createAgentWithEnv(); profile.update({ modelAlias: 'kimi-code' }); @@ -303,6 +308,7 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = it('injects KIMI_MODEL_THINKING_KEEP into config.provider when thinking is on (so compaction keeps it)', () => { vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all'); + createAgentWithEnv(); profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); @@ -315,6 +321,7 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = it('does NOT inject thinking.keep into config.provider when thinking is off', () => { vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all'); + createAgentWithEnv(); profile.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' }); diff --git a/packages/agent-core-v2/test/shellTools/bash.test.ts b/packages/agent-core-v2/test/shellTools/bash.test.ts index 3035da0e0..e0d18d70c 100644 --- a/packages/agent-core-v2/test/shellTools/bash.test.ts +++ b/packages/agent-core-v2/test/shellTools/bash.test.ts @@ -1169,7 +1169,7 @@ describe('BashTool background mode', () => { const { proc, finish } = pendingProcess(); const { runner } = createTestRunner(proc); const { service } = createFakeBackgroundService(); - const tool = bashTool(runner, createTestKaos(), service, { allowBackground: false }); + const tool = bashTool(runner, createTestKaos(), service, { allowBackground: () => false }); const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 })); await vi.waitFor(() => { @@ -1240,7 +1240,7 @@ describe('BashTool background mode', () => { runner, createTestKaos(), createFakeBackgroundService().service, - { allowBackground: false }, + { allowBackground: () => false }, ); const unavailable = await executeTool( @@ -1516,7 +1516,7 @@ describe('BashTool prompt / runtime consistency', () => { ); const tool = bashTool(runner, createTestKaos(), createFakeBackgroundService().service, { - allowBackground: false, + allowBackground: () => false, }); const result = await executeTool( tool, diff --git a/packages/agent-core-v2/test/shellTools/result-builder.test.ts b/packages/agent-core-v2/test/shellTools/result-builder.test.ts new file mode 100644 index 000000000..e59a02a79 --- /dev/null +++ b/packages/agent-core-v2/test/shellTools/result-builder.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; + +import { ToolResultBuilder } from '#/shellTools/tools/result-builder'; + +describe('ToolResultBuilder', () => { + it('returns concatenated output and a confirmation message under the limit', () => { + const builder = new ToolResultBuilder({ maxChars: 50 }); + + expect(builder.write('Hello')).toBe(5); + expect(builder.write(' world')).toBe(6); + + const result = builder.ok('Operation completed'); + expect(result.output).toBe('Hello world'); + expect(result.message).toBe('Operation completed.'); + expect(builder.nChars).toBe(11); + }); + + it('truncates with marker at the cut point and appends the message after', () => { + const builder = new ToolResultBuilder({ maxChars: 10 }); + + expect(builder.write('Hello')).toBe(5); + expect(builder.write(' world!')).toBe(14); + expect(builder.nChars).toBeGreaterThanOrEqual(10); + + const result = builder.ok('Operation completed'); + expect(result.output).toContain('Hello[...truncated]'); + expect(result.output).toContain('Output is truncated'); + expect(result.output.endsWith('Output is truncated to fit in the message.')).toBe(true); + expect(result.message).toContain('Operation completed.'); + expect(result.message).toContain('Output is truncated'); + expect(result.truncated).toBe(true); + }); + + it('truncates lines that exceed maxLineLength', () => { + const builder = new ToolResultBuilder({ maxChars: 100, maxLineLength: 20 }); + + expect(builder.write('This is a very long line that should be truncated\n')).toBe(20); + + const result = builder.ok(); + expect(result.output).toContain('[...truncated]'); + expect(result.message).toContain('Output is truncated'); + }); + + it('respects both per-line and per-buffer limits at once', () => { + const builder = new ToolResultBuilder({ maxChars: 40, maxLineLength: 20 }); + + expect(builder.write('Line 1\n')).toBe(7); + expect(builder.write('This is a very long line that exceeds limit\n')).toBe(20); + expect(builder.write('This would exceed char limit')).toBe(14); + expect(builder.write('ignored')).toBe(0); + + const result = builder.ok(); + expect(result.output).toContain('[...truncated]'); + expect(result.message).toContain('Output is truncated'); + }); + + it('tracks nChars as the buffer grows', () => { + const builder = new ToolResultBuilder({ maxChars: 20, maxLineLength: 30 }); + + expect(builder.nChars).toBe(0); + + builder.write('Short\n'); + expect(builder.nChars).toBe(6); + + builder.write('1\n2\n'); + expect(builder.nChars).toBe(10); + + builder.write('More text that exceeds'); + expect(builder.nChars).toBeGreaterThanOrEqual(20); + }); + + it('marks truncation when non-empty text arrives after the buffer is full', () => { + const builder = new ToolResultBuilder({ maxChars: 5 }); + + expect(builder.write('Hello')).toBe(5); + expect(builder.write(' world')).toBe(0); + + const result = builder.ok(); + expect(result.output).toContain('Hello[...truncated]'); + expect(result.output).toContain('Output is truncated'); + expect(result.truncated).toBe(true); + }); + + it('marks truncation when a multi-line write leaves unprocessed lines', () => { + const builder = new ToolResultBuilder({ maxChars: 6 }); + + expect(builder.write('Hello\nworld')).toBe(6); + + const result = builder.ok(); + expect(result.output).toContain('Hello\n[...truncated]'); + expect(result.message).toContain('Output is truncated'); + }); + + it('keeps unterminated trailing text in output', () => { + const builder = new ToolResultBuilder({ maxChars: 100 }); + + expect(builder.write('Line 1\nLine 2\nLine 3')).toBe(20); + + const result = builder.ok(); + expect(result.output).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('treats an empty write as a no-op', () => { + const builder = new ToolResultBuilder({ maxChars: 50 }); + + expect(builder.write('')).toBe(0); + expect(builder.nChars).toBe(0); + }); + + it('returns the accumulated output with the supplied error message', () => { + const builder = new ToolResultBuilder({ maxChars: 20 }); + + builder.write('Some output'); + const result = builder.error('Something went wrong'); + + expect(result.output).toContain('Some output'); + expect(result.output).toContain('Something went wrong'); + expect(result.message).toBe('Something went wrong'); + }); + + it('preserves the truncation hint on error', () => { + const builder = new ToolResultBuilder({ maxChars: 10 }); + + builder.write('Very long output that exceeds limit'); + const result = builder.error('Command failed'); + + expect(result.output).toContain('[...truncated]'); + expect(result.message).toContain('Command failed'); + expect(result.message).toContain('Output is truncated'); + }); + + it('returns executable output with critical messages included', () => { + const builder = new ToolResultBuilder({ maxChars: 10 }); + + builder.write('Very long output that exceeds limit'); + const result = builder.ok('Operation completed'); + + expect(result.output).toContain('[...truncated]'); + expect(result.output).toContain('Output is truncated'); + expect(result.message).toContain('Output is truncated'); + }); + + it('keeps normal success messages out of non-empty output', () => { + const builder = new ToolResultBuilder({ maxChars: 100 }); + + builder.write('ok\n'); + const result = builder.ok('Command executed successfully.'); + + expect(result.output).toBe('ok\n'); + expect(result.message).toBe('Command executed successfully.'); + }); +}); diff --git a/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts b/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts index 151c63f62..858580362 100644 --- a/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts +++ b/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts @@ -4,6 +4,7 @@ import type { IBackgroundService } from '#/background'; import type { IDisposable } from '#/_base/di'; import type { IKaos } from '#/kaos'; import type { IProcessRunner } from '#/process'; +import type { IProfileService } from '#/profile'; import { ShellToolsService } from '#/shellTools'; import type { IToolRegistry } from '#/toolRegistry'; @@ -27,11 +28,14 @@ const fakeKaos = { pathClass: () => 'posix', } as unknown as IKaos; const fakeBackground = {} as unknown as IBackgroundService; +const fakeProfile = { + isToolActive: () => true, +} as unknown as IProfileService; describe('ShellToolsService', () => { it('registers Bash into the tool registry', () => { const { registry, names } = fakeToolRegistry(); - new ShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground); + new ShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground, fakeProfile); expect(names()).toEqual(['Bash']); }); }); diff --git a/packages/agent-core-v2/test/skill/plugin-session-start.test.ts b/packages/agent-core-v2/test/skill/plugin-session-start.test.ts index 1827aeb25..fa083eb3e 100644 --- a/packages/agent-core-v2/test/skill/plugin-session-start.test.ts +++ b/packages/agent-core-v2/test/skill/plugin-session-start.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IContextInjector } from '#/contextInjector'; -import type { ContextMessage } from '#/contextMemory'; +import { IContextMemory, type ContextMessage } from '#/contextMemory'; import type { LogContext, LogPayload } from '#/log'; import type { EnabledPluginSessionStart } from '#/plugin/types'; -import { InMemorySkillCatalog } from '#/skill'; +import { InMemorySkillCatalog as SessionSkillRegistry } from '#/skill/registry'; import type { SkillDefinition } from '#/skill/types'; -import { testAgent } from '../harness'; +import { + createTestAgent, + logServices, + skillServices, + type TestAgentContext, +} from '../harness'; import { stubSkill } from './stubs'; type InjectableDynamicInjector = { @@ -54,156 +60,273 @@ function recordingLogger(warnings: CapturedWarn[]): RecordingLogger { }; } -function sessionStartRuntime(input: { - readonly sessionStarts: readonly EnabledPluginSessionStart[]; - readonly skills: readonly SkillDefinition[]; - readonly history?: readonly ContextMessage[]; -}): { - readonly ctx: ReturnType; - readonly warnings: readonly CapturedWarn[]; -} { - const warnings: CapturedWarn[] = []; - const skills = new InMemorySkillCatalog(); - for (const skill of input.skills) { - skills.register(skill); - } - const ctx = testAgent({ - skills, - pluginSessionStarts: input.sessionStarts, - log: recordingLogger(warnings), +function registerPluginSessionStartInjection( + injector: IContextInjector, + sessionStarts: readonly EnabledPluginSessionStart[], + skills: SessionSkillRegistry, + logger: RecordingLogger, +): void { + injector.register('plugin_session_start', ({ lastInjectedAt }) => { + if (lastInjectedAt !== null || sessionStarts.length === 0) return undefined; + const blocks: string[] = []; + for (const sessionStart of sessionStarts) { + const registeredSkill = skills.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); + if (registeredSkill === undefined) { + logger.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + continue; + } + blocks.push( + renderSessionStartBlock( + sessionStart, + registeredSkill, + skills.renderSkillPrompt(registeredSkill, ''), + ), + ); + } + return blocks.length === 0 ? undefined : blocks.join('\n'); }); - ctx.configure(); - if (input.history !== undefined) { - ctx.context.spliceHistory(0, 0, input.history); - } - return { ctx, warnings }; } -async function injectDynamic(ctx: ReturnType): Promise { - await (ctx.get(IContextInjector) as unknown as InjectableDynamicInjector).inject(); +function renderSessionStartBlock( + sessionStart: EnabledPluginSessionStart, + registeredSkill: SkillDefinition, + skillContent: string, +): string { + return ( + `\n${skillContent}\n` + ); } -function lastReminder(ctx: ReturnType): string { - const last = ctx.context.getHistory().findLast((message) => message.role === 'user'); +async function injectDynamic(injector: IContextInjector): Promise { + await (injector as unknown as InjectableDynamicInjector).inject(); +} + +function lastReminder(context: IContextMemory): string { + const last = context.get().findLast((message) => message.role === 'user'); return last?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; } describe('plugin session-start dynamic injection', () => { - it('injects one block per declared sessionStart on first call', async () => { - const { ctx } = sessionStartRuntime({ - sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], - skills: [ - skill('using-superpowers', 'body of skill', { - id: 'superpowers', - instructions: 'Use AskUserQuestion and TodoList.', - }), - ], + let context: IContextMemory; + let ctx: TestAgentContext | undefined; + let injector: IContextInjector; + let logger: RecordingLogger; + let skills: SessionSkillRegistry; + let warnings: CapturedWarn[]; + + afterEach(async () => { + if (ctx === undefined) return; + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + ctx = undefined; + } + }); + + describe('with plugin instructions', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + skills.register(skill('using-superpowers', 'body of skill', { + id: 'superpowers', + instructions: 'Use AskUserQuestion and TodoList.', + })); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + registerPluginSessionStartInjection( + injector, + [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + skills, + logger, + ); }); - await injectDynamic(ctx); + it('injects one block per declared sessionStart on first call', async () => { + await injectDynamic(injector); - const text = lastReminder(ctx); - expect(text).toContain(''); - expect(text).toContain(''); - expect(text).toContain('AskUserQuestion'); - expect(text).toContain('TodoList'); - expect(text).toContain('body of skill'); - expect(text).toContain(''); - expect(ctx.context.getHistory().at(-1)?.origin).toEqual({ - kind: 'injection', - variant: 'plugin_session_start', + const text = lastReminder(context); + expect(text).toContain(''); + expect(text).toContain(''); + expect(text).toContain('AskUserQuestion'); + expect(text).toContain('TodoList'); + expect(text).toContain('body of skill'); + expect(text).toContain(''); + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'injection', + variant: 'plugin_session_start', + }); }); }); - it('does not hard-code Superpowers guidance when the skill has no plugin instructions', async () => { - const { ctx } = sessionStartRuntime({ - sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], - skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], + describe('without plugin instructions', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + skills.register(skill('using-superpowers', 'body', { id: 'superpowers' })); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + registerPluginSessionStartInjection( + injector, + [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + skills, + logger, + ); }); - await injectDynamic(ctx); + it('does not hard-code Superpowers guidance when the skill has no plugin instructions', async () => { + await injectDynamic(injector); - const text = lastReminder(ctx); - expect(text).toContain(''); - expect(text).toContain('body'); - expect(text).not.toContain(''); - expect(text).not.toContain('AskUserQuestion'); + const text = lastReminder(context); + expect(text).toContain(''); + expect(text).toContain('body'); + expect(text).not.toContain(''); + expect(text).not.toContain('AskUserQuestion'); + }); }); - it('does not re-inject on subsequent calls within the same session', async () => { - const { ctx } = sessionStartRuntime({ - sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], - skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], + describe('single-session idempotency', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + skills.register(skill('using-superpowers', 'body', { id: 'superpowers' })); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + registerPluginSessionStartInjection( + injector, + [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + skills, + logger, + ); }); - await injectDynamic(ctx); - await injectDynamic(ctx); + it('does not re-inject on subsequent calls within the same session', async () => { + await injectDynamic(injector); + await injectDynamic(injector); - expect(ctx.context.getHistory()).toHaveLength(1); + expect(context.get()).toHaveLength(1); + }); }); - it('does not re-inject when a replayed history already contains plugin sessionStart', async () => { - const { ctx } = sessionStartRuntime({ - sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], - skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], - history: [ + describe('replayed session-start history', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + skills.register(skill('using-superpowers', 'body', { id: 'superpowers' })); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + context.splice(0, 0, [ { role: 'user', content: [{ type: 'text', text: 'old' }], toolCalls: [], origin: { kind: 'injection', variant: 'plugin_session_start' }, }, - ], + ]); + registerPluginSessionStartInjection( + injector, + [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + skills, + logger, + ); }); - await injectDynamic(ctx); + it('does not re-inject when a replayed history already contains plugin sessionStart', async () => { + await injectDynamic(injector); - expect(ctx.context.getHistory()).toHaveLength(1); + expect(context.get()).toHaveLength(1); + }); }); - it('skips a sessionStart whose skill is not registered and warns', async () => { - const { ctx, warnings } = sessionStartRuntime({ - sessionStarts: [ - { pluginId: 'demo', skillName: 'missing' }, - { pluginId: 'superpowers', skillName: 'using-superpowers' }, - ], - skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], + describe('missing session-start skill', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + skills.register(skill('using-superpowers', 'body', { id: 'superpowers' })); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + registerPluginSessionStartInjection( + injector, + [ + { pluginId: 'demo', skillName: 'missing' }, + { pluginId: 'superpowers', skillName: 'using-superpowers' }, + ], + skills, + logger, + ); }); - await injectDynamic(ctx); + it('skips a sessionStart whose skill is not registered and warns', async () => { + await injectDynamic(injector); - const text = lastReminder(ctx); - expect(text).not.toContain('plugin="demo"'); - expect(text).toContain('plugin="superpowers"'); - expect(warnings).toContainEqual( - expect.objectContaining({ - message: 'plugin sessionStart skill not found', - payload: expect.objectContaining({ pluginId: 'demo', skillName: 'missing' }), - }), - ); + const text = lastReminder(context); + expect(text).not.toContain('plugin="demo"'); + expect(text).toContain('plugin="superpowers"'); + expect(warnings).toContainEqual( + expect.objectContaining({ + message: 'plugin sessionStart skill not found', + payload: expect.objectContaining({ pluginId: 'demo', skillName: 'missing' }), + }), + ); + }); }); - it('emits nothing when no sessionStart declarations are present', async () => { - const { ctx } = sessionStartRuntime({ sessionStarts: [], skills: [] }); - - await injectDynamic(ctx); - - expect(ctx.context.getHistory()).toEqual([]); - }); - - it('resolves sessionStart skills by plugin identity when names collide', async () => { - const { ctx } = sessionStartRuntime({ - sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], - skills: [ - skill('using-superpowers', 'project body'), - skill('using-superpowers', 'plugin body', { id: 'superpowers' }), - ], + describe('empty declarations', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + registerPluginSessionStartInjection(injector, [], skills, logger); }); - await injectDynamic(ctx); + it('emits nothing when no sessionStart declarations are present', async () => { + await injectDynamic(injector); - const text = lastReminder(ctx); - expect(text).toContain('plugin body'); - expect(text).not.toContain('project body'); + expect(context.get()).toEqual([]); + }); + }); + + describe('colliding skill names', () => { + beforeEach(() => { + warnings = []; + logger = recordingLogger(warnings); + skills = new SessionSkillRegistry(); + skills.register(skill('using-superpowers', 'project body')); + skills.register(skill('using-superpowers', 'plugin body', { id: 'superpowers' })); + ctx = createTestAgent(skillServices(skills), logServices(logger)); + context = ctx.get(IContextMemory); + injector = ctx.get(IContextInjector); + registerPluginSessionStartInjection( + injector, + [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + skills, + logger, + ); + }); + + it('resolves sessionStart skills by plugin identity when names collide', async () => { + await injectDynamic(injector); + + const text = lastReminder(context); + expect(text).toContain('plugin body'); + expect(text).not.toContain('project body'); + }); }); }); diff --git a/packages/agent-core-v2/test/skill/skill.test.ts b/packages/agent-core-v2/test/skill/skill.test.ts index 219064a66..b9c3d545d 100644 --- a/packages/agent-core-v2/test/skill/skill.test.ts +++ b/packages/agent-core-v2/test/skill/skill.test.ts @@ -8,11 +8,18 @@ import { IEventSink } from '#/eventSink'; import { IPromptService } from '#/prompt'; import { IAgentSkillService, InMemorySkillCatalog, ISkillCatalog } from '#/skill'; import { AgentSkillService } from '#/skill/skillService'; +import { + MAX_SKILL_QUERY_DEPTH, + NestedSkillTooDeepError, + SkillTool, +} from '#/skill/tools/skill'; +import { ModelSkillTool } from '#/skill/tools/modelSkill'; import { ITelemetryService } from '#/telemetry'; import { IToolRegistry } from '#/toolRegistry'; import type { Turn } from '#/turn'; import { IWireRecord } from '#/wireRecord'; import { stubWireRecord } from '../contextMemory/stubs'; +import { executeTool } from '../tools/fixtures/execute-tool'; import { stubSkill } from './stubs'; const COMMIT_SKILL = stubSkill('commit', { @@ -37,6 +44,7 @@ describe('AgentSkillService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let prompted: ContextMessage[]; + let skills: InMemorySkillCatalog; beforeEach(() => { disposables = new DisposableStore(); @@ -48,7 +56,10 @@ describe('AgentSkillService', () => { prompted.push(message); return fakeTurn(); }, - steer: () => undefined, + steer: (message) => { + prompted.push(message); + return undefined; + }, retry: () => undefined, undo: () => 0, clear: () => {}, @@ -64,7 +75,7 @@ describe('AgentSkillService', () => { }); }, }); - const skills = new InMemorySkillCatalog(); + skills = new InMemorySkillCatalog(); skills.register(COMMIT_SKILL); const skillCatalog: ISkillCatalog = { _serviceBrand: undefined, @@ -128,3 +139,184 @@ describe('AgentSkillService', () => { expect(prompted).toHaveLength(1); }); }); + +describe('SkillTool', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let prompted: ContextMessage[]; + let skills: InMemorySkillCatalog; + + beforeEach(() => { + disposables = new DisposableStore(); + prompted = []; + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.definePartialInstance(IPromptService, { + prompt: (message) => { + prompted.push(message); + return fakeTurn(); + }, + steer: (message) => { + prompted.push(message); + return undefined; + }, + retry: () => undefined, + undo: () => 0, + clear: () => {}, + }); + reg.definePartialInstance(IEventSink, { + emit: () => {}, + on: () => ({ dispose: () => {} }), + }); + reg.defineInstance(IWireRecord, stubWireRecord()); + reg.definePartialInstance(ITelemetryService, { track: () => {} }); + }, + }); + skills = new InMemorySkillCatalog(); + skills.register(COMMIT_SKILL); + ix.set(ISkillCatalog, { + _serviceBrand: undefined, + catalog: skills, + ready: Promise.resolve(), + load: async () => {}, + reload: async () => {}, + } satisfies ISkillCatalog); + ix.set(IAgentSkillService, new SyncDescriptor(AgentSkillService)); + }); + afterEach(() => disposables.dispose()); + + function toolContext(args: { readonly skill: string; readonly args?: string }) { + return { + turnId: '0', + toolCallId: 'call_skill', + args, + signal: new AbortController().signal, + }; + } + + it('exposes metadata and schema for model-invoked skills', () => { + const tool = new SkillTool(ix.get(IAgentSkillService)); + + expect(tool.name).toBe('Skill'); + expect(tool.description).toContain('Invoke a registered skill'); + expect(tool.description).toContain(String(MAX_SKILL_QUERY_DEPTH)); + expect(tool.parameters).toMatchObject({ + type: 'object', + required: ['skill'], + additionalProperties: false, + properties: { + skill: expect.objectContaining({ type: 'string' }), + args: expect.objectContaining({ type: 'string' }), + }, + }); + }); + + it('returns a tool error when the skill is unknown', async () => { + const result = await executeTool( + new SkillTool(ix.get(IAgentSkillService)), + toolContext({ skill: 'missing' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Skill "missing" not found in the current skill listing.', + }); + }); + + it('rejects skills that disable model invocation', async () => { + skills.register(stubSkill('private', { metadata: { disableModelInvocation: true } })); + + const result = await executeTool( + new SkillTool(ix.get(IAgentSkillService)), + toolContext({ skill: 'private' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Skill "private" can only be triggered by the user (model invocation is disabled).', + }); + }); + + it('rejects non-inline skill types in the current v1 runtime', async () => { + skills.register(stubSkill('flow-only', { metadata: { type: 'flow' } })); + + const result = await executeTool( + new SkillTool(ix.get(IAgentSkillService)), + toolContext({ skill: 'flow-only' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Skill "flow-only" is not an inline skill and cannot be invoked by the model in v1.', + }); + }); + + it('loads inline skills through the model-tool wrapper without exposing the body in output', async () => { + const result = await executeTool( + new SkillTool(ix.get(IAgentSkillService)), + toolContext({ skill: 'commit', args: 'src/app.ts' }), + ); + + expect(result).toMatchObject({ + output: 'Skill "commit" loaded inline. Follow its instructions.', + }); + expect(result.output).not.toContain('# Commit'); + expect(prompted).toHaveLength(1); + expect(prompted[0]!.origin).toMatchObject({ + kind: 'skill_activation', + skillName: 'commit', + trigger: 'model-tool', + }); + expect(prompted[0]!.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining( + '', + ), + }); + expect(prompted[0]!.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('ARGUMENTS: src/app.ts'), + }); + }); + + it('honors initialQueryDepth as an alias for queryDepth', async () => { + const calls: Array<{ readonly name: string; readonly queryDepth?: number }> = []; + const service: IAgentSkillService = { + _serviceBrand: undefined, + activate: async () => fakeTurn(), + activateFromModel: async (input) => { + calls.push({ name: input.name, queryDepth: input.queryDepth }); + return { output: 'loaded' }; + }, + }; + + await executeTool( + new SkillTool(service, { initialQueryDepth: 2 }), + toolContext({ skill: 'commit' }), + ); + await executeTool( + new ModelSkillTool(service, { initialQueryDepth: 1 }), + toolContext({ skill: 'commit' }), + ); + + expect(calls).toEqual([ + { name: 'commit', queryDepth: 2 }, + { name: 'commit', queryDepth: 1 }, + ]); + }); + + it('throws a structured recursion error when nested skill invocation is too deep', async () => { + const service: IAgentSkillService = { + _serviceBrand: undefined, + activate: async () => fakeTurn(), + activateFromModel: async () => ({ output: 'should not run' }), + }; + + await expect( + executeTool( + new SkillTool(service, { initialQueryDepth: MAX_SKILL_QUERY_DEPTH }), + toolContext({ skill: 'commit' }), + ), + ).rejects.toBeInstanceOf(NestedSkillTooDeepError); + }); +}); diff --git a/packages/agent-core-v2/test/snapshot/events.ts b/packages/agent-core-v2/test/snapshot/events.ts new file mode 100644 index 000000000..5af5b6f7f --- /dev/null +++ b/packages/agent-core-v2/test/snapshot/events.ts @@ -0,0 +1,140 @@ +import type { EventSnapshot, EventSnapshotEntry } from '../harness/snapshots'; +import { createEventSnapshotter } from '../harness/snapshots'; + +export type RecordedEventEntry = EventSnapshotEntry & { + readonly response?: PromiseLike & { + resolve(value: unknown): void; + reject(reason?: unknown): void; + }; +}; + +interface PendingWaiter { + readonly event: string; + readonly resolve: (entry: RecordedEventEntry) => void; +} + +interface PendingAnyWaiter { + readonly events: readonly string[]; + readonly resolve: (event: string) => void; +} + +export function recordAgentEvents() { + const entries: RecordedEventEntry[] = []; + const snapshot = createEventSnapshotter(); + const waiters: PendingWaiter[] = []; + const anyWaiters: PendingAnyWaiter[] = []; + let drainIndex = 0; + + function push(entry: RecordedEventEntry): RecordedEventEntry { + entries.push(entry); + for (let index = waiters.length - 1; index >= 0; index -= 1) { + const waiter = waiters[index]!; + if (waiter.event === entry.event) { + waiters.splice(index, 1); + waiter.resolve(entry); + } + } + for (let index = anyWaiters.length - 1; index >= 0; index -= 1) { + const waiter = anyWaiters[index]!; + if (waiter.events.includes(entry.event)) { + anyWaiters.splice(index, 1); + waiter.resolve(entry.event); + } + } + return entry; + } + + function waitFor(event: string): Promise { + const existing = entries.slice(drainIndex).find((entry) => entry.event === event); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolve) => { + waiters.push({ event, resolve }); + }); + } + + function resolveEntry(entry: RecordedEventEntry, result: unknown): void { + entry.response?.resolve(result); + } + + function drainThrough(entry: RecordedEventEntry): EventSnapshot { + const entryIndex = entries.indexOf(entry); + if (entryIndex < drainIndex) return snapshot([]); + const drained = entries.slice(drainIndex, entryIndex + 1); + drainIndex = entryIndex + 1; + return snapshot(drained); + } + + return { + entries, + drain(): EventSnapshot { + const drained = entries.slice(drainIndex); + drainIndex = entries.length; + return snapshot(drained); + }, + async until(event: string): Promise { + const entry = await waitFor(event); + return drainThrough(entry); + }, + async take(event: string): Promise<{ + readonly event: RecordedEventEntry; + readonly events: EventSnapshot; + readonly respond: (result: T) => void; + }> { + const entry = await waitFor(event); + return { + event: entry, + events: drainThrough(entry), + respond: (result) => resolveEntry(entry, result), + }; + }, + once(type: string): Promise { + return waitFor(type).then(() => {}); + }, + onceAny(types: readonly string[]): Promise { + const existing = entries.slice(drainIndex).find((entry) => types.includes(entry.event)); + if (existing !== undefined) return Promise.resolve(existing.event); + return new Promise((resolve) => { + anyWaiters.push({ events: [...types], resolve }); + }); + }, + recordWire(event: { readonly type: string; readonly [key: string]: unknown }) { + const { type, ...args } = event; + return push({ + type: '[wire]', + event: type, + args, + }); + }, + recordEmit(method: string, args: unknown, response?: RecordedEventEntry['response']) { + return push( + response === undefined + ? { + type: '[rpc]', + event: method, + args, + } + : { + type: '[rpc]', + event: method, + args, + response, + }, + ); + }, + respond(event: RecordedEventEntry, result: unknown): void { + resolveEntry(event, result); + }, + respondPending(method: string, id: string, result: unknown): void { + const pending = entries.find((entry) => { + if (entry.event !== method || entry.response === undefined) return false; + if (entry.args === null || typeof entry.args !== 'object') return false; + const args = entry.args as Record; + return args['id'] === id || args['toolCallId'] === id; + }); + if (pending === undefined) { + throw new Error(`No pending ${method} event found for ${id}`); + } + resolveEntry(pending, result); + }, + }; +} diff --git a/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts b/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts index e21981c4b..b560e520f 100644 --- a/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts +++ b/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts @@ -1,7 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userCancellationReason } from '#/_base/utils/abort'; +import { IBackgroundService, type IBackgroundService as BackgroundService } from '#/background'; +import type { ILogger, LogPayload } from '#/log'; import { IProfileService } from '#/profile'; -import type { SessionSubagentHost } from '#/subagentHost'; +import { + AgentTool, + AgentToolInputSchema, + DEFAULT_SUBAGENT_TIMEOUT_MS, + type ISubagentHost, + type SessionSubagentHost, +} from '#/subagentHost'; +import type { AgentToolSubagentMap } from '#/subagentHost/agentTool'; +import { ToolAccesses } from '#/tool'; import { IToolRegistry } from '#/toolRegistry'; import { executeTool } from '../tools/fixtures/execute-tool'; import { @@ -12,6 +23,682 @@ import { const signal = new AbortController().signal; +interface CapturedLogEntry { + readonly level: 'error' | 'warn' | 'info' | 'debug'; + readonly message: string; + readonly payload: LogPayload | undefined; +} + +function context(args: Input, toolCallId = 'call_agent') { + return { turnId: '0', toolCallId, args, signal }; +} + +function createLogCapture(): { + readonly logger: ILogger; + readonly entries: CapturedLogEntry[]; +} { + const entries: CapturedLogEntry[] = []; + const logger: ILogger = { + error: (message, payload) => entries.push({ level: 'error', message, payload }), + warn: (message, payload) => entries.push({ level: 'warn', message, payload }), + info: (message, payload) => entries.push({ level: 'info', message, payload }), + debug: (message, payload) => entries.push({ level: 'debug', message, payload }), + child: () => logger, + }; + return { logger, entries }; +} + +describe('AgentTool direct contract', () => { + let contexts: TestAgentContext[]; + + beforeEach(() => { + contexts = []; + }); + + afterEach(async () => { + vi.useRealTimers(); + const current = contexts; + contexts = []; + await Promise.all(current.map((ctx) => ctx.dispose())); + }); + + function makeTool({ + host = createSubagentHost(), + maxRunningTasks, + subagents, + canRunInBackground, + log, + }: { + readonly host?: SessionSubagentHost; + readonly maxRunningTasks?: number; + readonly subagents?: AgentToolSubagentMap; + readonly canRunInBackground?: () => boolean; + readonly log?: ILogger; + } = {}): { + readonly ctx: TestAgentContext; + readonly background: BackgroundService; + readonly host: SessionSubagentHost; + readonly tool: AgentTool; + } { + const ctx = + maxRunningTasks === undefined + ? createTestAgent() + : createTestAgent({ + initialConfig: { background: { maxRunningTasks } }, + }); + contexts.push(ctx); + const background = ctx.get(IBackgroundService); + return { + ctx, + background, + host, + tool: new AgentTool(host as unknown as ISubagentHost, background, subagents, { + canRunInBackground, + log, + }), + }; + } + + it('accepts the snake_case background parameter', () => { + const parsed = AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + run_in_background: true, + }); + + expect(parsed).toMatchObject({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + run_in_background: true, + }); + }); + + it('exposes current schema without legacy background, timeout, or model parameters', () => { + const { tool } = makeTool(); + const properties = (tool.parameters as { properties: Record }).properties; + + expect(properties).toHaveProperty('run_in_background'); + expect(properties).toHaveProperty('subagent_type'); + expect(properties).not.toHaveProperty('runInBackground'); + expect(properties).not.toHaveProperty('timeout'); + expect(properties).not.toHaveProperty('model'); + }); + + it('describes subagent_type and run_in_background parameters', () => { + const { tool } = makeTool(); + const properties = ( + tool.parameters as { + properties: Record; + } + ).properties; + + expect(properties['subagent_type']?.description).toContain('coder'); + expect(properties['subagent_type']?.description).toContain('agent type'); + expect(properties['subagent_type']?.description).not.toContain('registry'); + expect(properties['run_in_background']?.description).toContain('false'); + }); + + it('explains the fixed background subagent timeout', () => { + const { tool } = makeTool(); + + expect(DEFAULT_SUBAGENT_TIMEOUT_MS).toBe(30 * 60 * 1000); + expect(tool.description).toContain('fixed 30-minute timeout'); + expect(tool.description).not.toContain('operator-configured background timeout'); + expect(tool.description).not.toContain('no time limit'); + }); + + it('renders configured subagent types and their tool sets', () => { + const { tool } = makeTool({ + subagents: { + explore: { + description: 'Read-only exploration.', + whenToUse: 'Use for searches.', + tools: ['Read', 'Grep', 'Glob'], + }, + coder: { + description: 'General coding.', + tools: ['Read', 'Write', 'Edit', 'Bash'], + }, + }, + }); + + expect(tool.description).toContain('Available agent types'); + expect(tool.description).toContain('- explore: Read-only exploration. Use for searches.'); + expect(tool.description).toContain('Tools: Read, Grep, Glob'); + expect(tool.description).toContain('- coder: General coding.'); + expect(tool.description).toContain('Tools: Read, Write, Edit, Bash'); + }); + + it('mentions resume preference and result visibility in the description', () => { + const { tool } = makeTool(); + + expect(tool.description.toLowerCase()).toContain('resume'); + expect(tool.description.toLowerCase()).toContain('only visible to you'); + expect(tool.description.toLowerCase()).toContain('when not to'); + }); + + it('normalizes the default subagent type into tool args', () => { + expect( + AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + }).subagent_type, + ).toBe('coder'); + expect( + AgentToolInputSchema.parse({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: '', + }).subagent_type, + ).toBe('coder'); + expect( + AgentToolInputSchema.parse({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }).subagent_type, + ).toBeUndefined(); + }); + + it('declares no resource accesses so concurrent Agent calls can run in parallel', async () => { + const { tool } = makeTool(); + const execution = await tool.resolveExecution({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.accesses).toEqual(ToolAccesses.none()); + }); + + it('uses the resumed agent profile in the activity description', async () => { + const host = createSubagentHost({ + getProfileName: vi.fn().mockResolvedValue('explore'), + }); + const { tool } = makeTool({ host }); + const execution = await tool.resolveExecution({ + prompt: 'Continue', + description: 'Continue work', + resume: ' agent-existing ', + }); + + if (execution.isError === true) throw new Error('expected runnable execution'); + expect(execution.description).toBe('Launching explore agent: Continue work'); + expect(host.getProfileName).toHaveBeenCalledWith('agent-existing'); + }); + + it('falls back to coder for an empty subagent type', async () => { + const host = createSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'child result' }), + }), + }); + const { tool } = makeTool({ host }); + + await executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + subagent_type: '', + }), + ); + + expect(host.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + parentToolCallId: 'call_agent', + profileName: 'coder', + }), + ); + }); + + it('resumes a foreground subagent when resume is provided', async () => { + const host = createSubagentHost({ + spawn: vi.fn(), + resume: vi.fn().mockResolvedValue({ + agentId: 'agent-existing', + profileName: 'explore', + resumed: true, + completion: Promise.resolve({ result: 'resumed result' }), + }), + }); + const { tool } = makeTool({ host }); + + const result = await executeTool( + tool, + context({ + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }), + ); + + expect(host.spawn).not.toHaveBeenCalled(); + expect(host.resume).toHaveBeenCalledWith( + 'agent-existing', + expect.objectContaining({ + parentToolCallId: 'call_agent', + prompt: 'Continue', + description: 'Continue work', + runInBackground: false, + signal: expect.any(AbortSignal), + }), + ); + expect(result.output).toContain('agent_id: agent-existing'); + expect(result.output).toContain('actual_subagent_type: explore'); + expect(result.output).toContain('resumed result'); + }); + + it('does not consume a background task slot when validation fails before launch', async () => { + const host = createSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: new Promise(() => {}), + }), + resume: vi.fn(), + }); + const { tool } = makeTool({ host, maxRunningTasks: 1 }); + + const invalid = await executeTool( + tool, + context({ + prompt: 'Continue', + description: 'Invalid background resume', + resume: 'agent-existing', + subagent_type: 'explore', + run_in_background: true, + }), + ); + const valid = await executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }), + ); + + expect(invalid).toMatchObject({ + isError: true, + output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', + }); + expect(valid.output).toContain('status: running'); + expect(host.resume).not.toHaveBeenCalled(); + expect(host.spawn).toHaveBeenCalledTimes(1); + }); + + it('can detach a foreground subagent through the background manager', async () => { + let resolveCompletion: (value: { result: string }) => void = () => {}; + const completion = new Promise<{ result: string }>((resolve) => { + resolveCompletion = resolve; + }); + const host = createSubagentHost({ + markActiveChildDetached: vi.fn(), + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion, + }), + }); + const { background, tool } = makeTool({ host }); + + const running = executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + }), + ); + await vi.waitFor(() => { + expect(background.list(false)).toHaveLength(1); + }); + const task = background.list(false)[0]!; + + expect(task).toMatchObject({ + kind: 'agent', + detached: false, + agentId: 'agent-child', + }); + + background.detach(task.taskId); + const result = await running; + + expect(host.markActiveChildDetached).toHaveBeenCalledWith('agent-child'); + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('automatic_notification: true'); + + resolveCompletion({ result: 'finished later' }); + await expect(background.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('does not recommend disabled task tools when a foreground subagent is detached', async () => { + let resolveCompletion: (value: { result: string }) => void = () => {}; + const completion = new Promise<{ result: string }>((resolve) => { + resolveCompletion = resolve; + }); + const host = createSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion, + }), + }); + const { background, tool } = makeTool({ + host, + canRunInBackground: () => false, + }); + + const running = executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + }), + ); + await vi.waitFor(() => { + expect(background.list(false)).toHaveLength(1); + }); + const task = background.list(false)[0]!; + + background.detach(task.taskId); + const result = await running; + + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('next_step: The completion arrives automatically'); + expect(result.output).not.toContain('TaskOutput'); + expect(result.output).not.toContain('TaskStop'); + + resolveCompletion({ result: 'finished later' }); + await expect(background.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('guides the AI with a non-blocking query hint and a resume hint on background launch', async () => { + const host = createSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: new Promise(() => {}), + }), + }); + const { tool } = makeTool({ host }); + + const result = await executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }), + ); + + if (typeof result.output !== 'string') throw new TypeError('expected string output'); + const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1]; + expect(taskId).toBeDefined(); + expect(result.output).toContain('next_step:'); + expect(result.output).toContain(`TaskOutput(task_id="${taskId!}", block=false)`); + expect(result.output).toContain('resume_hint:'); + expect(result.output).toContain('Agent(resume="agent-child"'); + expect(result.output).toMatch(/agent_id.*not.*task_id|task_id.*not.*agent_id/i); + expect(result.output).toMatch(/task\.lost|task\.failed|task\.killed/); + }); + + it('returns an error when background registration hits the task limit', async () => { + const host = createSubagentHost({ + spawn: vi + .fn() + .mockResolvedValueOnce({ + agentId: 'agent-existing', + profileName: 'coder', + resumed: false, + completion: new Promise(() => {}), + }) + .mockResolvedValueOnce({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: new Promise(() => {}), + }), + }); + const { tool } = makeTool({ host, maxRunningTasks: 1 }); + + const existing = await executeTool( + tool, + context({ + prompt: 'Keep busy', + description: 'Existing work', + run_in_background: true, + }), + ); + const rejected = await executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }), + ); + + expect(existing.output).toContain('status: running'); + expect(rejected).toMatchObject({ + isError: true, + output: 'Too many background tasks are already running.', + }); + expect(host.spawn).toHaveBeenCalledTimes(2); + }); + + it('rejects one of two concurrent background subagents when the task limit is reached', async () => { + const host = createSubagentHost({ + spawn: vi + .fn() + .mockResolvedValueOnce({ + agentId: 'agent-first', + profileName: 'coder', + resumed: false, + completion: new Promise(() => {}), + }) + .mockResolvedValueOnce({ + agentId: 'agent-second', + profileName: 'coder', + resumed: false, + completion: Promise.resolve({ result: 'second result' }), + }), + }); + const { tool } = makeTool({ host, maxRunningTasks: 1 }); + + const first = executeTool( + tool, + context({ + prompt: 'Investigate first', + description: 'Find first', + run_in_background: true, + }), + ); + const second = executeTool( + tool, + context({ + prompt: 'Investigate second', + description: 'Find second', + run_in_background: true, + }), + ); + + const results = await Promise.all([first, second]); + + expect(host.spawn).toHaveBeenCalledTimes(2); + expect(results).toContainEqual( + expect.objectContaining({ output: expect.stringContaining('status: running') }), + ); + expect(results).toContainEqual( + expect.objectContaining({ + isError: true, + output: 'Too many background tasks are already running.', + }), + ); + }); + + it('returns tool errors when spawning fails', async () => { + const error = new Error('missing subagent'); + const { logger, entries } = createLogCapture(); + const host = createSubagentHost({ + spawn: vi.fn().mockRejectedValue(error), + }); + const { tool } = makeTool({ host, log: logger }); + + const result = await executeTool( + tool, + context({ prompt: 'Investigate', description: 'Find cause' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: missing subagent', + }); + expect(entries).toEqual([ + { + level: 'warn', + message: 'subagent launch failed', + payload: expect.objectContaining({ + toolCallId: 'call_agent', + runInBackground: false, + operation: 'spawn', + subagentType: 'coder', + error, + }), + }, + ]); + }); + + it('logs background registration failures', async () => { + const error = new Error('background unavailable'); + const { logger, entries } = createLogCapture(); + const host = createSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: new Promise(() => {}), + }), + }); + const { background, tool } = makeTool({ host, log: logger }); + vi.spyOn(background, 'registerTask').mockImplementation(() => { + throw error; + }); + + const result = await executeTool( + tool, + context({ + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'background unavailable', + }); + expect(entries).toEqual([ + { + level: 'warn', + message: 'background agent task registration failed', + payload: expect.objectContaining({ + toolCallId: 'call_agent', + agentId: 'agent-child', + subagentType: 'coder', + error, + }), + }, + ]); + }); + + it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => { + const controller = new AbortController(); + const host = createSubagentHost({ + spawn: vi.fn((options) => + Promise.resolve({ + agentId: 'agent-child', + profileName: 'coder', + resumed: false, + completion: new Promise<{ result: string }>((_resolve, reject) => { + const onAbort = (): void => { + reject(options.signal.reason); + }; + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener('abort', onAbort, { once: true }); + }), + }), + ), + }); + const { tool } = makeTool({ host }); + + const resultPromise = executeTool(tool, { + turnId: '0', + toolCallId: 'call_agent', + args: { prompt: 'Investigate', description: 'Find cause' }, + signal: controller.signal, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(userCancellationReason()); + const result = await resultPromise; + + expect(result.isError).toBe(true); + expect(result.output).toContain('status: failed'); + expect(result.output).not.toContain('was stopped by the user'); + expect(result.output).toContain('not a system error'); + expect(result.output).toContain('capacity'); + expect(result.output).toContain('wait for the user'); + }); + + it('returns the spawned agent id when a foreground subagent times out', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const host = createSubagentHost({ + spawn: vi.fn().mockResolvedValue({ + agentId: 'agent-timeout', + profileName: 'coder', + resumed: false, + completion: new Promise<{ result: string }>(() => {}), + }), + }); + const { tool } = makeTool({ host }); + + const resultPromise = executeTool( + tool, + context({ + prompt: 'Investigate long task', + description: 'Investigate timeout', + }), + ); + await vi.advanceTimersByTimeAsync(DEFAULT_SUBAGENT_TIMEOUT_MS + 5_000); + const result = await resultPromise; + + expect(result.isError).toBe(true); + expect(result.output).toContain('agent_id: agent-timeout'); + expect(result.output).toContain('actual_subagent_type: coder'); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('Agent timed out after 30 minutes.'); + expect(result.output).toContain('Agent(resume="agent-timeout", prompt="continue")'); + expect(result.output).toContain('Use agent_id only; do not set subagent_type.'); + }); +}); + describe('Agent tool service runtime', () => { describe('with a default subagent host', () => { let ctx: TestAgentContext; diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts index 0a0c9d069..bc749c273 100644 --- a/packages/agent-core-v2/test/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/swarm/swarm.test.ts @@ -1,22 +1,61 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { IContextMemory } from '#/contextMemory'; import { IEventSink } from '../../src/eventSink'; -import { ISubagentHost } from '#/subagentHost'; +import { + DEFAULT_SUBAGENT_TIMEOUT_MS, + ISubagentHost, + type QueuedSubagentRunResult, + type QueuedSubagentTask, +} from '#/subagentHost'; import { ISystemReminderService } from '#/systemReminder'; import { SystemReminderService } from '#/systemReminder/systemReminderService'; import { ISwarmService } from '#/swarm'; import { SwarmService } from '#/swarm/swarmService'; +import { + AgentSwarmTool, + AgentSwarmToolInputSchema, +} from '#/swarm/tools/agent-swarm'; +import type { ExecutableToolContext } from '#/tool'; import { IToolRegistry, ToolRegistryService } from '#/toolRegistry'; import { ITurnService } from '#/turn'; import { IWireRecord } from '#/wireRecord'; import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs'; +import { executeTool } from '../tools/fixtures/execute-tool'; import { stubTurnWithHooks } from '../turn/stubs'; +const signal = new AbortController().signal; + +function context( + args: Input, + toolCallId = 'call_swarm', +): ExecutableToolContext & { readonly args: Input } { + return { turnId: '0', toolCallId, args, signal }; +} + +function mockSubagentHost({ + getSwarmItem = () => undefined, + runQueued = vi.fn().mockResolvedValue([]), +}: { + readonly getSwarmItem?: (agentId: string) => string | undefined; + readonly runQueued?: ( + tasks: readonly QueuedSubagentTask[], + ) => Promise>>; +} = {}) { + return { + getSwarmItem: vi.fn(getSwarmItem), + runQueued, + }; +} + +function mockSwarmMode() { + return { enter: vi.fn() }; +} + describe('SwarmService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -44,3 +83,501 @@ describe('SwarmService', () => { expect(swarm.isActive).toBe(false); }); }); + +describe('AgentSwarmTool', () => { + it('applies one subagent_type across templated subagents', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockResolvedValue([ + { + task: { + kind: 'spawn', + data: { + kind: 'spawn', + index: 1, + item: 'src/a.ts', + prompt: 'Review src/a.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (explore)', + runInBackground: false, + }, + agentId: 'agent-explore-1', + status: 'completed', + result: 'explore result a', + }, + { + task: { + kind: 'spawn', + data: { + kind: 'spawn', + index: 2, + item: 'src/b.ts', + prompt: 'Review src/b.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (explore)', + runInBackground: false, + }, + agentId: 'agent-explore-2', + status: 'completed', + result: 'explore result b', + }, + ]), + }); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); + const input = { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + subagent_type: 'explore', + }; + + expect(AgentSwarmToolInputSchema.safeParse(input).success).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + ...input, + items: Array.from({ length: 128 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + ...input, + items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(false); + expect(tool.parameters).toMatchObject({ + type: 'object', + properties: { + subagent_type: { type: 'string' }, + }, + }); + expect(Object.keys(tool.parameters['properties'] as Record).at(-1)).toBe( + 'resume_agent_ids', + ); + + const result = await executeTool(tool, context(input)); + + expect(swarmMode.enter).toHaveBeenCalledWith('tool'); + expect(host.runQueued).toHaveBeenCalledTimes(1); + expect(host.runQueued).toHaveBeenCalledWith([ + { + kind: 'spawn', + data: { + kind: 'spawn', + index: 1, + item: 'src/a.ts', + prompt: 'Review src/a.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (explore)', + swarmIndex: 1, + swarmItem: 'src/a.ts', + runInBackground: false, + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }, + { + kind: 'spawn', + data: { + kind: 'spawn', + index: 2, + item: 'src/b.ts', + prompt: 'Review src/b.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (explore)', + swarmIndex: 2, + swarmItem: 'src/b.ts', + runInBackground: false, + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }, + ]); + expect(result.output).toBe( + [ + '', + 'completed: 2', + 'explore result a', + 'explore result b', + '', + ].join('\n'), + ); + expect(result.isError).toBeUndefined(); + }); + + it('does not expose permission rule argument matching', () => { + const tool = new AgentSwarmTool(mockSubagentHost(), mockSwarmMode()); + const execution = tool.resolveExecution({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + + expect(execution.isError).toBeUndefined(); + expect(execution.approvalRule).toBe('AgentSwarm'); + expect(execution.matchesRule).toBeUndefined(); + }); + + it('rejects invalid launch shapes at execution time', async () => { + const cases = [ + { + input: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`), + }, + output: 'AgentSwarm supports at most 128 subagents.', + }, + { + input: { + description: 'Review one file', + prompt_template: 'Review {{item}}', + items: ['src/only.ts'], + }, + output: 'AgentSwarm requires at least 2 items unless resume_agent_ids is provided.', + }, + { + input: { + description: 'Review files', + items: ['src/a.ts', 'src/b.ts'], + }, + output: 'prompt_template is required when items are provided.', + }, + { + input: { + description: 'Review files', + prompt_template: 'Review files', + items: ['src/a.ts', 'src/b.ts'], + }, + output: 'prompt_template must include the {{item}} placeholder.', + }, + { + input: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['same', 'same'], + }, + output: + 'Duplicate subagent prompts from items 1 and 2. AgentSwarm requires distinct subagents.', + }, + ]; + + for (const testCase of cases) { + const host = mockSubagentHost(); + const tool = new AgentSwarmTool(host, mockSwarmMode()); + + const result = await executeTool(tool, context(testCase.input)); + + expect(result.output).toBe(testCase.output); + expect(result.isError).toBe(true); + expect(host.runQueued).not.toHaveBeenCalled(); + } + }); + + it('resumes mapped agents before spawning item subagents', async () => { + const runQueued = vi.fn( + async ( + tasks: readonly QueuedSubagentTask[], + ): Promise>> => + tasks.map((task, index) => ({ + task, + agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`, + status: 'completed' as const, + result: `result ${String(index + 1)}`, + })), + ); + const host = mockSubagentHost({ + getSwarmItem: (agentId) => + ({ 'agent-old-1': 'src/old-a.ts', 'agent-old-2': 'src/old-b.ts' })[agentId], + runQueued, + }); + const tool = new AgentSwarmTool(host, mockSwarmMode()); + const input = { + description: 'Finish review', + subagent_type: 'explore', + prompt_template: 'Review {{item}}', + items: ['src/new.ts'], + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }; + + expect(AgentSwarmToolInputSchema.safeParse(input).success).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume one agent', + resume_agent_ids: { 'agent-old-1': 'Continue previous review A' }, + }).success, + ).toBe(true); + + const result = await executeTool(tool, context(input)); + + expect(host.runQueued).toHaveBeenCalledWith([ + { + kind: 'resume', + data: { + kind: 'resume', + index: 1, + agentId: 'agent-old-1', + item: 'src/old-a.ts', + prompt: 'Continue previous review A', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review A', + description: 'Finish review #1 (resume)', + swarmIndex: 1, + swarmItem: 'src/old-a.ts', + runInBackground: false, + resumeAgentId: 'agent-old-1', + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }, + { + kind: 'resume', + data: { + kind: 'resume', + index: 2, + agentId: 'agent-old-2', + item: 'src/old-b.ts', + prompt: 'Continue previous review B', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review B', + description: 'Finish review #2 (resume)', + swarmIndex: 2, + swarmItem: 'src/old-b.ts', + runInBackground: false, + resumeAgentId: 'agent-old-2', + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }, + { + kind: 'spawn', + data: { + kind: 'spawn', + index: 3, + item: 'src/new.ts', + prompt: 'Review src/new.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/new.ts', + description: 'Finish review #3 (explore)', + swarmIndex: 3, + swarmItem: 'src/new.ts', + runInBackground: false, + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }, + ]); + expect(result.output).toBe( + [ + '', + 'completed: 3', + 'result 1', + 'result 2', + 'result 3', + '', + ].join('\n'), + ); + expect(result.isError).toBeUndefined(); + }); + + it('allows a single resumed subagent without item subagents', async () => { + const runQueued = vi.fn( + async ( + tasks: readonly QueuedSubagentTask[], + ): Promise>> => + tasks.map((task) => ({ + task, + agentId: task.kind === 'resume' ? task.resumeAgentId : 'agent-new', + status: 'completed' as const, + result: 'resumed result', + })), + ); + const host = mockSubagentHost({ + getSwarmItem: (agentId) => (agentId === 'agent-old-1' ? 'src/old-a.ts' : undefined), + runQueued, + }); + const tool = new AgentSwarmTool(host, mockSwarmMode()); + const input = { + description: 'Resume review', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + }, + }; + + const result = await executeTool(tool, context(input)); + + expect(host.runQueued).toHaveBeenCalledWith([ + { + kind: 'resume', + data: { + kind: 'resume', + index: 1, + agentId: 'agent-old-1', + item: 'src/old-a.ts', + prompt: 'Continue previous review A', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review A', + description: 'Resume review #1 (resume)', + swarmIndex: 1, + swarmItem: 'src/old-a.ts', + runInBackground: false, + resumeAgentId: 'agent-old-1', + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }, + ]); + expect(result.output).toBe( + [ + '', + 'completed: 1', + 'resumed result', + '', + ].join('\n'), + ); + }); + + it('reports failed subagents inside the XML result without failing the tool', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockImplementation(async (tasks) => [ + { + task: tasks[0], + agentId: 'agent-coder-1', + status: 'completed', + result: 'imports are stable', + }, + { + task: tasks[1], + agentId: 'agent-coder-2', + status: 'failed', + error: 'Agent timed out after 30s.', + }, + ]), + }); + const tool = new AgentSwarmTool(host, mockSwarmMode()); + + const result = await executeTool( + tool, + context({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }), + ); + + expect(result.output).toBe( + [ + '', + 'completed: 1, failed: 1', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'Agent timed out after 30s.', + '', + ].join('\n'), + ); + expect(result.isError).toBeUndefined(); + }); + + it('omits resume hint when incomplete subagents have no agent ids', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockImplementation(async (tasks) => [ + { + task: tasks[0], + status: 'failed', + error: 'Agent did not start.', + }, + { + task: tasks[1], + status: 'failed', + error: 'Agent also did not start.', + }, + ]), + }); + const tool = new AgentSwarmTool(host, mockSwarmMode()); + + const result = await executeTool( + tool, + context({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }), + ); + + expect(result.output).toBe( + [ + '', + 'failed: 2', + 'Agent did not start.', + 'Agent also did not start.', + '', + ].join('\n'), + ); + }); + + it('reports partial aborted subagents inside the XML result', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockImplementation(async (tasks) => [ + { + task: tasks[0], + agentId: 'agent-coder-1', + status: 'completed', + result: 'imports are stable', + }, + { + task: tasks[1], + agentId: 'agent-coder-2', + status: 'aborted', + state: 'started', + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + task: tasks[2], + status: 'aborted', + state: 'not_started', + error: + 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ]), + }); + const tool = new AgentSwarmTool(host, mockSwarmMode()); + + const result = await executeTool( + tool, + context({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }), + ); + + expect(result.output).toBe( + [ + '', + 'completed: 1, aborted: 2', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'The user manually interrupted this subagent batch before this subagent finished.', + 'The user manually interrupted this subagent batch before this subagent was started.', + '', + ].join('\n'), + ); + expect(result.isError).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/todoList/todo-list.test.ts b/packages/agent-core-v2/test/todoList/todo-list.test.ts new file mode 100644 index 000000000..1846f971a --- /dev/null +++ b/packages/agent-core-v2/test/todoList/todo-list.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import { + TODO_LIST_TOOL_NAME, + TODO_STORE_KEY, + TodoListInputSchema, + TodoListTool, + type TodoItem, +} from '#/todoList/tools/todo-list'; +import type { ToolStore } from '#/toolStore'; +import { executeTool } from '../tools/fixtures/execute-tool'; + +const signal = new AbortController().signal; + +function makeStore(initial: readonly TodoItem[] = []): { + readonly store: ToolStore; + readonly getTodos: () => readonly TodoItem[]; +} { + let todos = [...initial]; + return { + store: { + get: (key) => (key === TODO_STORE_KEY ? todos : undefined), + set: (key, value) => { + if (key === TODO_STORE_KEY) { + todos = [...(value as readonly TodoItem[])]; + } + }, + }, + getTodos: () => todos, + }; +} + +function makeTool(initial: readonly TodoItem[] = []): { + readonly tool: TodoListTool; + readonly getTodos: () => readonly TodoItem[]; +} { + const { store, getTodos } = makeStore(initial); + return { tool: new TodoListTool(store), getTodos }; +} + +describe('TodoListTool', () => { + it('has name, description, and parameters from the current schema', () => { + const { tool } = makeTool(); + + expect(TODO_LIST_TOOL_NAME).toBe('TodoList'); + expect(TODO_STORE_KEY).toBe('todo'); + expect(tool.name).toBe(TODO_LIST_TOOL_NAME); + expect(tool.description.length).toBeGreaterThan(0); + expect(TodoListInputSchema.safeParse({}).success).toBe(true); + expect( + TodoListInputSchema.safeParse({ todos: [{ title: 'x', status: 'wip' }] }).success, + ).toBe(false); + expect(tool.parameters).toMatchObject({ + type: 'object', + additionalProperties: false, + properties: { + todos: { type: 'array' }, + }, + }); + }); + + it('description includes the anti-churn guardrails', () => { + const { description } = makeTool().tool; + + expect(description).toContain('**Avoid churn:**'); + expect(description).toMatch(/nothing meaningful has changed/i); + expect(description).toMatch(/real progress/i); + expect(description).toMatch(/query mode/i); + expect(description).toMatch(/tell the user/i); + }); + + it('description encourages proactive progress updates without allowing churn', () => { + const { description } = makeTool().tool; + + expect(description).toMatch(/proactively and often/i); + expect(description).toMatch(/immediately after finishing/i); + expect(description).toMatch(/exactly one/i); + expect(description).toMatch(/in_progress/i); + expect(description).toMatch(/tests are failing/i); + expect(description).toContain('**Avoid churn:**'); + }); + + it('query mode renders the current list without mutating it', async () => { + const { tool, getTodos } = makeTool([{ title: 'existing', status: 'in_progress' }]); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'call_1', + args: {}, + signal, + }); + + expect(result).toMatchObject({ isError: false }); + expect(result.output).toContain('Current todo list'); + expect(result.output).toContain('[in_progress] existing'); + expect(getTodos()).toEqual([{ title: 'existing', status: 'in_progress' }]); + }); + + it('write mode replaces the list and defensively copies todos into the store', async () => { + const { tool, getTodos } = makeTool(); + const todos: TodoItem[] = [ + { title: 'first', status: 'pending' }, + { title: 'second', status: 'in_progress' }, + ]; + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'call_1', + args: { todos }, + signal, + }); + todos[0] = { title: 'leaked', status: 'done' }; + + expect(result).toMatchObject({ isError: false }); + expect(result.output).toContain('Todo list updated'); + expect(result.output).toContain('[pending] first'); + expect(result.output).toContain('[in_progress] second'); + expect(result.output).toContain( + 'Ensure that you continue to use the todo list to track progress.', + ); + expect(result.output).toContain('exactly one task in_progress'); + expect(getTodos()).toEqual([ + { title: 'first', status: 'pending' }, + { title: 'second', status: 'in_progress' }, + ]); + }); + + it('renders a done todo with a marker matching the status enum value', async () => { + const { tool } = makeTool([{ title: 'shipped', status: 'done' }]); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'call_1', + args: {}, + signal, + }); + + expect(result).toMatchObject({ isError: false }); + expect(result.output).toContain('[done] shipped'); + expect(result.output).not.toContain('[completed]'); + }); + + it('clear mode empties the list without adding the progress-tracking reminder', async () => { + const { tool, getTodos } = makeTool([{ title: 'x', status: 'pending' }]); + + const result = await executeTool(tool, { + turnId: 't1', + toolCallId: 'call_1', + args: { todos: [] }, + signal, + }); + + expect(result).toMatchObject({ isError: false, output: 'Todo list cleared.' }); + expect(getTodos()).toEqual([]); + }); + + it('resolveExecution description reflects the mode', () => { + const { tool } = makeTool(); + const readExecution = tool.resolveExecution({}); + const clearExecution = tool.resolveExecution({ todos: [] }); + const updateExecution = tool.resolveExecution({ + todos: [{ title: 'x', status: 'pending' }], + }); + + if ( + readExecution.isError === true || + clearExecution.isError === true || + updateExecution.isError === true + ) { + throw new TypeError('expected runnable executions'); + } + expect(readExecution.description).toBe('Reading todo list'); + expect(clearExecution.description).toBe('Clearing todo list'); + expect(updateExecution.description).toBe('Updating todo list'); + }); +}); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 930034844..b59892bbe 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -436,48 +436,48 @@ describe('Agent tools', () => { output: 'moon-result', }), ).toMatchInlineSnapshot(` - [wire] permission.set_mode { "mode": "auto", "time": "