Merge remote-tracking branch 'origin/kimi-code-v2' into kimi-code-v2

# Conflicts:
#	packages/agent-core-v2/src/shellTools/shellToolsService.ts
#	packages/agent-core-v2/src/userTool/userToolService.ts
#	packages/agent-core-v2/test/goal/injection.test.ts
#	packages/agent-core-v2/test/harness/agent.ts
#	packages/agent-core-v2/test/profile/config-state.test.ts
#	packages/agent-core-v2/test/session-lifecycle/sessionLifecycle.test.ts
#	packages/agent-core-v2/test/shellTools/shellToolsService.test.ts
#	packages/agent-core-v2/test/skill/plugin-session-start.test.ts
#	packages/agent-core-v2/test/snapshot/events.ts
#	packages/agent-core-v2/test/subagentHost/agent-tool.test.ts
#	packages/agent-core-v2/test/swarm/swarm.test.ts
#	packages/agent-core-v2/test/toolDedup/tool-dedup.test.ts
#	packages/agent-core-v2/test/turn/stubs.ts
#	packages/agent-core-v2/test/turn/turn.test.ts
This commit is contained in:
haozhe.yang 2026-07-01 13:58:56 +08:00
commit 314e4ef2df
41 changed files with 3972 additions and 409 deletions

View file

@ -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',

View file

@ -115,6 +115,23 @@ function applySectionEnv(base: unknown, env: AnyEnvBindings, getEnv: GetEnv): un
return target;
}
function isSameSection(
existing: ConfigSection,
schema: ConfigSchema<unknown>,
options: RegisterSectionOptions<unknown>,
): boolean {
return (
existing.schema === schema &&
existing.merge === (options.merge ?? deepMerge) &&
existing.scope === (options.scope ?? ConfigScope.Core) &&
existing.env === (options.env as ConfigSection['env']) &&
existing.stripEnv === (options.stripEnv as ConfigSection['stripEnv']) &&
existing.fromToml === options.fromToml &&
existing.toToml === options.toToml &&
deepEqual(existing.defaultValue, options.defaultValue)
);
}
export class ConfigRegistry implements IConfigRegistry {
declare readonly _serviceBrand: undefined;
private readonly sections = new Map<string, ConfigSection>();
@ -131,7 +148,22 @@ export class ConfigRegistry implements IConfigRegistry {
schema: ConfigSchema<T>,
options: RegisterSectionOptions<T> = {},
): void {
if (this.sections.has(domain)) {
const existing = this.sections.get(domain);
if (existing !== undefined) {
// A section's owner may live in a child scope (Session/Agent) that is
// instantiated more than once per process (e.g. one Agent scope per
// session), so the same owner can register its section again. Treat an
// identical re-registration as a no-op; only a conflicting registration
// from a different owner is an error.
if (
isSameSection(
existing,
schema as ConfigSchema<unknown>,
options as RegisterSectionOptions<unknown>,
)
) {
return;
}
throw new Error(`ConfigRegistry: section '${domain}' is already registered`);
}
this.sections.set(domain, {

View file

@ -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<string, unknown>;
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<GetResult> {
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<void> {
if (!isFileId(fileId)) {
throw fileNotFoundError(fileId);
}
await this.ensureIndex();
if (!this.indexCache!.has(fileId)) {
throw fileNotFoundError(fileId);

View file

@ -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;

View file

@ -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<string, unknown> = {};
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<string, unknown> | undefined {
const modelOverrides: Record<string, unknown> = {};
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;
}

View file

@ -12,6 +12,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/background';
import { IKaos } from '#/kaos';
import { ISessionProcessRunner } from '#/process';
import { IAgentProfileService } from '#/profile';
import { IAgentToolRegistryService } from '#/toolRegistry';
import { IAgentShellToolsService } from './shellTools';
@ -25,8 +26,12 @@ export class AgentShellToolsService implements IAgentShellToolsService {
@ISessionProcessRunner runner: ISessionProcessRunner,
@IKaos kaos: IKaos,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
) {
toolRegistry.register(new BashTool(runner, kaos, background));
toolRegistry.register(new BashTool(runner, kaos, background, {
allowBackground: () =>
profile.isToolActive('TaskOutput') && profile.isToolActive('TaskStop'),
}));
}
}

View file

@ -157,25 +157,30 @@ function withoutBackgroundDescription(description: string): string {
export class BashTool implements BuiltinTool<BashInput> {
readonly name = 'Bash' as const;
readonly description: string;
readonly parameters: Record<string, unknown> = toInputJsonSchema(BashInputSchema);
private readonly isWindowsBash: boolean;
private readonly allowBackground: boolean;
private readonly renderedDescription: string;
private readonly allowBackground: () => boolean;
constructor(
private readonly runner: ISessionProcessRunner,
private readonly kaos: IKaos,
private readonly background: IAgentBackgroundService,
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 {
@ -327,7 +332,7 @@ export class BashTool implements BuiltinTool<BashInput> {
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:
@ -416,14 +421,16 @@ export class BashTool implements BuiltinTool<BashInput> {
// 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 (

View file

@ -1,4 +1,4 @@
import { createDecorator } from "#/_base/di";
import { createDecorator } from '#/_base/di';
export interface UserToolRegistration {
readonly name: string;

View file

@ -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 { ISessionInteractionService } from '#/interaction';
import { IAgentProfileService } from '#/profile';
import { IAgentToolRegistryService } from '#/toolRegistry';
import type { ToolResult } from '#/tool';
import { IAgentWireRecordService } from '#/wireRecord';
import {
IAgentUserToolService,
@ -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 AgentUserToolService extends Disposable implements IAgentUserToolSe
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService,
@ISessionInteractionService private readonly interaction: ISessionInteractionService,
) {
super();
this._register(
@ -86,14 +96,42 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe
}
private async executeUserTool(
_context: ExecutableToolContext,
_name: string,
_args: unknown,
context: ExecutableToolContext,
name: string,
args: unknown,
): Promise<ToolResult> {
throw new Error('TODO');
const request = this.interaction.request<UserToolExecutionRequest, ToolResult>({
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 {

View file

@ -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();
});
});

View file

@ -195,15 +195,15 @@ describe('Agent config', () => {
input: [{ type: 'text', text: 'Look up before config changes' }],
});
expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will look it up." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"original\\"}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_lookup", "toolName": "Lookup", "action": "Approve Lookup", "display": { "kind": "generic", "summary": "Approve Lookup", "detail": { "query": "original" } } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
@ -228,16 +228,20 @@ describe('Agent config', () => {
ctx.mockNextResponse({ type: 'text', text: 'Still using the original turn config.' });
await toolCallEvents;
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "original-result" } ], "toolCalls": [], "toolCallId": "call_lookup" } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "original-result" } ], "toolCalls": [], "toolCallId": "call_lookup", "id": "<msg-3>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_lookup", "output": "original-result" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"original\\"}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "Still using the original turn config." }
[wire] usage.record { "model": "changed-model", "usage": { "inputOther": 31, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 }, "changed-model": { "inputOther": 31, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 40, "output": 30, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 40, "output": 30, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "Still using the original turn config." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "id": "<msg-4>", "role": "assistant", "content": [ { "type": "text", "text": "Still using the original turn config." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 4, "tokens": 44, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 44 }
[wire] context.splice { "start": 3, "deleteCount": 1, "messages": [ { "id": "<msg-4>", "role": "assistant", "content": [ { "type": "text", "text": "Still using the original turn config." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 31, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
@ -253,16 +257,18 @@ describe('Agent config', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start a fresh turn' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 1, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 1, "origin": { "kind": "user" } }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "id": "<msg-5>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 1, "origin": { "kind": "user" }, "promptMessageId": "<msg-5>", "time": "<time>" }
[emit] turn.started { "turnId": 1, "origin": { "kind": "user" }, "promptMessageId": "<msg-5>" }
[emit] turn.step.started { "turnId": 1, "step": 1, "stepId": "<uuid-3>" }
[emit] assistant.delta { "turnId": 1, "delta": "Now the changed config is active." }
[wire] usage.record { "model": "changed-model", "usage": { "inputOther": 50, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 1 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 9, "output": 17, "inputCacheRead": 0, "inputCacheCreation": 0 }, "changed-model": { "inputOther": 81, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 90, "output": 42, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 50, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 5, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "Now the changed config is active." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 5, "deleteCount": 0, "messages": [ { "id": "<msg-6>", "role": "assistant", "content": [ { "type": "text", "text": "Now the changed config is active." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 6, "tokens": 62, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 62 }
[wire] context.splice { "start": 5, "deleteCount": 1, "messages": [ { "id": "<msg-6>", "role": "assistant", "content": [ { "type": "text", "text": "Now the changed config is active." } ], "toolCalls": [], "providerMessageId": "mock-3" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 1, "step": 1, "stepId": "<uuid-3>", "usage": { "inputOther": 50, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 1, "reason": "completed" }
`);

View file

@ -126,6 +126,7 @@ describe('AgentGoalService 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('AgentGoalService 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([

View file

@ -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: IAgentContextMemoryService): 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('<untrusted_objective>');
@ -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);
});
});
});

View file

@ -10,6 +10,7 @@ import {
type ChatProvider,
type ContentPart,
type GenerateOptions,
KimiChatProvider,
type Message as KosongMessage,
type ModelCapability,
type ProviderConfig,
@ -112,6 +113,12 @@ import { IAgentPromptService } from '#/prompt/prompt';
import { AgentGoalService, IAgentGoalService, type GoalServiceOptions } from '#/goal';
import { IAgentPlanService } from '#/plan';
import { ISessionQuestionService, type QuestionResult } from '#/question/question';
import {
ISessionInteractionService,
type Interaction,
type InteractionRequest,
type InteractionResolution,
} from '#/interaction';
import {
IAgentReplayBuilderService,
AgentReplayBuilderService,
@ -258,15 +265,6 @@ type RpcPromise<T> = Promise<T> & {
reject(reason?: unknown): void;
};
interface UserToolExecutionRequest {
readonly turnId?: string | number;
readonly signal: AbortSignal;
readonly toolCallId: string;
readonly args: unknown;
}
type UserToolExecutionHandler = (request: UserToolExecutionRequest) => Promise<ToolResult>;
type PromiseAgentAPI = PromisifyMethods<AgentAPI>;
type GenerateFn = typeof kosongGenerate;
@ -274,6 +272,12 @@ type TestToolResult = ToolResult & {
readonly content?: unknown;
};
interface UserToolInteractionPayload {
readonly turnId?: number;
readonly toolCallId: string;
readonly args: unknown;
}
interface ResumeStateSnapshot {
readonly background: ReturnType<IAgentBackgroundService['list']>;
readonly config: {
@ -883,6 +887,7 @@ export class AgentTestContext {
sessionDir: `${bootstrap.sessionsDir}/test-workspace/${sessionId}`,
metaScope: `sessions/test-workspace/${sessionId}/session-meta`,
});
reg.defineInstance(ISessionInteractionService, this.createInteractionService());
reg.defineInstance(ISessionApprovalService, this.createApprovalService());
reg.defineInstance(ISessionQuestionService, this.createQuestionService());
reg.defineInstance(IKaos, createIKaos(kaos));
@ -930,13 +935,7 @@ export class AgentTestContext {
reg.defineDescriptor(IAgentReplayBuilderService, new SyncDescriptor(AgentReplayBuilderService, [{}]));
reg.defineDescriptor(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}]));
reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService));
reg.defineDescriptor(
IAgentUserToolService,
new SyncDescriptor(AgentUserToolService, [{
execute: (request: Parameters<UserToolExecutionHandler>[0]) =>
this.executeUserTool(request),
}]),
);
reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService));
reg.defineDescriptor(
ISessionSubagentHost,
new SyncDescriptor(SessionSubagentHostService, [unavailableSubagentHost()]),
@ -1456,6 +1455,73 @@ export class AgentTestContext {
this.snapshots.respondPending(method, id, result);
}
private createInteractionService(): ISessionInteractionService {
const pending = new Map<string, Interaction>();
function createTestInteraction<TPayload>(
request: InteractionRequest<TPayload>,
): Interaction<TPayload> {
return {
id: request.id ?? 'interaction:test',
kind: request.kind,
payload: request.payload,
origin: request.origin ?? {},
createdAt: Date.now(),
};
}
return {
_serviceBrand: undefined,
request: <TPayload, TResponse>(request: InteractionRequest<TPayload>) => {
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<ToolResult>();
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<TResponse>;
},
enqueue: <TPayload>(request: InteractionRequest<TPayload>): Interaction<TPayload> => {
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<void>,
onDidResolve: Event.None as Event<InteractionResolution>,
};
}
private createApprovalService(): ISessionApprovalService {
return {
_serviceBrand: undefined,
@ -1502,21 +1568,6 @@ export class AgentTestContext {
};
}
private executeUserTool: UserToolExecutionHandler = (request) => {
const turnId = Number(request.turnId);
const promise = this.createRpcPromise<ToolResult>(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);
@ -1793,19 +1844,35 @@ function resumeStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot {
function normalizeBackgroundSnapshot(
background: readonly BackgroundTaskInfo[],
): readonly BackgroundTaskInfo[] {
return background.toSorted(
(left, right) => left.startedAt - right.startedAt || left.taskId.localeCompare(right.taskId),
);
return background
.map((task) => stripUndefinedFields(task) as BackgroundTaskInfo)
.toSorted(
(left, right) => left.startedAt - right.startedAt || left.taskId.localeCompare(right.taskId),
);
}
function stripUndefinedFields<T extends object>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(([, nested]) => nested !== undefined),
) as T;
}
function resumeContextSnapshot(ctx: AgentTestContext) {
const context = ctx.contextData();
return {
...context,
history: context.history.filter((message) => !isSystemReminderMessage(message)),
history: context.history
.filter((message) => !isSystemReminderMessage(message))
.map(stripMessageId),
};
}
function stripMessageId(message: ContextMessage): ContextMessage {
if (message.id === undefined) return message;
const { id: _id, ...rest } = message;
return rest as ContextMessage;
}
function isSystemReminderMessage(message: ContextMessage): boolean {
if (message.role !== 'user') return false;
const text = message.content
@ -1884,19 +1951,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,
};
}
@ -1948,6 +2028,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<string, unknown> {
return value !== null && typeof value === 'object'
? { ...(value as Record<string, unknown>) }
@ -2055,11 +2142,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<ProviderConfig, { type: 'kimi' }>,
private readonly generateFn: GenerateFn,
) {
super(config);
}
override async generate(
systemPrompt: string,
tools: KosongTool[],
history: KosongMessage[],
options?: GenerateOptions,
): Promise<StreamedMessage> {
return generateBackedResponse(
this,
this.generateFn,
systemPrompt,
tools,
history,
options,
);
}
}
class GenerateBackedChatProvider implements ChatProvider {
readonly name: string;
readonly modelName: string;
@ -2080,32 +2195,13 @@ class GenerateBackedChatProvider implements ChatProvider {
history: KosongMessage[],
options?: GenerateOptions,
): Promise<StreamedMessage> {
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,
);
}
@ -2131,6 +2227,43 @@ class GenerateBackedChatProvider implements ChatProvider {
}
}
async function generateBackedResponse(
provider: ChatProvider,
generateFn: GenerateFn,
systemPrompt: string,
tools: KosongTool[],
history: KosongMessage[],
options?: GenerateOptions,
): Promise<StreamedMessage> {
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<string, unknown> {
return {
model: modelNameFromConfig(config),

View file

@ -45,18 +45,25 @@ export interface GenerateInputsSnapshot {
export function eventSnapshot(
events: readonly EventSnapshotEntry[],
uuidLabels: Map<string, string>,
labels: SnapshotLabels,
) {
const normalized = events.map((event) => normalizeValue(event, uuidLabels));
const normalized = events.map((event) => normalizeValue(event, labels));
(normalized as unknown as Record<symbol, true>)[IS_EVENT_ARRAY] = true;
return normalized;
}
export function createEventSnapshotter() {
const uuidLabels = new Map<string, string>();
interface SnapshotLabels {
readonly uuidLabels: Map<string, string>;
readonly msgLabels: Map<string, string>;
}
return (events: readonly EventSnapshotEntry[]): EventSnapshot =>
eventSnapshot(events, uuidLabels);
export function createEventSnapshotter() {
const labels: SnapshotLabels = {
uuidLabels: new Map<string, string>(),
msgLabels: new Map<string, string>(),
};
return (events: readonly EventSnapshotEntry[]): EventSnapshot => eventSnapshot(events, labels);
}
export function generateInputSnapshot(
@ -248,49 +255,38 @@ function isDeepEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function normalizeValue(value: unknown, uuidLabels: Map<string, string>): unknown {
function normalizeValue(value: unknown, labels: SnapshotLabels): unknown {
if (typeof value === 'string') {
if (isAutoModeEnterReminder(value)) return '<auto-mode-enter-reminder>';
if (isAutoModeExitReminder(value)) return '<auto-mode-exit-reminder>';
if (isPlanModeReminder(value)) return '<plan-mode-reminder>';
if (!isUuid(value)) return value;
let label = uuidLabels.get(value);
if (label === undefined) {
label = `<uuid-${String(uuidLabels.size + 1)}>`;
uuidLabels.set(value, label);
}
return label;
if (isUuid(value)) return labelFor(value, labels.uuidLabels, 'uuid');
if (isMessageId(value)) return labelFor(value, labels.msgLabels, 'msg');
return value;
}
if (Array.isArray(value)) {
return value.map((item) => normalizeValue(item, uuidLabels));
return value.map((item) => normalizeValue(item, labels));
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !isVolatileDurationKey(key))
.map(([key, nested]) => [
key,
normalizeObjectField(key, nested, uuidLabels),
]),
.map(([key, nested]) => [key, normalizeObjectField(key, nested, labels)]),
);
}
return value;
}
function normalizeObjectField(
key: string,
value: unknown,
uuidLabels: Map<string, string>,
): unknown {
function normalizeObjectField(key: string, value: unknown, labels: SnapshotLabels): unknown {
if ((key === 'time' || key === 'created_at') && typeof value === 'number') return '<time>';
if (key === 'protocol_version' && value === AGENT_WIRE_PROTOCOL_VERSION) {
return '<protocol-version>';
}
if (key === 'cwd' && typeof value === 'string') return '<cwd>';
return normalizeValue(value, uuidLabels);
return normalizeValue(value, labels);
}
function stripUndefined(value: unknown): unknown {
@ -313,6 +309,19 @@ function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function isMessageId(value: string): boolean {
return /^msg_[0-9A-Z]{26}$/.test(value);
}
function labelFor(value: string, labels: Map<string, string>, kind: string): string {
let label = labels.get(value);
if (label === undefined) {
label = `<${kind}-${String(labels.size + 1)}>`;
labels.set(value, label);
}
return label;
}
function isVolatileDurationKey(key: string): boolean {
return (
key === 'llmFirstTokenLatencyMs' ||

View file

@ -159,14 +159,17 @@ describe('LLMRequester service migration coverage', () => {
});
expect(events).toContainEqual({
type: 'finish',
id: 'response-1',
providerFinishReason: 'completed',
rawFinishReason: 'stop',
});
expect(events).toContainEqual({
type: 'timing',
firstTokenLatencyMs: expect.any(Number),
streamDurationMs: expect.any(Number),
});
expect(events).toContainEqual(
expect.objectContaining({
type: 'timing',
firstTokenLatencyMs: expect.any(Number),
streamDurationMs: expect.any(Number),
}),
);
});
it('applies a per-request output budget override', async () => {

View file

@ -48,18 +48,20 @@ describe('Agent loop', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] tools.set_active_tools { "names": [], "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] thinking.delta { "turnId": 0, "delta": "<think-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "<text-1>" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "think", "think": "<think-1>" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "think", "think": "<think-1>" }, { "type": "text", "text": "<text-1>" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "think", "think": "<think-1>" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "think", "think": "<think-1>" }, { "type": "text", "text": "<text-1>" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 2, "tokens": 11, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 11 }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "think", "think": "<think-1>" }, { "type": "text", "text": "<text-1>" } ], "toolCalls": [], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
@ -80,16 +82,18 @@ describe('Agent loop', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "blocked" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "blocked" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "blocked" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 2, "tokens": 8, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 8 }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "blocked" } ], "toolCalls": [], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 3, "output": 5, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "filtered", "providerFinishReason": "filtered", "rawFinishReason": "content_filter" }
[emit] turn.ended { "turnId": 0, "reason": "filtered" }
`);
@ -139,15 +143,15 @@ describe('Agent loop', () => {
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is lookup-result.' });
expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(`
[wire] tools.set_active_tools { "names": [ "Lookup" ], "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will look it up." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"moon\\"}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_lookup", "toolName": "Lookup", "action": "Approve Lookup", "display": { "kind": "generic", "summary": "Approve Lookup", "detail": { "query": "moon" } } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
@ -159,20 +163,24 @@ describe('Agent loop', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_lookup", "toolName": "Lookup", "action": "Approve Lookup", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"moon\\"}" } ] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"moon\\"}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 2, "tokens": 20, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 20 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "args": { "query": "moon" } }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "lookup-result" } ], "toolCalls": [], "toolCallId": "call_lookup" } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "lookup-result" } ], "toolCalls": [], "toolCallId": "call_lookup", "id": "<msg-3>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_lookup", "output": "lookup-result" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"moon\\"}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 4, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "The lookup result is lookup-result." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 25, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 29, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 29, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 29, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "The lookup result is lookup-result." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "id": "<msg-4>", "role": "assistant", "content": [ { "type": "text", "text": "The lookup result is lookup-result." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 4, "tokens": 37, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 37 }
[wire] context.splice { "start": 3, "deleteCount": 1, "messages": [ { "id": "<msg-4>", "role": "assistant", "content": [ { "type": "text", "text": "The lookup result is lookup-result." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 25, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);

View file

@ -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 { IAgentToolExecutorService } 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<LLMChatResponse> {
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<LoopRecordedEvent, { type: 'step.begin' }> }
| { kind: 'appendStepEnd'; input: Extract<LoopRecordedEvent, { type: 'step.end' }> }
| { kind: 'appendContentPart'; input: Extract<LoopRecordedEvent, { type: 'content.part' }> }
| { kind: 'appendToolCall'; input: Extract<LoopRecordedEvent, { type: 'tool.call' }> }
| { kind: 'appendToolResult'; input: Extract<LoopRecordedEvent, { type: 'tool.result' }> };
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<void> => {
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<K extends AppendCall['kind']>(kind: K): Extract<AppendCall, { kind: K }>[] {
return this.calls.filter((call): call is Extract<AppendCall, { kind: K }> => call.kind === kind);
}
stepBegins(): Array<Extract<LoopRecordedEvent, { type: 'step.begin' }>> {
return this.ofKind('appendStepBegin').map((call) => call.input);
}
stepEnds(): Array<Extract<LoopRecordedEvent, { type: 'step.end' }>> {
return this.ofKind('appendStepEnd').map((call) => call.input);
}
contentParts(): Array<Extract<LoopRecordedEvent, { type: 'content.part' }>> {
return this.ofKind('appendContentPart').map((call) => call.input);
}
toolCalls(): Array<Extract<LoopRecordedEvent, { type: 'tool.call' }>> {
return this.ofKind('appendToolCall').map((call) => call.input);
}
toolResults(): Array<Extract<LoopRecordedEvent, { type: 'tool.result' }>> {
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<T extends LoopEvent['type']>(type: T): Array<Extract<LoopEvent, { type: T }>> {
return this.events.filter((event): event is Extract<LoopEvent, { type: T }> => 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?: IAgentToolExecutorService | undefined;
}
export interface RunTurnResult {
readonly result: TurnResult;
readonly llm: FakeLLM;
readonly context: RecordingContext;
readonly sink: CollectingSink;
readonly toolExecutor: IAgentToolExecutorService;
}
export async function runTurn(opts: RunTurnOptions): Promise<RunTurnResult> {
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<TokenUsage> = {}): FakeLLMResponse {
return {
toolCalls: [],
providerFinishReason: 'completed',
usage: zeroUsage(usage),
contentParts: makeTextParts(text),
};
}
export function makeMaxTokensResponse(text: string, usage: Partial<TokenUsage> = {}): FakeLLMResponse {
return {
toolCalls: [],
providerFinishReason: 'truncated',
usage: zeroUsage(usage),
contentParts: makeTextParts(text),
};
}
export function makeToolUseResponse(toolCalls: ToolCall[], usage: Partial<TokenUsage> = {}): FakeLLMResponse {
return {
toolCalls,
providerFinishReason: 'tool_calls',
usage: zeroUsage(usage),
};
}
export function makeResponse(
contentParts: readonly FakeOutputPart[],
toolCalls: ToolCall[],
stopReason: LoopStepStopReason,
usage: Partial<TokenUsage> = {},
): FakeLLMResponse {
return {
contentParts,
toolCalls,
providerFinishReason: providerFinishReasonForStopReason(stopReason),
usage: zeroUsage(usage),
};
}
export function zeroUsage(partial: Partial<TokenUsage> = {}): 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<ExecutableToolResult> => {
this.calls.push({ id: ctx.toolCallId, args, turnId: ctx.turnId });
return { output: args.text };
},
};
}
}
export class ControlledTool implements ExecutableTool<Record<string, unknown>> {
readonly description = 'Controlled test tool.';
readonly parameters = { type: 'object', additionalProperties: true };
readonly calls: Array<{ readonly id: string; readonly args: Record<string, unknown>; readonly signal: AbortSignal }> = [];
readonly started: Promise<void>;
private resolveStarted: () => void = () => { };
private resolveResult: (value: ExecutableToolResult) => void = () => { };
private rejectResult: (error: unknown) => void = () => { };
private readonly result: Promise<ExecutableToolResult>;
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<string, unknown>): ToolExecution {
return {
approvalRule: this.name,
accesses: this.accesses,
execute: async (ctx): Promise<ExecutableToolResult> => {
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 IAgentToolExecutorService {
declare readonly _serviceBrand: undefined;
readonly hooks = {
onWillExecuteTool: new OrderedHookSlot<ToolWillExecuteContext>(),
onDidExecuteTool: new OrderedHookSlot<ToolDidExecuteContext>(),
};
private readonly tools: Map<string, ExecutableTool>;
constructor(tools: readonly ExecutableTool[]) {
this.tools = new Map(tools.map((tool) => [tool.name, tool]));
}
async execute(calls: ToolCall[], options: Parameters<IAgentToolExecutorService['execute']>[1] = {}): Promise<ToolResult[]> {
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';
}
}

View file

@ -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<typeof chatWithRetry>[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<LLMChatResponse> {
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<LLMChatResponse> {
calls += 1;
throw new APIConnectionError('terminated');
},
};
await expect(chatWithRetry(makeInput(llm, controller.signal))).rejects.toMatchObject({
name: 'AbortError',
});
expect(calls).toBe(1);
});
});

View file

@ -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);
});
});

View file

@ -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',

View file

@ -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('AgentPlanService 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<PlanData>;
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<string, unknown>;
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();

View file

@ -530,31 +530,35 @@ describe('Plan service', () => {
[emit] agent.status.updated { "permission": "yolo" }
[wire] plan_mode.enter { "id": "test-plan", "time": "<time>" }
[emit] agent.status.updated { "planMode": true }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ], "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" }, "id": "<msg-2>" } ], "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will inspect safely." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 530, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 530, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 530, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 530, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will inspect safely." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will inspect safely." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will inspect safely." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will inspect safely." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 3, "tokens": 553, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 553 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "plan-safe" } }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "plan-safe" } ], "toolCalls": [], "toolCallId": "call_bash" } ], "time": "<time>" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "plan-safe" } ], "toolCalls": [], "toolCallId": "call_bash", "id": "<msg-4>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "plan-safe" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will inspect safely." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 530, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "The safe command printed plan-safe." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 557, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 1087, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1087, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 1087, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "The safe command printed plan-safe." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "The safe command printed plan-safe." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 5, "tokens": 569, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 569 }
[wire] context.splice { "start": 4, "deleteCount": 1, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "The safe command printed plan-safe." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 557, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
@ -590,31 +594,35 @@ describe('Plan service', () => {
[emit] agent.status.updated { "permission": "yolo" }
[wire] plan_mode.enter { "id": "test-plan", "time": "<time>" }
[emit] agent.status.updated { "planMode": true }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ], "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" }, "id": "<msg-2>" } ], "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will mutate a file." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 527, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 527, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 527, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 527, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will mutate a file." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will mutate a file." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will mutate a file." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will mutate a file." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 3, "tokens": 550, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 550 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "removed" } }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "removed" } ], "toolCalls": [], "toolCallId": "call_bash" } ], "time": "<time>" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "removed" } ], "toolCalls": [], "toolCallId": "call_bash", "id": "<msg-4>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "removed" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will mutate a file." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 527, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "The command completed." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 553, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 1080, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1080, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 1080, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "The command completed." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "The command completed." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 5, "tokens": 562, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 562 }
[wire] context.splice { "start": 4, "deleteCount": 1, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "The command completed." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 553, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);

View file

@ -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: IAgentProfileService;
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(IAgentProfileService);
});
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(IAgentProfileService);
}
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' });

View file

@ -122,7 +122,7 @@ describe('AgentPromptLegacyService', () => {
const { service, turns } = createHarness();
const result = await service.submit(textBody('hi'));
expect(result.status).toBe('running');
expect(result.prompt_id).toMatch(/^prompt_/);
expect(result.prompt_id).toMatch(/^msg_/);
expect(turns).toHaveLength(1);
expect(service.list().active?.prompt_id).toBe(result.prompt_id);
});

View file

@ -14,6 +14,9 @@ import { ISessionLifecycleService } from '#/session-lifecycle/sessionLifecycle';
import { SessionLifecycleService } from '#/session-lifecycle/sessionLifecycleService';
import { ISessionMetadata } from '#/session-metadata';
import { ISessionSkillCatalog } from '#/skill';
import { ISessionIndex } from '#/session-index';
import { IAppendLogStore, IAtomicDocumentStore } from '#/storage';
import { IWorkspaceRegistry, type Workspace } from '#/workspaceRegistry';
function bootstrapStub(): IBootstrapService {
return {
@ -73,6 +76,57 @@ function skillCatalogStub(): ISessionSkillCatalog {
};
}
function workspaceRegistryStub(): IWorkspaceRegistry {
return {
_serviceBrand: undefined,
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
createOrTouch: (root, name) =>
Promise.resolve<Workspace>({
id: 'wd_stub',
root,
name: name ?? 'stub',
createdAt: 0,
lastOpenedAt: 0,
}),
update: () => Promise.resolve(undefined),
delete: () => Promise.resolve(),
};
}
function sessionIndexStub(): ISessionIndex {
return {
_serviceBrand: undefined,
list: () => Promise.resolve({ items: [], total: 0, hasMore: false }),
get: () => Promise.resolve(undefined),
countActive: () => Promise.resolve(0),
};
}
function appendLogStoreStub(): IAppendLogStore {
return {
_serviceBrand: undefined,
append: () => {},
read: async function* () {},
rewrite: () => Promise.resolve(),
flush: () => Promise.resolve(),
close: () => Promise.resolve(),
acquire: () => ({ dispose: () => {} }),
};
}
function atomicDocumentStoreStub(): IAtomicDocumentStore {
return {
_serviceBrand: undefined,
get: () => Promise.resolve(undefined),
set: () => Promise.resolve(),
delete: () => Promise.resolve(),
list: () => Promise.resolve([]),
watch: () => (_listener) => ({ dispose: () => {} }),
acquire: () => ({ dispose: () => {} }),
};
}
describe('SessionLifecycleService', () => {
let host: ScopedTestHost | undefined;
@ -98,6 +152,10 @@ describe('SessionLifecycleService', () => {
stubPair(ISessionMetadata, metadataStub()),
stubPair(IKaosFactory, kaosFactoryStub()),
stubPair(ISessionSkillCatalog, skillCatalogStub()),
stubPair(IWorkspaceRegistry, workspaceRegistryStub()),
stubPair(ISessionIndex, sessionIndexStub()),
stubPair(IAppendLogStore, appendLogStoreStub()),
stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()),
...extra,
]);
return host.app.accessor.get(ISessionLifecycleService);

View file

@ -1235,7 +1235,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(() => {
@ -1306,7 +1306,7 @@ describe('BashTool background mode', () => {
runner,
createTestKaos(),
createFakeBackgroundService().service,
{ allowBackground: false },
{ allowBackground: () => false },
);
const unavailable = await executeTool(
@ -1582,7 +1582,7 @@ describe('BashTool prompt / runtime consistency', () => {
);
const tool = bashTool(runner, createTestKaos(), createFakeBackgroundService().service, {
allowBackground: false,
allowBackground: () => false,
});
const result = await executeTool(
tool,

View file

@ -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.');
});
});

View file

@ -4,6 +4,7 @@ import type { IAgentBackgroundService } from '#/background';
import type { IDisposable } from '#/_base/di';
import type { IKaos } from '#/kaos';
import type { ISessionProcessRunner } from '#/process';
import type { IAgentProfileService } from '#/profile';
import { AgentShellToolsService } from '#/shellTools';
import type { IAgentToolRegistryService } from '#/toolRegistry';
@ -27,11 +28,14 @@ const fakeKaos = {
pathClass: () => 'posix',
} as unknown as IKaos;
const fakeBackground = {} as unknown as IAgentBackgroundService;
const fakeProfile = {
isToolActive: () => true,
} as unknown as IAgentProfileService;
describe('AgentShellToolsService', () => {
it('registers Bash into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
new AgentShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground);
new AgentShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground, fakeProfile);
expect(names()).toEqual(['Bash']);
});
});

View file

@ -73,11 +73,13 @@ describe('ToolManager SkillTool registration', () => {
}
});
it('does not expose Skill when the agent has no skill registry', () => {
it('exposes Skill even when the agent has no registered skills', () => {
profile.update({ activeToolNames: ['Skill'] });
expect(ctx.toolsData().find((tool) => tool.name === 'Skill')).toBeUndefined();
expect(tools.resolve('Skill')).toBeUndefined();
expect(ctx.toolsData().find((tool) => tool.name === 'Skill')).toMatchObject({
name: 'Skill',
});
expect(tools.resolve('Skill')).toMatchObject({ name: 'Skill' });
});
});
@ -103,11 +105,13 @@ describe('ToolManager SkillTool registration with an empty model skill catalog',
}
});
it('does not expose Skill when there are no model-invocable skills', () => {
it('exposes Skill even when there are no model-invocable skills', () => {
profile.update({ activeToolNames: ['Skill'] });
expect(ctx.toolsData().find((tool) => tool.name === 'Skill')).toBeUndefined();
expect(tools.resolve('Skill')).toBeUndefined();
expect(ctx.toolsData().find((tool) => tool.name === 'Skill')).toMatchObject({
name: 'Skill',
});
expect(tools.resolve('Skill')).toMatchObject({ name: 'Skill' });
});
});

View file

@ -8,11 +8,18 @@ import { IAgentEventSinkService } from '#/eventSink';
import { IAgentPromptService } from '#/prompt';
import { IAgentSkillService, InMemorySkillCatalog, ISessionSkillCatalog } 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 { IAgentToolRegistryService } from '#/toolRegistry';
import type { Turn } from '#/turn';
import { IAgentWireRecordService } 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: ISessionSkillCatalog = {
_serviceBrand: undefined,
@ -128,3 +139,187 @@ 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(IAgentPromptService, {
prompt: (message: ContextMessage) => {
prompted.push(message);
return fakeTurn();
},
steer: (message: ContextMessage) => {
prompted.push(message);
return undefined;
},
retry: () => undefined,
undo: () => 0,
clear: () => {},
});
reg.definePartialInstance(IAgentEventSinkService, {
emit: () => {},
on: () => ({ dispose: () => {} }),
});
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.definePartialInstance(ITelemetryService, { track: () => {} });
reg.definePartialInstance(IAgentToolRegistryService, {
register: () => ({ dispose: () => {} }),
});
},
});
skills = new InMemorySkillCatalog();
skills.register(COMMIT_SKILL);
ix.set(ISessionSkillCatalog, {
_serviceBrand: undefined,
catalog: skills,
ready: Promise.resolve(),
load: async () => {},
reload: async () => {},
} satisfies ISessionSkillCatalog);
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(
'<kimi-skill-loaded name="commit" trigger="model-tool" source="user" dir="/skills/commit" args="src/app.ts">',
),
});
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);
});
});

View file

@ -34,7 +34,11 @@ interface OnceAnyWaiter {
interface TakeWaiter {
readonly event: string;
readonly start: number;
readonly resolve: (value: { event: RecordedEventEntry; events: EventSnapshot; respond(result: unknown): void }) => void;
readonly resolve: (value: {
event: RecordedEventEntry;
events: EventSnapshot;
respond(result: unknown): void;
}) => void;
}
export function recordAgentEvents() {
@ -147,13 +151,20 @@ export function recordAgentEvents() {
},
respondPending(method: string, id: string, result: unknown): void {
const entry = entries.find(
(candidate) =>
candidate.type === '[rpc]' &&
candidate.event === method &&
(candidate.args as { readonly id?: unknown } | null)?.id === id &&
candidate.response !== undefined,
);
const entry = entries.find((candidate) => {
if (
candidate.type !== '[rpc]' ||
candidate.event !== method ||
candidate.response === undefined
) {
return false;
}
const args = candidate.args as {
readonly id?: unknown;
readonly toolCallId?: unknown;
} | null;
return args?.id === id || args?.toolCallId === id;
});
entry?.response?.resolve(result);
},
};

View file

@ -1,7 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { userCancellationReason } from '#/_base/utils/abort';
import { IAgentBackgroundService } from '#/background';
import type { ILogger, LogPayload } from '#/log';
import { IAgentProfileService } from '#/profile';
import type { SessionSubagentHost } from '#/subagentHost';
import {
AgentTool,
AgentToolInputSchema,
DEFAULT_SUBAGENT_TIMEOUT_MS,
type ISessionSubagentHost,
type SessionSubagentHost,
} from '#/subagentHost';
import type { AgentToolSubagentMap } from '#/subagentHost/agentTool';
import { ToolAccesses } from '#/tool';
import { IAgentToolRegistryService } 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<Input>(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: IAgentBackgroundService;
readonly host: SessionSubagentHost;
readonly tool: AgentTool;
} {
const ctx =
maxRunningTasks === undefined
? createTestAgent()
: createTestAgent({
initialConfig: { background: { maxRunningTasks } },
});
contexts.push(ctx);
const background = ctx.get(IAgentBackgroundService);
return {
ctx,
background,
host,
tool: new AgentTool(host as unknown as ISessionSubagentHost, 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<string, unknown> }).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<string, { description?: string }>;
}
).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;

View file

@ -1,22 +1,58 @@
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 { IAgentContextMemoryService } from '#/contextMemory';
import { IAgentEventSinkService } from '../../src/eventSink';
import { ISessionSubagentHost } from '#/subagentHost';
import {
DEFAULT_SUBAGENT_TIMEOUT_MS,
ISessionSubagentHost,
type QueuedSubagentRunResult,
type QueuedSubagentTask,
} from '#/subagentHost';
import { IAgentSystemReminderService } from '#/systemReminder';
import { AgentSystemReminderService } from '#/systemReminder/systemReminderService';
import { IAgentSwarmService } from '#/swarm';
import { AgentSwarmService } from '#/swarm/swarmService';
import { AgentSwarmTool, AgentSwarmToolInputSchema } from '#/swarm/tools/agent-swarm';
import type { ExecutableToolContext } from '#/tool';
import { IAgentToolRegistryService, AgentToolRegistryService } from '#/toolRegistry';
import { IAgentTurnService } from '#/turn';
import { IAgentWireRecordService } 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<Input>(
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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly runQueued?: (...args: any[]) => any;
} = {}) {
return {
getSwarmItem: vi.fn(getSwarmItem),
runQueued,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
function mockSwarmMode() {
return { enter: vi.fn() };
}
describe('AgentSwarmService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
@ -44,3 +80,502 @@ describe('AgentSwarmService', () => {
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<string, unknown>).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(
[
'<agent_swarm_result>',
'<summary>completed: 2</summary>',
'<subagent agent_id="agent-explore-1" item="src/a.ts" outcome="completed">explore result a</subagent>',
'<subagent agent_id="agent-explore-2" item="src/b.ts" outcome="completed">explore result b</subagent>',
'</agent_swarm_result>',
].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();
if (execution.isError === true) throw new Error('expected a successful execution');
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 <T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> =>
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(
[
'<agent_swarm_result>',
'<summary>completed: 3</summary>',
'<subagent mode="resume" agent_id="agent-old-1" item="src/old-a.ts" outcome="completed">result 1</subagent>',
'<subagent mode="resume" agent_id="agent-old-2" item="src/old-b.ts" outcome="completed">result 2</subagent>',
'<subagent agent_id="agent-new-3" item="src/new.ts" outcome="completed">result 3</subagent>',
'</agent_swarm_result>',
].join('\n'),
);
expect(result.isError).toBeUndefined();
});
it('allows a single resumed subagent without item subagents', async () => {
const runQueued = vi.fn(
async <T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> =>
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(
[
'<agent_swarm_result>',
'<summary>completed: 1</summary>',
'<subagent mode="resume" agent_id="agent-old-1" item="src/old-a.ts" outcome="completed">resumed result</subagent>',
'</agent_swarm_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(
[
'<agent_swarm_result>',
'<summary>completed: 1, failed: 1</summary>',
'<resume_hint>Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.</resume_hint>',
'<subagent agent_id="agent-coder-1" item="src/a.ts" outcome="completed">imports are stable</subagent>',
'<subagent agent_id="agent-coder-2" item="src/b.ts" outcome="failed">Agent timed out after 30s.</subagent>',
'</agent_swarm_result>',
].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(
[
'<agent_swarm_result>',
'<summary>failed: 2</summary>',
'<subagent item="src/a.ts" outcome="failed">Agent did not start.</subagent>',
'<subagent item="src/b.ts" outcome="failed">Agent also did not start.</subagent>',
'</agent_swarm_result>',
].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(
[
'<agent_swarm_result>',
'<summary>completed: 1, aborted: 2</summary>',
'<resume_hint>Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.</resume_hint>',
'<subagent agent_id="agent-coder-1" item="src/a.ts" outcome="completed">imports are stable</subagent>',
'<subagent agent_id="agent-coder-2" item="src/b.ts" state="started" outcome="aborted">The user manually interrupted this subagent batch before this subagent finished.</subagent>',
'<subagent item="src/c.ts" state="not_started" outcome="aborted">The user manually interrupted this subagent batch before this subagent was started.</subagent>',
'</agent_swarm_result>',
].join('\n'),
);
expect(result.isError).toBeUndefined();
});
});

View file

@ -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');
});
});

View file

@ -436,48 +436,52 @@ describe('Agent tools', () => {
output: 'moon-result',
}),
).toMatchInlineSnapshot(`
[wire] permission.set_mode { "mode": "auto", "time": "<time>" }
[emit] agent.status.updated { "permission": "auto" }
[wire] tools.register_user_tool { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false }, "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } } ], "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will look it up." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"moon\\"}" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"moon\\"}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 3, "tokens": 104, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 104, "maxContextTokens": 1000000, "contextUsage": 0.000104 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "args": { "query": "moon" } }
[emit] toolCall { "turnId": 0, "toolCallId": "call_lookup", "args": { "query": "moon" } }
`);
[wire] permission.set_mode { "mode": "auto", "time": "<time>" }
[emit] agent.status.updated { "permission": "auto" }
[wire] tools.register_user_tool { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false }, "time": "<time>" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" }, "id": "<msg-2>" } ], "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will look it up." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"moon\\"}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"moon\\"}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 3, "tokens": 104, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 104 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "args": { "query": "moon" } }
[emit] toolCall { "turnId": 0, "toolCallId": "call_lookup", "args": { "query": "moon" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AskUserQuestion, Bash, CronCreate, CronDelete, CronList, Edit, FetchURL, GetGoal, Glob, Grep, Lookup, MultiEdit, Read, SetGoalBudget, SetTodoList, Skill, TaskList, TaskOutput, TodoList, UpdateGoal, WebSearch, Write
messages:
user: text "Look up moon"
user: text <auto-mode-enter-reminder>
`);
system: <system-prompt>
tools: Agent, AgentSwarm, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, GetGoal, Glob, Grep, Lookup, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, UpdateGoal, Write
messages:
user: text "Look up moon"
user: text <auto-mode-enter-reminder>
`);
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is moon-result.' });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "moon-result" } ], "toolCalls": [], "toolCallId": "call_lookup" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_lookup", "output": "moon-result" }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] assistant.delta { "turnId": 0, "delta": "The lookup result is moon-result." }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "The lookup result is moon-result." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 5, "tokens": 120, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 120, "maxContextTokens": 1000000, "contextUsage": 0.00012 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "moon-result" } ], "toolCalls": [], "toolCallId": "call_lookup", "id": "<msg-4>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_lookup", "output": "moon-result" }
[wire] context.splice { "start": 2, "deleteCount": 1, "messages": [ { "id": "<msg-3>", "role": "assistant", "content": [ { "type": "text", "text": "I will look it up." } ], "toolCalls": [ { "type": "function", "id": "call_lookup", "name": "Lookup", "arguments": "{\\"query\\":\\"moon\\"}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "The lookup result is moon-result." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "The lookup result is moon-result." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 5, "tokens": 120, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 120 }
[wire] context.splice { "start": 4, "deleteCount": 1, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "The lookup result is moon-result." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
messages:
<last>
@ -489,27 +493,29 @@ describe('Agent tools', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Can you still use Lookup?' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] tools.unregister_user_tool { "name": "Lookup", "time": "<time>" }
[wire] context.splice { "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 1, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 1, "origin": { "kind": "user" } }
[emit] turn.step.started { "turnId": 1, "step": 1, "stepId": "<uuid-3>" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 324, "output": 38, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 324, "output": 38, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] assistant.delta { "turnId": 1, "delta": "No lookup tool is available." }
[wire] context.splice { "start": 6, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "No lookup tool is available." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 7, "tokens": 138, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 138, "maxContextTokens": 1000000, "contextUsage": 0.000138 }
[emit] turn.step.completed { "turnId": 1, "step": 1, "stepId": "<uuid-3>", "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 1, "reason": "completed" }
`);
[wire] tools.unregister_user_tool { "name": "Lookup", "time": "<time>" }
[wire] context.splice { "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "id": "<msg-6>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 1, "origin": { "kind": "user" }, "promptMessageId": "<msg-6>", "time": "<time>" }
[emit] turn.started { "turnId": 1, "origin": { "kind": "user" }, "promptMessageId": "<msg-6>" }
[emit] turn.step.started { "turnId": 1, "step": 1, "stepId": "<uuid-3>" }
[emit] assistant.delta { "turnId": 1, "delta": "No lookup tool is available." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 1 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 324, "output": 38, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 324, "output": 38, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 6, "deleteCount": 0, "messages": [ { "id": "<msg-7>", "role": "assistant", "content": [ { "type": "text", "text": "No lookup tool is available." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 7, "tokens": 138, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 138 }
[wire] context.splice { "start": 6, "deleteCount": 1, "messages": [ { "id": "<msg-7>", "role": "assistant", "content": [ { "type": "text", "text": "No lookup tool is available." } ], "toolCalls": [], "providerMessageId": "mock-3" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 1, "step": 1, "stepId": "<uuid-3>", "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 1, "reason": "completed" }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
tools: Agent, AskUserQuestion, Bash, CronCreate, CronDelete, CronList, Edit, FetchURL, GetGoal, Glob, Grep, MultiEdit, Read, SetGoalBudget, SetTodoList, Skill, TaskList, TaskOutput, TodoList, UpdateGoal, WebSearch, Write
messages:
<last>
assistant: text "The lookup result is moon-result."
user: text "Can you still use Lookup?"
`);
tools: Agent, AgentSwarm, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, UpdateGoal, Write
messages:
<last>
assistant: text "The lookup result is moon-result."
user: text "Can you still use Lookup?"
`);
});
});
});

View file

@ -44,9 +44,13 @@ function okResult(text: string): ToolDedupResult {
}
interface ToolDedupeInternals extends IAgentToolDedupeService {
beginStep(): void;
endStep(): void;
checkSameStep(toolCallId: string, toolName: string, args: unknown): ToolDedupResult | null;
beginStep(): Promise<void>;
endStep(): Promise<void>;
checkSameStep(
toolCallId: string,
toolName: string,
args: unknown,
): Promise<ToolDedupResult | null>;
finalizeResult(
toolCallId: string,
toolName: string,
@ -71,7 +75,7 @@ async function runOriginal(
args: unknown,
result: ToolDedupResult,
): Promise<ToolDedupResult> {
const cached = deduper.checkSameStep(callId, tool, args);
const cached = await deduper.checkSameStep(callId, tool, args);
expect(cached).toBeNull();
return deduper.finalizeResult(callId, tool, args, result);
}
@ -80,9 +84,9 @@ describe('AgentToolDedupeService', () => {
describe('same-step dedup', () => {
it('returns a placeholder synchronously and resolves to the real result on finalize', async () => {
const dedup = createDeduper();
dedup.beginStep();
await dedup.beginStep();
const original = await runOriginal(dedup, 'c1', 'Read', { path: '/a' }, okResult('FILE_A'));
const cached = dedup.checkSameStep('c2', 'Read', { path: '/a' });
const cached = await dedup.checkSameStep('c2', 'Read', { path: '/a' });
// Same-step dup gets a synthetic placeholder (non-error, empty string).
expect(cached).not.toBeNull();
expect(cached!.isError).toBeUndefined();
@ -93,9 +97,9 @@ describe('AgentToolDedupeService', () => {
it('propagates error results to same-step dups', async () => {
const dedup = createDeduper();
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, 'c1', 'Bash', { cmd: 'x' }, errResult('boom'));
const cached = dedup.checkSameStep('c2', 'Bash', { cmd: 'x' });
const cached = await dedup.checkSameStep('c2', 'Bash', { cmd: 'x' });
expect(cached).not.toBeNull();
const finalDup = await dedup.finalizeResult('c2', 'Bash', { cmd: 'x' }, cached!);
expect(finalDup).toEqual(errResult('boom'));
@ -105,10 +109,10 @@ describe('AgentToolDedupeService', () => {
// The loop guarantees finalize runs in provider order, so by the time a
// dup's finalize runs, the original's deferred is already resolved.
const dedup = createDeduper();
dedup.beginStep();
const origCached = dedup.checkSameStep('c1', 'Read', { path: '/a' });
await dedup.beginStep();
const origCached = await dedup.checkSameStep('c1', 'Read', { path: '/a' });
expect(origCached).toBeNull();
const dupCached = dedup.checkSameStep('c2', 'Read', { path: '/a' });
const dupCached = await dedup.checkSameStep('c2', 'Read', { path: '/a' });
expect(dupCached).not.toBeNull();
// Finalize in provider order: c1 first, then c2.
const origFinal = await dedup.finalizeResult('c1', 'Read', { path: '/a' }, okResult('A'));
@ -123,9 +127,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(typeof last!.output).toBe('string');
expect(last!.output as string).not.toContain('<system-reminder>');
@ -135,9 +139,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < 3; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('repeating the exact same tool call');
@ -148,9 +152,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < 4; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('repeating the exact same tool call');
@ -160,9 +164,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < 5; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('repeated_times: 5');
@ -174,9 +178,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < streak; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain(`repeated_times: ${String(streak)}`);
@ -187,9 +191,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < 8; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(last!.output as string).toContain('<system-reminder>');
expect(last!.output as string).toContain('stuck in a dead end');
@ -199,18 +203,18 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
// 2× Read({p:1}) — should NOT trigger yet
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `a${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
// 1× Read({p:2}) interrupts the streak
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, 'b1', 'Read', { p: 2 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
// Back to Read({p:1}); streak restarts → 1 occurrence, no reminder
dedup.beginStep();
await dedup.beginStep();
const last = await runOriginal(dedup, 'c1', 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
expect(last.output as string).not.toContain('<system-reminder>');
});
@ -218,13 +222,13 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
// Build streak up to 2 across previous steps.
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `p${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
// Next step: same call appears twice. First is the original (triggers reminder1 at streak=3),
// second is a same-step dup that should inherit it.
dedup.beginStep();
await dedup.beginStep();
const original = await runOriginal(
dedup,
'orig',
@ -232,10 +236,10 @@ describe('AgentToolDedupeService', () => {
{ p: 1 },
okResult('R'),
);
const dupCached = dedup.checkSameStep('dup', 'Read', { p: 1 });
const dupCached = await dedup.checkSameStep('dup', 'Read', { p: 1 });
expect(dupCached).not.toBeNull();
const finalDup = await dedup.finalizeResult('dup', 'Read', { p: 1 }, dupCached!);
dedup.endStep();
await dedup.endStep();
expect(original.output as string).toContain('<system-reminder>');
expect(original.output as string).toContain('repeating the exact same tool call');
@ -248,11 +252,11 @@ describe('AgentToolDedupeService', () => {
// 8 occurrences of the same call within a single step, but no prior
// streak — the trigger is about sustained behaviour across steps, not
// intra-step spam. Same-step dedup already short-circuits execution.
dedup.beginStep();
const cached = dedup.checkSameStep('orig', 'Read', { p: 1 });
await dedup.beginStep();
const cached = await dedup.checkSameStep('orig', 'Read', { p: 1 });
expect(cached).toBeNull();
for (let i = 0; i < 7; i += 1) {
dedup.checkSameStep(`dup${String(i)}`, 'Read', { p: 1 });
await dedup.checkSameStep(`dup${String(i)}`, 'Read', { p: 1 });
}
const final = await dedup.finalizeResult('orig', 'Read', { p: 1 }, okResult('R'));
expect(final.output as string).not.toContain('<system-reminder>');
@ -267,13 +271,13 @@ describe('AgentToolDedupeService', () => {
};
// Build streak up to 2 prior steps then this one (streak=3).
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `p${String(i)}`, 'X', {}, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
dedup.beginStep();
await dedup.beginStep();
const final = await runOriginal(dedup, 'final', 'X', {}, arrayResult);
dedup.endStep();
await dedup.endStep();
const arr = final.output as Array<{ type: string; text: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.type).toBe('text');
@ -287,13 +291,13 @@ describe('AgentToolDedupeService', () => {
};
// Build streak up to 4 prior steps then this one (streak=5).
for (let i = 0; i < 4; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `p${String(i)}`, 'X', { a: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
dedup.beginStep();
await dedup.beginStep();
const final = await runOriginal(dedup, 'final', 'X', { a: 1 }, arrayResult);
dedup.endStep();
await dedup.endStep();
const arr = final.output as Array<{ type: string; text: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.type).toBe('text');
@ -307,13 +311,13 @@ describe('AgentToolDedupeService', () => {
};
// Build streak to 3.
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `p${String(i)}`, 'X', {}, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
dedup.beginStep();
await dedup.beginStep();
const final = await runOriginal(dedup, 'final', 'X', {}, arrayResult);
dedup.endStep();
await dedup.endStep();
const arr = final.output as Array<{ type: string; text?: string }>;
expect(arr).toHaveLength(2);
expect(arr[0]!.type).toBe('image_url');
@ -325,13 +329,13 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
// Build streak to 3.
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `p${String(i)}`, 'X', {}, errResult('boom'));
dedup.endStep();
await dedup.endStep();
}
dedup.beginStep();
await dedup.beginStep();
const final = await runOriginal(dedup, 'final', 'X', {}, errResult('boom'));
dedup.endStep();
await dedup.endStep();
expect(final.isError).toBe(true);
expect(final.output as string).toContain('<system-reminder>');
});
@ -340,9 +344,9 @@ describe('AgentToolDedupeService', () => {
describe('key canonicalization', () => {
it('treats argument objects with different key order as the same call', async () => {
const dedup = createDeduper();
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, 'c1', 'Read', { a: 1, b: 2 }, okResult('SAME'));
const cached = dedup.checkSameStep('c2', 'Read', { b: 2, a: 1 });
const cached = await dedup.checkSameStep('c2', 'Read', { b: 2, a: 1 });
expect(cached).not.toBeNull();
const finalDup = await dedup.finalizeResult('c2', 'Read', { b: 2, a: 1 }, cached!);
expect(finalDup).toEqual(okResult('SAME'));
@ -356,10 +360,10 @@ describe('AgentToolDedupeService', () => {
// args. The dedup key registered at checkSameStep time uses the
// LLM-issued args; the deferred must be resolved under that same key.
const dedup = createDeduper();
dedup.beginStep();
const c1 = dedup.checkSameStep('c1', 'Read', { path: '/a' });
await dedup.beginStep();
const c1 = await dedup.checkSameStep('c1', 'Read', { path: '/a' });
expect(c1).toBeNull();
const c2 = dedup.checkSameStep('c2', 'Read', { path: '/a' });
const c2 = await dedup.checkSameStep('c2', 'Read', { path: '/a' });
expect(c2).not.toBeNull();
// Original finalize is called with REWRITTEN args (simulates a hook
@ -388,19 +392,19 @@ describe('AgentToolDedupeService', () => {
describe('beginStep cleanup', () => {
it('resolves leaked deferreds from a prior aborted step with an error result', async () => {
const dedup = createDeduper();
dedup.beginStep();
await dedup.beginStep();
// Register an original but never finalize it (simulates abort mid-step).
const orig = dedup.checkSameStep('leaked', 'Read', { p: 1 });
const orig = await dedup.checkSameStep('leaked', 'Read', { p: 1 });
expect(orig).toBeNull();
// Register a dup that captures the leaked deferred.
const dupCached = dedup.checkSameStep('dup', 'Read', { p: 1 });
const dupCached = await dedup.checkSameStep('dup', 'Read', { p: 1 });
expect(dupCached).not.toBeNull();
// Next step begins — the leaked deferred should resolve so an awaiter
// doesn't hang. (In production the dup's finalize would have already
// happened before beginStep, but defensively resolving leaked deferreds
// protects against any ordering bug.)
dedup.beginStep();
await dedup.beginStep();
// Finalize the dup that captured the leaked deferred. Since we cleared
// syntheticCallIds in beginStep, this is no longer tracked — it just
// returns the placeholder it was passed. The leaked deferred has been
@ -421,9 +425,9 @@ describe('AgentToolDedupeService', () => {
): Promise<ToolDedupResult> {
let last: ToolDedupResult | undefined;
for (let i = 0; i < count; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
return last!;
}
@ -476,9 +480,9 @@ describe('AgentToolDedupeService', () => {
const dedup = createDeduper();
let last: ToolDedupResult | undefined;
for (let i = 0; i < 12; i += 1) {
dedup.beginStep();
await dedup.beginStep();
last = await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, errResult('boom'));
dedup.endStep();
await dedup.endStep();
}
// The underlying tool was an error — that must survive force-stop.
expect(last!.isError).toBe(true);
@ -491,9 +495,9 @@ describe('AgentToolDedupeService', () => {
it('emits tool_call_repeat with the streak count starting at the second occurrence', async () => {
const dedup = createDeduper();
for (let i = 0; i < 3; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
const repeats = telemetryEvents.filter((e) => e.event === 'tool_call_repeat');
expect(repeats.map((e) => e.properties?.['repeat_count'])).toEqual([2, 3]);
@ -502,18 +506,18 @@ describe('AgentToolDedupeService', () => {
it('does not emit telemetry on the first call', async () => {
const dedup = createDeduper();
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, 'c0', 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
expect(telemetryEvents.filter((e) => e.event === 'tool_call_repeat')).toHaveLength(0);
});
it('labels the action as r1/r2/r3 according to the reminder tier from streak 3 through 11', async () => {
const dedup = createDeduper();
for (let i = 0; i < 11; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
const byCount = new Map<number, string>();
for (const e of telemetryEvents) {
@ -535,9 +539,9 @@ describe('AgentToolDedupeService', () => {
it('labels the action as "stop" at streak 12+', async () => {
const dedup = createDeduper();
for (let i = 0; i < 13; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
const at12 = telemetryEvents.find(
(e) => e.event === 'tool_call_repeat' && e.properties?.['repeat_count'] === 12,
@ -552,16 +556,16 @@ describe('AgentToolDedupeService', () => {
it('resets the count when a different call interleaves', async () => {
const dedup = createDeduper();
for (let i = 0; i < 2; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `a${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, 'b1', 'Read', { p: 2 }, okResult('R'));
dedup.endStep();
dedup.beginStep();
await dedup.endStep();
await dedup.beginStep();
await runOriginal(dedup, 'c1', 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
const counts = telemetryEvents
.filter((e) => e.event === 'tool_call_repeat')
.map((e) => e.properties?.['repeat_count']);
@ -572,9 +576,9 @@ describe('AgentToolDedupeService', () => {
it('runs with a no-op telemetry service', async () => {
const dedup = createDeduper(recordingTelemetry([]));
for (let i = 0; i < 3; i += 1) {
dedup.beginStep();
await dedup.beginStep();
await runOriginal(dedup, `c${String(i)}`, 'Read', { p: 1 }, okResult('R'));
dedup.endStep();
await dedup.endStep();
}
expect(telemetryEvents.filter((e) => e.event === 'tool_call_repeat')).toHaveLength(0);
});

View file

@ -327,9 +327,9 @@ describe('Agent turn flow', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Trigger generate failure' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Trigger generate failure" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Trigger generate failure" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "error", "message": "Unexpected generate call #1" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "internal", "message": "Unexpected generate call #1", "name": "Error", "details": { "turnId": 0 }, "retryable": false } }
@ -367,7 +367,11 @@ describe('Agent turn flow', () => {
expect(ctx.get(IAgentSwarmService).isActive).toBe(false);
expect(ctx.contextData().history).toEqual([]);
expect(ctx.newEvents()).toMatchInlineSnapshot(`[wire] context.splice { "start": 0, "deleteCount": 1, "messages": [], "time": "<time>" }`);
expect(ctx.newEvents()).toMatchInlineSnapshot(`
[wire] swarm_mode.enter { "trigger": "manual" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<system-reminder>\\nlegacy swarm enter reminder\\n</system-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } ] }
[wire] swarm_mode.exit {}
`);
});
it('keeps manual swarm mode active after a turn completes normally', async () => {
@ -594,9 +598,9 @@ describe('Agent turn flow', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello without login' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
`);
expect(ctx.newEvents()).toMatchInlineSnapshot(
@ -630,7 +634,7 @@ describe('Agent turn flow', () => {
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, Bash, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, Glob, Grep, Read, Write
tools: Agent, AgentSwarm, AskUserQuestion, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, FetchURL, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, UpdateGoal, Write
messages:
user: text "hooked input"
user: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 1\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\nhook response 2\\n</hook_result>"
@ -650,7 +654,7 @@ describe('Agent turn flow', () => {
args: expect.objectContaining({ delta: 'model saw original prompt only' }),
}),
);
expect(ctx.contextData().history).toEqual([
expect(ctx.contextData().history).toMatchObject([
{
role: 'user',
content: [{ type: 'text', text: 'hooked input' }],
@ -693,7 +697,7 @@ describe('Agent turn flow', () => {
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, Bash, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, Glob, Grep, Read, Write
tools: Agent, AgentSwarm, AskUserQuestion, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, FetchURL, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, UpdateGoal, Write
messages:
user: text "hooked input"
user: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\n{}\\n</hook_result>\\n<hook_result hook_event=\\"UserPromptSubmit\\">\\n{\\"hookSpecificOutput\\":{}}\\n</hook_result>"
@ -707,7 +711,7 @@ describe('Agent turn flow', () => {
}),
}),
);
expect(ctx.contextData().history).toEqual([
expect(ctx.contextData().history).toMatchObject([
{
role: 'user',
content: [{ type: 'text', text: 'hooked input' }],
@ -758,7 +762,7 @@ describe('Agent turn flow', () => {
}),
}),
);
expect(ctx.contextData().history).toEqual([
expect(ctx.contextData().history).toMatchObject([
{
role: 'user',
content: [{ type: 'text', text: 'bad words here' }],
@ -779,7 +783,7 @@ describe('Agent turn flow', () => {
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: Agent, AgentSwarm, Bash, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, Glob, Grep, Read, Write
tools: Agent, AgentSwarm, AskUserQuestion, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, FetchURL, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, UpdateGoal, Write
messages:
user: text "bad words here"
assistant: text "<hook_result hook_event=\\"UserPromptSubmit\\">\\nno profanity\\n</hook_result>"
@ -814,7 +818,7 @@ describe('Agent turn flow', () => {
args: expect.objectContaining({ delta: expect.stringContaining('late hook') }),
}),
);
expect(ctx.contextData().history).toEqual([
expect(ctx.contextData().history).toMatchObject([
{
role: 'user',
content: [{ type: 'text', text: 'hook will sleep' }],
@ -861,8 +865,8 @@ describe('Agent turn flow', () => {
toolCalls: [],
};
expect(JSON.stringify(ctx.contextData().history)).toContain('continue from hook');
expect(ctx.contextData().history).toContainEqual(stopHookMessage);
expect(ctx.llmCalls[1]?.history).toContainEqual(llmStopHookMessage);
expect(ctx.contextData().history).toContainEqual(expect.objectContaining(stopHookMessage));
expect(ctx.llmCalls[1]?.history).toContainEqual(expect.objectContaining(llmStopHookMessage));
expect(JSON.stringify(ctx.contextData().history)).toContain('Second answer.');
});
@ -1156,7 +1160,7 @@ describe('Agent turn flow', () => {
provider: 'kimi',
model: 'mock-model',
modelAlias: 'mock-model',
toolCount: 13,
toolCount: 21,
});
expect(configPayload['systemPromptChars']).toEqual(expect.any(Number));
@ -1761,15 +1765,15 @@ describe('Agent turn flow', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a command' }] });
expect(await ctx.untilApprovalRequest()).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Run a command" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Run a command" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will run Bash." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 5, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 5, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 5, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 5, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will run Bash." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will run Bash." } ], "toolCalls": [] } ], "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
@ -1808,15 +1812,15 @@ describe('Agent turn flow', () => {
const approval = await ctx.takeApprovalRequest();
expect(approval.events).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Run Bash, then listen" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Run Bash, then listen" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will ask first." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf approved\\",\\"timeout\\":60}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will ask first." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will ask first." } ], "toolCalls": [] } ], "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
@ -1839,22 +1843,26 @@ describe('Agent turn flow', () => {
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will ask first." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf approved\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will ask first." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf approved\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 2, "tokens": 29, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 29 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "approved" } }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "approved" } ], "toolCalls": [], "toolCallId": "call_bash" } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "approved" } ], "toolCalls": [], "toolCallId": "call_bash", "id": "<msg-3>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "approved" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will ask first." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf approved\\",\\"timeout\\":60}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Also mention the steer." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Also mention the steer." } ], "toolCalls": [], "id": "<msg-4>" } ], "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "Approved, and I saw the steer." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 39, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 46, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 46, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 46, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "Approved, and I saw the steer." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 4, "deleteCount": 0, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "Approved, and I saw the steer." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 5, "tokens": 50, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 50 }
[wire] context.splice { "start": 4, "deleteCount": 1, "messages": [ { "id": "<msg-5>", "role": "assistant", "content": [ { "type": "text", "text": "Approved, and I saw the steer." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 39, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
@ -1878,15 +1886,15 @@ describe('Agent turn flow', () => {
const approval = await ctx.takeApprovalRequest();
expect(approval.events).toMatchInlineSnapshot(`
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start the active turn" } ], "toolCalls": [] } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start the active turn" } ], "toolCalls": [], "id": "<msg-1>" } ], "time": "<time>" }
[wire] turn.launch { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "promptMessageId": "<msg-1>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will wait for approval." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 7, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 7, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 7, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 7, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will wait for approval." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 0, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will wait for approval." } ], "toolCalls": [] } ], "time": "<time>" }
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
`);
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
@ -1905,20 +1913,24 @@ describe('Agent turn flow', () => {
});
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf should-not-run", "result": { "decision": "rejected", "selectedLabel": "reject" }, "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will wait for approval." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will wait for approval." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" } ] } ], "time": "<time>" }
[wire] context_size.measured { "length": 2, "tokens": 32, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 32 }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "<system>ERROR: Tool execution failed.</system>\\nTool \\"Bash\\" was not run because the user rejected the approval request." } ], "toolCalls": [], "toolCallId": "call_bash", "isError": true } ], "time": "<time>" }
[wire] context.splice { "start": 2, "deleteCount": 0, "messages": [ { "role": "tool", "content": [ { "type": "text", "text": "<system>ERROR: Tool execution failed.</system>\\nTool \\"Bash\\" was not run because the user rejected the approval request." } ], "toolCalls": [], "toolCallId": "call_bash", "isError": true, "id": "<msg-3>" } ], "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was not run because the user rejected the approval request.", "isError": true }
[wire] context.splice { "start": 1, "deleteCount": 1, "messages": [ { "id": "<msg-2>", "role": "assistant", "content": [ { "type": "text", "text": "I will wait for approval." } ], "toolCalls": [ { "type": "function", "id": "call_bash", "name": "Bash", "arguments": "{\\"command\\":\\"printf should-not-run\\",\\"timeout\\":60}" } ], "providerMessageId": "mock-1" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 7, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-2>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will not run it." }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 63, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "context": { "type": "turn", "turnId": 0 }, "time": "<time>" }
[emit] agent.status.updated { "usage": { "byModel": { "mock-model": { "inputOther": 70, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 70, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 70, "output": 33, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "role": "assistant", "content": [ { "type": "text", "text": "I will not run it." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context.splice { "start": 3, "deleteCount": 0, "messages": [ { "id": "<msg-4>", "role": "assistant", "content": [ { "type": "text", "text": "I will not run it." } ], "toolCalls": [] } ], "time": "<time>" }
[wire] context_size.measured { "length": 4, "tokens": 71, "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 71 }
[wire] context.splice { "start": 3, "deleteCount": 1, "messages": [ { "id": "<msg-4>", "role": "assistant", "content": [ { "type": "text", "text": "I will not run it." } ], "toolCalls": [], "providerMessageId": "mock-2" } ], "time": "<time>" }
[emit] agent.status.updated { "contextTokens": 0 }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-2>", "usage": { "inputOther": 63, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);

View file

@ -15,6 +15,7 @@ import {
InMemoryWireRecordPersistence,
createTestAgent,
replayServices,
testAgent,
type TestAgentContext,
} from '../harness';
@ -366,10 +367,14 @@ describe('IAgentWireRecordService.records()', () => {
records.append({ type: 'turn.launch', turnId: 0, origin: { kind: 'user' } });
const snapshot = records.getRecords();
expect(snapshot.map((record) => record.type)).toEqual(['context.splice', 'turn.launch']);
const types = snapshot
.map((record) => record.type)
.filter((type) => type !== 'config.update');
expect(types).toEqual(['context.splice', 'turn.launch']);
// A copy is returned, so mutating it must not affect the service.
const lengthBefore = records.getRecords().length;
(snapshot as unknown as PersistedWireRecord[]).pop();
expect(records.getRecords()).toHaveLength(2);
expect(records.getRecords()).toHaveLength(lengthBefore);
});
});
@ -385,8 +390,8 @@ describe('agent replay range build', () => {
];
await expect(buildReplay(records)).resolves.toEqual([
expect.objectContaining({ type: 'message', message: firstMessage }),
expect.objectContaining({ type: 'message', message: afterClearMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(firstMessage) }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(afterClearMessage) }),
]);
});
@ -417,7 +422,7 @@ describe('agent replay range build', () => {
expect(replay).toEqual([
expect.objectContaining({ type: 'permission_updated', mode: 'yolo' }),
expect.objectContaining({ type: 'message', message }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(message) }),
]);
expect(persistence.rewrites).toEqual([]);
});
@ -435,14 +440,14 @@ describe('agent replay range build', () => {
];
await expect(buildReplay(records, { count: 2 })).resolves.toEqual([
expect.objectContaining({ type: 'message', message: secondMessage }),
expect.objectContaining({ type: 'message', message: thirdMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(secondMessage) }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(thirdMessage) }),
]);
await expect(buildReplay(records, { count: 10 })).resolves.toEqual([
expect.objectContaining({ type: 'message', message: firstMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(firstMessage) }),
expect.objectContaining({ type: 'permission_updated', mode: 'auto' }),
expect.objectContaining({ type: 'message', message: secondMessage }),
expect.objectContaining({ type: 'message', message: thirdMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(secondMessage) }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(thirdMessage) }),
]);
});
@ -475,7 +480,7 @@ describe('agent replay range build', () => {
expect(replay).toHaveLength(10);
expect(replay).toEqual(
afterClearMessages.slice(-10).map((message) =>
expect.objectContaining({ type: 'message', message }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(message) }),
),
);
});
@ -534,7 +539,7 @@ describe('agent replay range build', () => {
]);
expect(replay).toEqual([
expect.objectContaining({ type: 'message', message: before }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(before) }),
expect.objectContaining({
type: 'compaction',
instruction: 'keep facts',
@ -593,7 +598,7 @@ describe('agent replay range build', () => {
];
await expect(buildReplay(records, { start: 2, count: 1 })).resolves.toEqual([
expect.objectContaining({ type: 'message', message: expectedMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(expectedMessage) }),
]);
});
@ -610,11 +615,11 @@ describe('agent replay range build', () => {
];
await expect(buildReplay(records, { start: 0, count: 10 })).resolves.toEqual([
expect.objectContaining({ type: 'message', message: firstMessage }),
expect.objectContaining({ type: 'message', message: secondMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(firstMessage) }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(secondMessage) }),
]);
await expect(buildReplay(records, { start: 2, count: 10 })).resolves.toEqual([
expect.objectContaining({ type: 'message', message: afterClearMessage }),
expect.objectContaining({ type: 'message', message: expect.objectContaining(afterClearMessage) }),
]);
});
});

View file

@ -62,6 +62,6 @@ export function wireSnapshot(records: readonly WireMigrationRecord[]) {
args,
};
}),
new Map(),
{ uuidLabels: new Map(), msgLabels: new Map() },
);
}

View file

@ -294,7 +294,7 @@ describe('Agent resume', () => {
expect(ctx.llmInputs()).toMatchInlineSnapshot(`
call 1:
system: <system-prompt>
tools: Agent, AgentSwarm, Bash, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, Glob, Grep, Read, Write
tools: Agent, AgentSwarm, Bash, CreateGoal, CronCreate, CronDelete, CronList, Edit, EnterPlanMode, ExitPlanMode, GetGoal, Glob, Grep, Read, SetGoalBudget, Skill, TaskList, TaskOutput, TaskStop, UpdateGoal, Write
messages:
user: text "Historical prompt before skill"
assistant: [] calls call_resume_write:Write { "path": "result.txt" }, call_resume_skill:Skill { "skill": "review" }