mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 14:16:22 +00:00
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:
commit
314e4ef2df
41 changed files with 3972 additions and 409 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createDecorator } from "#/_base/di";
|
||||
import { createDecorator } from '#/_base/di';
|
||||
|
||||
export interface UserToolRegistration {
|
||||
readonly name: string;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
77
packages/agent-core-v2/test/_base/tools/input-schema.test.ts
Normal file
77
packages/agent-core-v2/test/_base/tools/input-schema.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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" }
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
`);
|
||||
|
|
|
|||
576
packages/agent-core-v2/test/loop/fixtures.ts
Normal file
576
packages/agent-core-v2/test/loop/fixtures.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
70
packages/agent-core-v2/test/loop/retry.test.ts
Normal file
70
packages/agent-core-v2/test/loop/retry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
484
packages/agent-core-v2/test/loop/run-turn.test.ts
Normal file
484
packages/agent-core-v2/test/loop/run-turn.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
152
packages/agent-core-v2/test/shellTools/result-builder.test.ts
Normal file
152
packages/agent-core-v2/test/shellTools/result-builder.test.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
176
packages/agent-core-v2/test/todoList/todo-list.test.ts
Normal file
176
packages/agent-core-v2/test/todoList/todo-list.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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?"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,6 +62,6 @@ export function wireSnapshot(records: readonly WireMigrationRecord[]) {
|
|||
args,
|
||||
};
|
||||
}),
|
||||
new Map(),
|
||||
{ uuidLabels: new Map(), msgLabels: new Map() },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue