From e39cebdee3b47e0dfa64f567977648408e36b425 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:16:07 +0800 Subject: [PATCH 01/11] fix: restore v2 grep telemetry and tests --- .../src/os/backends/node-local/tools/grep.ts | 9 + .../agent-core-v2/test/fileTools/grep.test.ts | 176 +++++++++++++++--- 2 files changed, 159 insertions(+), 26 deletions(-) diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts index 8a5357942..90c306a3d 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts @@ -24,6 +24,7 @@ import { z } from 'zod'; import { ToolResultBuilder } from '#/agent/tool/result-builder'; import { ToolAccesses } from '#/agent/tool/tool-access'; import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool/toolContract'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -195,6 +196,7 @@ export class GrepTool implements BuiltinTool { @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ITelemetryService private readonly telemetry: ITelemetryService, ) {} private get workspace(): WorkspaceConfig { @@ -243,10 +245,17 @@ export class GrepTool implements BuiltinTool { allowCachedFallback: true, }); rgPath = resolution.path; + if (resolution.source !== 'system-path') { + this.telemetry.track('grep_tool_rg_fallback', { + source: resolution.source, + outcome: 'resolved', + }); + } } catch (error) { if (signal.aborted) { return { isError: true, output: 'Grep aborted' }; } + this.telemetry.track('grep_tool_rg_fallback', { outcome: 'failed' }); return { isError: true, output: rgUnavailableMessage(error) }; } diff --git a/packages/agent-core-v2/test/fileTools/grep.test.ts b/packages/agent-core-v2/test/fileTools/grep.test.ts index ff9027a8d..1c76eb157 100644 --- a/packages/agent-core-v2/test/fileTools/grep.test.ts +++ b/packages/agent-core-v2/test/fileTools/grep.test.ts @@ -1,17 +1,31 @@ import { Readable, type Writable } from 'node:stream'; -import type { KaosProcess, StatResult } from '@moonshot-ai/kaos'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { type GrepInput, GrepInputSchema, GrepTool } from '../../src/tools/builtin/file/grep'; -import { SENSITIVE_DOT_VARIANT_SUFFIXES } from '../../src/tools/policies/sensitive'; -import { ensureRgPath } from '../../src/tools/support/rg-locator'; -import type { WorkspaceConfig } from '../../src/tools/support/workspace'; -import { createFakeKaos, toolContentString } from './fixtures/fake-kaos'; -import { executeTool } from './fixtures/execute-tool'; -import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; +import type { + ExecutableTool, + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, +} from '#/agent/tool/toolContract'; +import { noopTelemetryService, type ITelemetryService } from '#/app/telemetry/telemetry'; +import type { PathClass } from '#/_base/execEnv/environmentProbe'; +import { PathSecurityError } from '#/_base/tools/policies/path-access'; +import { SENSITIVE_DOT_VARIANT_SUFFIXES } from '#/_base/tools/policies/sensitive'; +import type { WorkspaceConfig } from '#/_base/tools/support/workspace'; +import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import { + type GrepInput, + GrepInputSchema, + GrepTool as ProductionGrepTool, +} from '#/os/backends/node-local/tools/grep'; +import { ensureRgPath } from '#/os/backends/node-local/tools/rgLocator'; +import { stubWorkspaceContext } from './stub-workspace-context'; +import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; -vi.mock('../../src/tools/support/rg-locator', () => ({ +vi.mock('#/os/backends/node-local/tools/rgLocator', () => ({ ensureRgPath: vi.fn(async () => ({ path: '/mock/rg', source: 'system-path' })), rgUnavailableMessage: (cause: unknown) => `rg unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -65,10 +79,85 @@ const SENSITIVE_RG_ARGS = [ '!**/.gcp/credentials/**', ] as const; -function processWithOutput(stdout: string, stderr = '', exitCode = 0): KaosProcess { +interface FakeKaos { + pathClass(): PathClass; + gethome(): string; + stat(path: string): Promise; + exec(...args: string[]): Promise; +} + +function notImplemented(method: string): never { + throw new Error(`FakeKaos.${method} not implemented - override in the test`); +} + +function createFakeKaos(overrides: Partial = {}): FakeKaos { + return { + pathClass: () => 'posix', + gethome: () => '/home/test', + stat: () => notImplemented('stat'), + exec: () => notImplemented('exec'), + ...overrides, + }; +} + +function createTestEnv(kaos: FakeKaos): IHostEnvironment { + return { + _serviceBrand: undefined, + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + pathClass: kaos.pathClass(), + homeDir: kaos.gethome(), + ready: Promise.resolve(), + }; +} + +function createTestFs(kaos: FakeKaos): IHostFileSystem { + return { + _serviceBrand: undefined, + readText: () => notImplemented('readText'), + writeText: () => notImplemented('writeText'), + readBytes: () => notImplemented('readBytes'), + writeBytes: () => notImplemented('writeBytes'), + readLines: () => notImplemented('readLines'), + createExclusive: () => notImplemented('createExclusive'), + stat: (path) => kaos.stat(path), + readdir: () => notImplemented('readdir'), + mkdir: () => notImplemented('mkdir'), + remove: () => notImplemented('remove'), + }; +} + +function createTestProcessService(kaos: FakeKaos): IHostProcessService { + return { + _serviceBrand: undefined, + spawn: (command, args = []) => kaos.exec(command, ...args), + }; +} + +class GrepTool extends ProductionGrepTool { + constructor( + kaos: FakeKaos, + workspaceConfig: WorkspaceConfig, + telemetry: ITelemetryService = noopTelemetryService, + ) { + super( + createTestProcessService(kaos), + createTestFs(kaos), + createTestEnv(kaos), + stubWorkspaceContext(workspaceConfig.workspaceDir, workspaceConfig.additionalDirs), + telemetry, + ); + } +} + +function processWithOutput(stdout: string, stderr = '', exitCode = 0): IHostProcess { const stdoutStream = Readable.from([stdout]); const stderrStream = Readable.from([stderr]); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout: stdoutStream, stderr: stderrStream, @@ -76,29 +165,23 @@ function processWithOutput(stdout: string, stderr = '', exitCode = 0): KaosProce exitCode, wait: vi.fn().mockResolvedValue(exitCode), kill: vi.fn(async () => {}), - dispose: vi.fn(async () => { + dispose: vi.fn(() => { stdoutStream.destroy(); stderrStream.destroy(); }), }; } -function statResult(mtime: number): StatResult { +function statResult(mtime: number): HostFileStat { return { - stMode: 0o100000, - stIno: 1, - stDev: 1, - stNlink: 1, - stUid: 0, - stGid: 0, - stSize: 0, - stAtime: mtime, - stMtime: mtime, - stCtime: mtime, + isFile: true, + isDirectory: false, + size: 0, + mtimeMs: mtime, }; } -function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): KaosProcess { +function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): IHostProcess { let currentExitCode: number | null = null; let resolveWait: (code: number) => void; const waitPromise = new Promise((resolve) => { @@ -108,6 +191,7 @@ function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): Ka const stderrStream = Readable.from(stderr === '' ? [] : [stderr]); return { + _serviceBrand: undefined, stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, stdout: stdoutStream, stderr: stderrStream, @@ -120,7 +204,7 @@ function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): Ka currentExitCode = exitCode; resolveWait(exitCode); }), - dispose: vi.fn(async () => { + dispose: vi.fn(() => { stdoutStream.destroy(); stderrStream.destroy(); }), @@ -128,13 +212,53 @@ function processThatExitsOnKill(stdout: string, stderr = '', exitCode = 143): Ka } function context(args: GrepInput, abortSignal = signal) { - return { turnId: '0', toolCallId: 'call_grep', args, signal: abortSignal }; + return { turnId: 0, toolCallId: 'call_grep', args, signal: abortSignal }; } function nullRecord(filePath: string, payload = ''): string { return `${filePath}\0${payload}`; } +type TestExecutableToolContext = ExecutableToolContext & { + readonly args: Input; +}; + +async function executeTool( + tool: ExecutableTool, + testContext: TestExecutableToolContext, +): Promise { + const { args, ...executionContext } = testContext; + let execution: ToolExecution; + try { + const resolved = tool.resolveExecution(args); + execution = isPromiseLike(resolved) ? await resolved : resolved; + } catch (error) { + const output = + error instanceof PathSecurityError + ? error.message + : `Tool "${tool.name}" failed to resolve execution: ${ + error instanceof Error ? error.message : String(error) + }`; + return { isError: true, output }; + } + if (execution.isError === true) return execution; + return execution.execute(executionContext); +} + +function isPromiseLike( + value: ToolExecution | Promise, +): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +function toolContentString(result: ExecutableToolResult): string { + const c = result.output; + if (typeof c !== 'string') { + throw new TypeError(`expected string content, got ${typeof c}`); + } + return c; +} + afterEach(() => { vi.useRealTimers(); }); @@ -1437,7 +1561,7 @@ describe('GrepTool', () => { it('aborts while resolving the ripgrep path without spawning', async () => { const controller = new AbortController(); const exec = vi.fn(); - vi.mocked(ensureRgPath).mockImplementationOnce(({ signal: locatorSignal } = {}) => { + vi.mocked(ensureRgPath).mockImplementationOnce((_probe, { signal: locatorSignal } = {}) => { expect(locatorSignal).toBe(controller.signal); return new Promise((_resolve, reject) => { const rejectAbort = (): void => { From 94b943978a6275d2ae465d0e53dc84cf9cb07939 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:17:29 +0800 Subject: [PATCH 02/11] fix: preserve v2 compaction boundary --- .../src/agent/fullCompaction/fullCompactionService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index b680cad9a..51504d1b9 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -324,7 +324,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let compactedCount = initialCompactedCount; for (let round = 1; ; round++) { - const result = await this.compactionRound(active, round, data); + const result = await this.compactionRound(active, round, data, compactedCount); if (this._compacting !== active) throw compactionCancelledReason(active); finalResult.summary = result.summary; @@ -375,6 +375,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull active: ActiveCompaction, round: number, data: Readonly, + initialCompactedCount: number, ): Promise { const startedAt = Date.now(); const originalHistory = [...this.context.get()]; @@ -382,7 +383,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let retryCount = 0; try { - let compactedCount = originalHistory.length; + let compactedCount = Math.min(initialCompactedCount, originalHistory.length); const signal = active.abortController.signal; signal.throwIfAborted(); From d082afb4ae770c937d909230b62a1b7eb2a5f928 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:20:51 +0800 Subject: [PATCH 03/11] fix(agent-core-v2): align agent and swarm tool behavior --- .../src/agent/userTool/userTool.ts | 2 + .../src/agent/userTool/userToolService.ts | 10 + .../tools/agent-background-enabled.md | 2 + .../src/session/agentLifecycle/tools/agent.md | 2 +- .../src/session/agentLifecycle/tools/agent.ts | 24 +- .../src/session/swarm/sessionSwarmService.ts | 4 + .../test/swarm/sessionSwarm.test.ts | 919 +++++++++- packages/agent-core-v2/test/tool/tool.test.ts | 1494 ++++++++++++++++- .../test/userTool/userTool.test.ts | 39 + 9 files changed, 2483 insertions(+), 13 deletions(-) diff --git a/packages/agent-core-v2/src/agent/userTool/userTool.ts b/packages/agent-core-v2/src/agent/userTool/userTool.ts index 86a05c640..82d5d644d 100644 --- a/packages/agent-core-v2/src/agent/userTool/userTool.ts +++ b/packages/agent-core-v2/src/agent/userTool/userTool.ts @@ -9,6 +9,8 @@ export interface UserToolRegistration { export interface IAgentUserToolService { readonly _serviceBrand: undefined; + list(): readonly UserToolRegistration[]; + inheritUserTools(parent: IAgentUserToolService): void; register(input: UserToolRegistration): void; unregister(name: string): void; } diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index a74865ded..6ec2233a7 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -53,6 +53,16 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe this._register(this.wire.onRestored(() => this.restoreRegisteredTools())); } + list(): readonly UserToolRegistration[] { + return [...this.wire.getModel(UserToolModel).values()]; + } + + inheritUserTools(parent: IAgentUserToolService): void { + for (const registration of parent.list()) { + this.register(registration); + } + } + register(input: UserToolRegistration): void { this.wire.dispatch(registerUserTool(input)); this.applyRegister(input); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md index 126b3389b..27c587da1 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent-background-enabled.md @@ -1 +1,3 @@ +Default to a foreground subagent unless the task can run independently and there is a clear benefit to not waiting. + When `run_in_background=true`, the subagent runs detached from this turn. The completion arrives in a later turn as a synthetic user-role message containing its result — you do not need to poll, sleep, or check on its progress. Continue with other work or respond to the user. Never fabricate or predict what the result will say. diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md index 6ff3f26a4..61775bf90 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.md @@ -11,6 +11,6 @@ Usage notes: - A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply. - Subagents use a fixed 30-minute timeout. If one times out, resume the same agent instead of starting over. -When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it. +When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to keep meaningful work out of your own context. Once a subagent is running, leave that scope to it: do not redo its searches or reads in parallel, and do not abandon it midway and finish the job manually. Both undo the context savings the delegation was meant to buy. diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts index 7ced2d0b3..1f4f17282 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts @@ -25,6 +25,7 @@ import { import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { isAbortError } from '#/agent/loop/errors'; import { ToolAccesses } from '#/agent/tool/tool-access'; import type { @@ -89,7 +90,9 @@ export const AgentToolInputSchema = z.preprocess( resume: z .string() .optional() - .describe('Optional agent ID to resume instead of creating a new instance'), + .describe( + 'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type; the resumed agent keeps its original type.', + ), run_in_background: z .boolean() .optional() @@ -256,6 +259,9 @@ export class AgentTool implements BuiltinTool { permissionMode: this.permissionMode.mode, labels: subagentLabels(this.callerAgentId), }); + created.accessor + .get(IAgentUserToolService) + .inheritUserTools(requester.accessor.get(IAgentUserToolService)); agentId = created.id; profileName = profile.name; promptText = await applyProfilePromptPrefix(profile, args.prompt, { @@ -326,6 +332,14 @@ export class AgentTool implements BuiltinTool { handle = await this.launch(args, toolCallId, controller); } catch (error) { signal.removeEventListener('abort', abortBeforeRegister); + this.log.warn('subagent launch failed', { + toolCallId, + runInBackground, + operation: isResume ? 'resume' : 'spawn', + subagentType: requestedProfileName ?? DEFAULT_PROFILE_NAME, + resumeAgentId: isResume ? resumeAgentId : undefined, + error, + }); throw error; } @@ -353,8 +367,12 @@ export class AgentTool implements BuiltinTool { subagentType: handle.profileName, error, }); + const message = error instanceof Error ? error.message : String(error); return { - output: error instanceof Error ? error.message : String(error), + output: + message === 'Too many detached tasks are already running.' + ? 'Too many background tasks are already running.' + : message, isError: true, }; } @@ -439,7 +457,7 @@ function formatBackgroundAgentResult( `description: ${description}`, '', allowBackground - ? `next_step: The completion arrives automatically in a later turn — no polling needed. To peek at progress without blocking, call TaskOutput(task_id="${taskId}", block=false).` + ? 'next_step: The completion arrives automatically in a later turn; do NOT wait, poll, or call TaskOutput on it. Continue with other work or respond to the user.' : 'next_step: The completion arrives automatically in a later turn.', `resume_hint: To continue or recover this same subagent later, call Agent(resume="${handle.agentId}", prompt="..."). The parameter is agent_id ("${handle.agentId}"), NOT task_id ("${taskId}") or source_id from a later . Recovery cases: a later for this subagent — its conversation history is preserved across session restarts and resume will pick it up.`, ].join('\n'); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 2e73f071c..f6914f9f0 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -21,6 +21,7 @@ import { linkAbortSignal } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import type { SubagentSuspendedEvent } from '@moonshot-ai/protocol'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; @@ -151,6 +152,9 @@ export class SessionSwarmService implements ISessionSwarmService { permissionMode: caller.accessor.get(IAgentPermissionModeService).mode, labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), }); + child.accessor + .get(IAgentUserToolService) + .inheritUserTools(caller.accessor.get(IAgentUserToolService)); emitAgentRunSpawned(caller, child.id, { profileName: options.profileName, parentToolCallId: options.parentToolCallId, diff --git a/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts index ba7a2cd88..d0f9e77e4 100644 --- a/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts @@ -1,3 +1,4 @@ +import { createControlledPromise } from '@antfu/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -6,10 +7,13 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; +import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; +import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { APIProviderRateLimitError } from '#/app/llmProtocol/errors'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { IAgentLifecycleService, @@ -29,7 +33,11 @@ import { ILogService } from '#/_base/log/log'; import { AgentRunBatch, resolveSwarmMaxConcurrency, + type AgentRunAttemptHandle, + type AgentRunAttemptOptions, type AgentRunBatchLauncher, + type AgentRunResult, + type AgentRunSuspendedEvent, type AgentSpawnAttemptOptions, type QueuedAgentRunTask, } from '#/session/swarm/agentRunBatch'; @@ -66,6 +74,702 @@ describe('resolveSwarmMaxConcurrency', () => { }); }); +describe('AgentRunBatch scheduling contract', () => { + it('normal phase starts five tasks immediately, then one task every 700ms', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(7); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(8); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(9); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(9); + + attempts.forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `result ${String(index + 1)}`, + }); + }); + const results = await running; + + expect(results).toHaveLength(9); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('user cancellation returns completed, started, and not-started task results', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + await vi.advanceTimersByTimeAsync(0); + + controller.abort(userCancellationReason()); + const results = await running; + + expect( + results.map((result) => ({ + data: result.task.data, + agentId: result.agentId, + status: result.status, + state: result.state, + result: result.result, + error: result.error, + })), + ).toEqual([ + { + data: 1, + agentId: 'agent-1', + status: 'completed', + state: undefined, + result: 'completed 1', + error: undefined, + }, + { + data: 2, + agentId: 'agent-2', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 3, + agentId: 'agent-3', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 4, + agentId: 'agent-4', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 5, + agentId: 'agent-5', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 6, + agentId: undefined, + status: 'aborted', + state: 'not_started', + result: undefined, + error: + 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('normal phase keeps processing completions while waiting for the next launch', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + attempts.slice(1).forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 2)}`, + status: 'completed', + result: `completed ${String(index + 2)}`, + }); + }); + await expect(running).resolves.toHaveLength(6); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase starts when the first provider rate limit stops the normal ramp', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(5); + + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(1); + expect(attempts[5]!.retryAgentId).toBe('agent-1'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase requeues 429 tasks, emits suspended, and throttles launches', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const onSuspended = vi.fn(); + const { runBatch, attempts } = createMockAgentRunBatchRunner({ onSuspended }); + const running = runBatch( + Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + attempts.forEach((attempt) => { + attempt.markReady(); + }); + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); + await vi.advanceTimersByTimeAsync(0); + expect(onSuspended).toHaveBeenCalledTimes(2); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(500); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2500); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(2); + expect(attempts[5]!.retryAgentId).toBe('agent-2'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('fails the only unfinished task on provider rate limit instead of suspending forever', async () => { + vi.useFakeTimers(); + try { + const onSuspended = vi.fn(); + const { runBatch, attempts } = createMockAgentRunBatchRunner({ onSuspended }); + const running = runBatch( + Array.from({ length: 2 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(2); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + await vi.advanceTimersByTimeAsync(0); + + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); + await expect(running).resolves.toMatchObject([ + { + task: { data: 1 }, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }, + { + task: { data: 2 }, + agentId: 'agent-2', + status: 'failed', + state: 'started', + error: 'Rate limited', + }, + ]); + expect(onSuspended).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit capacity blocks launches while active attempts fill all slots', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 12 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.slice(0, 5).forEach((attempt) => { + attempt.markReady(); + }); + + for (let count = 6; count <= 12; count += 1) { + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(count); + attempts[count - 1]!.markReady(); + } + + attempts.slice(0, 12).forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ + type: 'rate_limited', + agentId: 'agent-1', + }); + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(12); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit recovery adds one capacity slot after three quiet minutes with queued work', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 6 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2000); + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2000); + attempts[2]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-3' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2000); + attempts[3]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-4' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(179_999); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(4); + expect(attempts[5]!.retryAgentId).toBe('agent-4'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase keeps launches bounded after repeated 429s', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + for (let index = 0; index < 3; index += 1) { + attempts[index]!.outcome.resolve({ + type: 'rate_limited', + agentId: `agent-${String(index + 1)}`, + }); + await vi.advanceTimersByTimeAsync(0); + } + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(3); + expect(attempts[5]!.retryAgentId).toBe('agent-3'); + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(7); + expect(attempts[6]!.task.data).toBe(2); + expect(attempts[6]!.retryAgentId).toBe('agent-2'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase schedules another launch after starting while capacity remains', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + Array.from({ length: 8 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + attempts[2]!.outcome.resolve({ + task: attempts[2]!.task, + agentId: 'agent-3', + status: 'completed', + result: 'completed 3', + }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(2_999); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.task.data).toBe(1); + expect(attempts[5]!.retryAgentId).toBe('agent-1'); + + await vi.advanceTimersByTimeAsync(2_999); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(7); + expect(attempts[6]!.task.data).toBe(6); + expect(attempts[6]!.retryAgentId).toBeUndefined(); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('task timeout fails only that task', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch([{ ...queuedAgentRunTask(1), timeout: 10_000 }], { + signal: new AbortController().signal, + }); + + await vi.advanceTimersByTimeAsync(0); + attempts[0]!.markReady(); + + await vi.advanceTimersByTimeAsync(9999); + expect(attempts).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(running).resolves.toMatchObject([ + { + task: { data: 1 }, + agentId: 'agent-1', + status: 'failed', + state: 'started', + error: 'Subagent timed out.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('does not spend task timeout while the task is queued', async () => { + vi.useFakeTimers(); + try { + let settled = false; + const { runBatch, attempts } = createMockAgentRunBatchRunner(); + const running = runBatch( + [ + ...Array.from({ length: 5 }, (_, index) => queuedAgentRunTask(index + 1)), + { ...queuedAgentRunTask(6), timeout: 1000 }, + ], + { signal: new AbortController().signal }, + ); + void running.finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); + + attempts.slice(0, 5).forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `completed ${String(index + 1)}`, + }); + }); + await vi.advanceTimersByTimeAsync(1); + + await expect(running).resolves.toMatchObject([ + { task: { data: 1 }, status: 'completed' }, + { task: { data: 2 }, status: 'completed' }, + { task: { data: 3 }, status: 'completed' }, + { task: { data: 4 }, status: 'completed' }, + { task: { data: 5 }, status: 'completed' }, + { + task: { data: 6 }, + agentId: 'agent-6', + status: 'failed', + state: 'started', + error: 'Subagent timed out.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('rate-limit phase continues launching after rate-limited attempts settle', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { runBatch, attempts } = createMockAgentRunBatchRunner({ + readyDelay: (attemptIndex) => (attemptIndex >= 7 ? 100 : undefined), + }); + + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.slice(0, 5).forEach((attempt) => { + attempt.markReady(); + }); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(7); + + attempts[5]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-6' }); + attempts[6]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-7' }); + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + await vi.advanceTimersByTimeAsync(12_000); + expect(attempts).toHaveLength(8); + expect(attempts[7]!.task.data).toBe(7); + expect(attempts[7]!.retryAgentId).toBe('agent-7'); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('AgentRunBatch max concurrency cap', () => { + it('caps in-flight tasks at maxConcurrency during the normal phase', async () => { + vi.useFakeTimers(); + try { + const { runBatch, attempts } = createMockAgentRunBatchRunner({ maxConcurrency: 3 }); + const running = runBatch( + Array.from({ length: 9 }, (_, index) => queuedAgentRunTask(index + 1)), + { signal: new AbortController().signal }, + ); + const resolved = new Set(); + const resolveOne = (index: number) => { + const attempt = attempts[index]!; + resolved.add(index); + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `result ${String(index + 1)}`, + }); + }; + const inFlight = () => attempts.length - resolved.size; + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(3); + expect(inFlight()).toBe(3); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(3); + + resolveOne(0); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(4); + expect(inFlight()).toBeLessThanOrEqual(3); + + resolveOne(1); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + expect(inFlight()).toBeLessThanOrEqual(3); + + resolveOne(2); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(6); + expect(inFlight()).toBeLessThanOrEqual(3); + + for (let index = 3; index < 9; index += 1) { + resolveOne(index); + await vi.advanceTimersByTimeAsync(700); + expect(inFlight()).toBeLessThanOrEqual(3); + } + + const results = await running; + expect(results).toHaveLength(9); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('AgentRunBatch swarm item forwarding', () => { function recordingLauncher() { const spawned: AgentSpawnAttemptOptions[] = []; @@ -133,13 +837,14 @@ describe('SessionSwarmService metadata compatibility', () => { let lifecycle: IAgentLifecycleService; let createAgent: ReturnType; let runAgent: ReturnType; + let eventBus: IEventBus; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); agents = {}; handles = new Map(); - const eventBus = eventBusStub(); + eventBus = eventBusStub(); lifecycle = lifecycleStub(handles, eventBus); createAgent = lifecycle.create as ReturnType; runAgent = lifecycle.run as ReturnType; @@ -298,6 +1003,42 @@ describe('SessionSwarmService metadata compatibility', () => { ); }); + it('inherits parent user tools on spawned children', async () => { + const parentUserTools = userToolServiceStub(); + const childUserTools = userToolServiceStub(); + handles.set( + 'main', + agentHandle('main', lifecycle, eventBus, {}, new Map([ + [IAgentUserToolService, parentUserTools], + ])), + ); + createAgent.mockImplementationOnce(async (opts: CreateAgentOptions = {}) => { + const id = opts.agentId ?? 'agent-new'; + const handle = agentHandle( + id, + lifecycle, + eventBus, + { + profileName: opts.binding?.profile ?? 'coder', + modelAlias: opts.binding?.model ?? 'kimi-test', + thinkingLevel: opts.binding?.thinking ?? 'medium', + cwd: opts.binding?.cwd ?? '/repo', + }, + new Map([[IAgentUserToolService, childUserTools]]), + ); + handles.set(id, handle); + return handle; + }); + const service = ix.get(ISessionSwarmService); + + await service.run({ + callerAgentId: 'main', + tasks: [spawnSessionTask('src/a.ts')], + }); + + expect(childUserTools.inheritUserTools).toHaveBeenCalledWith(parentUserTools); + }); + it('keeps v1 resume ownership errors inside the per-subagent result', async () => { agents['other-child'] = { homedir: '/tmp/kimi/s1/agents/other-child', @@ -397,6 +1138,7 @@ function agentHandle( lifecycle: IAgentLifecycleService, eventBus: IEventBus, data: Partial = {}, + services: ReadonlyMap = new Map(), ): IAgentScopeHandle { const profile = profileService({ cwd: '/repo', @@ -418,8 +1160,11 @@ function agentHandle( kind: LifecycleScope.Agent, accessor: { get: ((serviceId: unknown) => { + const service = services.get(serviceId); + if (service !== undefined) return service; if (serviceId === IAgentProfileService) return profile; if (serviceId === IAgentPermissionModeService) return permissionMode; + if (serviceId === IAgentUserToolService) return userToolServiceStub(); if (serviceId === IEventBus) return eventBus; if (serviceId === ITelemetryService) return noopTelemetryService; if (serviceId === IAgentLifecycleService) return lifecycle; @@ -437,6 +1182,16 @@ function profileService(data: ProfileData): IAgentProfileService { } as IAgentProfileService; } +function userToolServiceStub(): IAgentUserToolService { + return { + _serviceBrand: undefined, + list: () => [], + inheritUserTools: vi.fn<(parent: IAgentUserToolService) => void>(), + register: () => {}, + unregister: () => {}, + }; +} + function eventBusStub(): IEventBus { return { _serviceBrand: undefined, @@ -444,3 +1199,165 @@ function eventBusStub(): IEventBus { subscribe: vi.fn(() => ({ dispose: () => {} })) as IEventBus['subscribe'], }; } + +type MockAgentRunAttemptOutcome = + | AgentRunResult + | { + readonly type: 'rate_limited'; + readonly agentId: string; + }; + +type MockAgentRunAttemptRecord = { + readonly task: QueuedAgentRunTask; + readonly retryAgentId?: string; + readonly markReady: () => void; + readonly outcome: ReturnType>>; +}; + +type MockAgentRunBatchRunnerOptions = { + readonly onSuspended?: (event: AgentRunSuspendedEvent) => void; + readonly readyDelay?: (attemptIndex: number) => number | undefined; + readonly maxConcurrency?: number; +}; + +function createMockAgentRunBatchRunner( + options: MockAgentRunBatchRunnerOptions = {}, +): { + readonly runBatch: ( + tasks: readonly QueuedAgentRunTask[], + options?: { readonly signal?: AbortSignal }, + ) => Promise>>; + readonly attempts: MockAgentRunAttemptRecord[]; +} { + const attempts: MockAgentRunAttemptRecord[] = []; + let activeTasks: readonly QueuedAgentRunTask[] = []; + + const createHandle = ( + runOptions: AgentRunAttemptOptions, + agentId: string, + profileName: string, + retryAgentId?: string, + ): AgentRunAttemptHandle => { + const task = findMockAgentRunTask(activeTasks, runOptions); + const outcome = createControlledPromise>(); + const markReady = () => { + runOptions.onReady?.(); + }; + const attemptIndex = attempts.length; + attempts.push({ + task: task as unknown as QueuedAgentRunTask, + retryAgentId, + markReady, + outcome: outcome as unknown as MockAgentRunAttemptRecord['outcome'], + }); + + const delay = options.readyDelay?.(attemptIndex); + if (delay !== undefined) setTimeout(markReady, delay); + + return { + agentId, + profileName, + completion: completionFromMockAgentRunOutcome(outcome, runOptions.signal), + }; + }; + + const launcher: AgentRunBatchLauncher = { + spawn: async (spawnOptions) => { + const task = findMockAgentRunTask(activeTasks, spawnOptions); + return createHandle( + spawnOptions, + mockAgentRunId(task, attempts.length), + spawnOptions.profileName, + ); + }, + resume: async (agentId, runOptions) => createHandle(runOptions, agentId, 'subagent'), + retry: async (agentId, runOptions) => createHandle(runOptions, agentId, 'subagent', agentId), + suspended: (event) => { + options.onSuspended?.(event); + }, + }; + + return { + runBatch: ( + tasks: readonly QueuedAgentRunTask[], + runOptions?: { readonly signal?: AbortSignal }, + ) => { + activeTasks = tasks.map((task) => ({ + ...task, + signal: task.signal ?? runOptions?.signal, + })); + return new AgentRunBatch(launcher, activeTasks as readonly QueuedAgentRunTask[], { + maxConcurrency: options.maxConcurrency, + }).run(); + }, + attempts, + }; +} + +function findMockAgentRunTask( + tasks: readonly QueuedAgentRunTask[], + options: AgentRunAttemptOptions, +): QueuedAgentRunTask { + const task = tasks.find( + (candidate) => + candidate.prompt === options.prompt && + candidate.parentToolCallId === options.parentToolCallId, + ); + if (task === undefined) { + throw new Error(`No mock queued task for prompt "${options.prompt}"`); + } + return task as QueuedAgentRunTask; +} + +function mockAgentRunId(task: QueuedAgentRunTask, attemptIndex: number): string { + if (typeof task.data === 'number') return `agent-${String(task.data)}`; + return `agent-${String(attemptIndex + 1)}`; +} + +function completionFromMockAgentRunOutcome( + outcome: ReturnType>>, + signal: AbortSignal, +): AgentRunAttemptHandle['completion'] { + return new Promise((resolve, reject) => { + const abort = () => { + reject(signal.reason ?? new Error('Aborted')); + }; + signal.addEventListener('abort', abort, { once: true }); + outcome.then( + (result) => { + signal.removeEventListener('abort', abort); + if (isMockAgentRunRateLimitOutcome(result)) { + reject(new APIProviderRateLimitError('Rate limited', result.agentId)); + return; + } + if (result.status === 'completed') { + resolve({ result: result.result ?? '', usage: result.usage }); + return; + } + reject(new Error(result.error ?? result.status)); + }, + (error: unknown) => { + signal.removeEventListener('abort', abort); + reject(error); + }, + ); + }); +} + +function isMockAgentRunRateLimitOutcome( + outcome: MockAgentRunAttemptOutcome, +): outcome is Extract, { readonly type: 'rate_limited' }> { + return 'type' in outcome && outcome.type === 'rate_limited'; +} + +function queuedAgentRunTask(index: number): QueuedAgentRunTask { + return { + kind: 'spawn', + data: index, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: `Review item-${String(index)}`, + description: `Review #${String(index)}`, + runInBackground: false, + }; +} diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 35b9830b8..5e555053f 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1,25 +1,1472 @@ import { Readable, type Writable } from 'node:stream'; +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { Event, type Event as KimiEvent } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { userCancellationReason } from '#/_base/utils/abort'; import type { ToolCall } from '#/app/llmProtocol/message'; +import type { TokenUsage } from '#/app/llmProtocol/usage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentTaskService } from '#/agent/task/task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { makeHookRunner } from '../externalHooks/runner-stub'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { ToolAccesses } from '#/agent/tool/tool-access'; +import type { ExecutableTool } from '#/agent/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAgentUserToolService, type UserToolRegistration } from '#/agent/userTool/userTool'; +import { + AgentSwarmToolInputSchema, + type AgentSwarmToolInput, +} from '#/agent/swarm/tools/agent-swarm'; +import { + AgentToolInputSchema, + type AgentToolInput, +} from '#/session/agentLifecycle/tools/agent'; +import { + IAgentLifecycleService, + type AgentRunHandle, + type AgentRunRequest, + type RunAgentOptions, +} from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; +import type { + ISessionSwarmService, + SessionSwarmRunArgs, + SessionSwarmRunResult, +} from '#/session/swarm/sessionSwarm'; import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; +import { IAgentWireService } from '#/wire/tokens'; import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { + configServices, createCommandRunner, createTestAgent, execEnvServices, externalHookServices, + sessionService, + swarmServices, type TestAgentContext, + type TestAgentOptions, + type TestAgentServiceOverride, } from '../harness'; import { executeTool } from '../tools/fixtures/execute-tool'; const signal = new AbortController().signal; +function agentSchemaProperties(): Record { + return ( + toInputJsonSchema(AgentToolInputSchema) as { properties: Record } + ).properties; +} + +function agentSwarmSchemaProperties(): Record { + return ( + toInputJsonSchema(AgentSwarmToolInputSchema) as { properties: Record } + ).properties; +} + +function deferred(): { + readonly promise: Promise; + resolve(value: T): void; + reject(reason?: unknown): void; +} { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +interface CapturedLogEntry { + readonly level: 'error' | 'warn' | 'info' | 'debug'; + readonly message: string; + readonly payload: unknown; +} + +function captureLogs(): { + readonly entries: CapturedLogEntry[]; + readonly logger: ILogService; +} { + const entries: CapturedLogEntry[] = []; + const capture = + (level: CapturedLogEntry['level']) => (message: string, payload?: unknown) => { + entries.push({ level, message, payload }); + }; + let logger: ILogService; + logger = { + _serviceBrand: undefined, + level: 'off', + setLevel: () => {}, + flush: async () => {}, + info: capture('info'), + warn: capture('warn'), + error: capture('error'), + debug: capture('debug'), + child: () => logger, + }; + return { entries, logger }; +} + +function hookSlot() { + return { + run: vi.fn(async (_input: T) => {}), + register: () => ({ dispose: () => {} }), + delete: () => false, + }; +} + +interface AgentLifecycleStubOptions { + readonly createAgentIds?: readonly string[]; + readonly runCompletion?: ( + agentId: string, + request: AgentRunRequest, + options: RunAgentOptions, + ) => Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + readonly createError?: Error; + readonly handleServices?: ReadonlyMap>; +} + +interface AgentLifecycleStub extends IAgentLifecycleService { + readonly create: ReturnType>; + readonly run: ReturnType>; + readonly getHandle: ReturnType>; + addHandle( + agentId: string, + profileName: string, + services?: ReadonlyMap, + ): void; +} + +function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): AgentLifecycleStub { + let lifecycle: AgentLifecycleStub; + let created = 0; + const profileByAgentId = new Map(); + const handles = new Map(); + const servicesByAgentId = new Map(options.handleServices); + const handle = (agentId: string): IAgentScopeHandle => ({ + id: agentId, + kind: 2, + accessor: { + get: (serviceId) => { + const service = servicesByAgentId.get(agentId)?.get(serviceId); + if (service !== undefined) return service as never; + if (serviceId === IAgentLifecycleService) return lifecycle as never; + if (serviceId === IAgentContextInjectorService) { + return { + _serviceBrand: undefined, + register: () => ({ dispose: () => {} }), + } as never; + } + if (serviceId === IAgentContextMemoryService) { + return { + _serviceBrand: undefined, + get: () => [], + } as never; + } + if (serviceId === IAgentProfileService) { + return { + _serviceBrand: undefined, + data: () => ({ profileName: profileByAgentId.get(agentId) }), + isToolActive: () => false, + } as never; + } + if (serviceId === IAgentToolRegistryService) { + return { + _serviceBrand: undefined, + register: () => ({ dispose: () => {} }), + } as never; + } + if (serviceId === IAgentUserToolService) { + return { + _serviceBrand: undefined, + list: () => [], + inheritUserTools: () => {}, + register: () => {}, + unregister: () => {}, + } as never; + } + if (serviceId === IAgentWireService) { + return { + _serviceBrand: undefined, + dispatch: () => {}, + getModel: () => [], + onRestored: () => ({ dispose: () => {} }), + } as never; + } + return undefined as never; + }, + }, + dispose: () => {}, + }); + lifecycle = { + _serviceBrand: undefined, + hooks: { + onWillStartAgentTask: hookSlot(), + onDidStopAgentTask: hookSlot(), + }, + onDidCreate: Event.None as KimiEvent, + onDidCreateMain: Event.None as KimiEvent, + onDidDispose: Event.None as KimiEvent, + create: vi.fn(async (input = {}) => { + if (options.createError !== undefined) throw options.createError; + const agentId = + input.agentId ?? + options.createAgentIds?.[created] ?? + `agent-child-${String(created + 1)}`; + created += 1; + const profileName = input.binding?.profile ?? 'coder'; + profileByAgentId.set(agentId, profileName); + const createdHandle = handle(agentId); + handles.set(agentId, createdHandle); + return createdHandle; + }), + ensureMcpReady: vi.fn(async () => {}), + notifyMainCreated: vi.fn(), + fork: vi.fn(async () => { + throw new Error('unexpected fork'); + }), + run: vi.fn(async (agentId, request, runOptions): Promise => { + const completion = + options.runCompletion?.(agentId, request, runOptions) ?? + Promise.resolve({ summary: 'child result' }); + return { + agentId, + turn: {} as AgentRunHandle['turn'], + completion, + }; + }), + getHandle: vi.fn((agentId) => handles.get(agentId)), + list: vi.fn(() => [...handles.values()]), + remove: vi.fn(async (agentId) => { + handles.delete(agentId); + }), + addHandle: (agentId, profileName, services) => { + profileByAgentId.set(agentId, profileName); + if (services !== undefined) servicesByAgentId.set(agentId, services); + handles.set(agentId, handle(agentId)); + }, + }; + return lifecycle; +} + +function agentTool(ctx: TestAgentContext): ExecutableTool { + const tool = ctx.get(IAgentToolRegistryService).resolve('Agent'); + expect(tool).toBeDefined(); + return tool! as ExecutableTool; +} + +function agentSwarmTool(ctx: TestAgentContext): ExecutableTool { + const tool = ctx.get(IAgentToolRegistryService).resolve('AgentSwarm'); + expect(tool).toBeDefined(); + return tool! as ExecutableTool; +} + +function executeAgentTool( + ctx: TestAgentContext, + args: AgentToolInput, + inputSignal: AbortSignal = signal, +) { + return executeTool(agentTool(ctx), { + turnId: 0, + toolCallId: 'call_agent', + args, + signal: inputSignal, + }); +} + +const cronStub = { + _serviceBrand: undefined, + list: () => [], +} as unknown as ISessionCronService; + +describe('AgentToolInputSchema', () => { + 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 run_in_background and not runInBackground in the JSON schema', () => { + const properties = agentSchemaProperties(); + + expect(properties).toHaveProperty('run_in_background'); + expect(properties).not.toHaveProperty('runInBackground'); + }); + + it('describes subagent_type and run_in_background parameters', () => { + const properties = agentSchemaProperties<{ description?: string }>(); + + const subagentTypeDescription = properties['subagent_type']?.description ?? ''; + expect(subagentTypeDescription).toContain('coder'); + expect(subagentTypeDescription).not.toContain('registry'); + expect(subagentTypeDescription).toContain('agent type'); + expect(properties['run_in_background']?.description).toContain('false'); + }); + + it('documents that resume excludes subagent_type', () => { + const properties = agentSchemaProperties<{ description?: string }>(); + + expect((properties['resume']?.description ?? '').toLowerCase()).toContain('subagent_type'); + }); + + it('does not expose timeout or model parameters in the JSON schema', () => { + const properties = agentSchemaProperties(); + + expect(properties).not.toHaveProperty('timeout'); + expect(properties).not.toHaveProperty('model'); + }); + + 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(); + }); +}); + +describe('Agent tool description', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + await ctx.dispose(); + }); + + function agentDescription(): string { + const tool = ctx.toolsData().find((entry) => entry.name === 'Agent'); + expect(tool).toBeDefined(); + return tool!.description; + } + + it('explains the fixed background subagent timeout', () => { + ctx = createTestAgent(); + + const description = agentDescription(); + + expect(description).toContain('fixed 30-minute timeout'); + expect(description).not.toContain('operator-configured background timeout'); + expect(description).not.toContain('no time limit'); + expect(description).toContain('Default to a foreground subagent'); + }); + + it('renders the tool set for each subagent type', () => { + ctx = createTestAgent(); + + const description = agentDescription(); + + expect(description).toContain('Tools: Bash, Read, ReadMediaFile, Glob, Grep, WebSearch, FetchURL'); + expect(description).toContain('Tools: Agent, AgentSwarm, Bash'); + }); + + it('mentions resume preference and result visibility', () => { + ctx = createTestAgent(); + + const description = agentDescription().toLowerCase(); + + expect(description).toContain('resume'); + expect(description).toContain('only visible to you'); + expect(description).toContain('when not to'); + expect(description).toContain('out of your own context'); + }); + + it('describes configured subagent types', () => { + ctx = createTestAgent(); + + const description = agentDescription(); + + expect(description).toContain('Available agent types'); + expect(description).toContain('- explore: Fast codebase exploration'); + expect(description).toContain('- coder: Good at general software engineering tasks.'); + }); +}); + +describe('Agent tool execution contract', () => { + let ctx: TestAgentContext | undefined; + + afterEach(async () => { + vi.useRealTimers(); + await ctx?.dispose(); + ctx = undefined; + }); + + function createAgentToolContext( + lifecycle: AgentLifecycleStub = createAgentLifecycleStub(), + ...extra: readonly (TestAgentServiceOverride | TestAgentOptions)[] + ): TestAgentContext { + ctx = createTestAgent( + sessionService(IAgentLifecycleService, lifecycle), + sessionService(ISessionCronService, cronStub), + ...extra, + ); + lifecycle.addHandle('main', 'agent'); + return ctx; + } + + it('declares no resource accesses so concurrent Agent calls can run in parallel', async () => { + const context = createAgentToolContext(); + + const execution = await agentTool(context).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 lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext(lifecycle); + lifecycle.addHandle('agent-existing', 'explore'); + + const execution = await agentTool(context).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(lifecycle.getHandle).toHaveBeenCalledWith('agent-existing'); + }); + + it('returns an error when resuming with a subagent type', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext(lifecycle); + lifecycle.addHandle('agent-existing', 'explore'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + subagent_type: 'explore', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', + }); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('spawns a foreground subagent and returns its summary', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => ({ summary: 'child result' }), + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ profile: 'explore' }), + labels: expect.objectContaining({ parentAgentId: 'main' }), + }), + ); + expect(lifecycle.run).toHaveBeenCalledWith( + 'agent-child', + { kind: 'prompt', prompt: expect.stringContaining('Investigate') }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('actual_subagent_type: explore'); + expect(result.output).toContain('child result'); + }); + + it('inherits parent user tools when spawning a subagent', async () => { + const lookupTool: UserToolRegistration = { + name: 'Lookup', + description: 'Look up a short test value.', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + }; + const parentUserTools = { + _serviceBrand: undefined, + list: () => [lookupTool], + inheritUserTools: vi.fn(), + register: vi.fn(), + unregister: vi.fn(), + } as unknown as IAgentUserToolService; + const childUserTools = { + _serviceBrand: undefined, + list: () => [], + inheritUserTools: vi.fn(), + register: vi.fn(), + unregister: vi.fn(), + } as unknown as IAgentUserToolService; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([ + ['main', new Map([[IAgentUserToolService, parentUserTools]])], + ['agent-child', new Map([[IAgentUserToolService, childUserTools]])], + ]), + }); + const context = createAgentToolContext(lifecycle); + + await executeAgentTool(context, { + prompt: 'Use the available lookup tool', + description: 'Use lookup', + }); + + expect(childUserTools.inheritUserTools).toHaveBeenCalledWith(parentUserTools); + }); + + it('falls back to coder for an empty subagent type', async () => { + const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); + const context = createAgentToolContext(lifecycle); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: '', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ profile: 'coder' }), + }), + ); + }); + + it('resumes a foreground subagent when resume is provided', async () => { + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + }); + const context = createAgentToolContext(lifecycle); + lifecycle.addHandle('agent-existing', 'explore'); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(lifecycle.create).not.toHaveBeenCalled(); + expect(lifecycle.run).toHaveBeenCalledWith( + 'agent-existing', + { kind: 'prompt', prompt: 'Continue' }, + expect.objectContaining({ 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('registers background subagents with the task manager', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + expect(result.output).toContain('status: running'); + expect(result.output).toContain('agent_id: agent-child'); + 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(context.get(IAgentTaskService).getTask(taskId!)).toMatchObject({ + status: 'running', + description: 'Find cause', + timeoutMs: 30 * 60 * 1000, + }); + completion.resolve({ summary: 'finished later' }); + }); + + it('rejects background subagents when background execution is disabled', async () => { + const lifecycle = createAgentLifecycleStub(); + const context = createAgentToolContext(lifecycle); + context.get(IAgentProfileService).update({ activeToolNames: ['Agent'] }); + + const description = context.toolsData().find((tool) => tool.name === 'Agent')?.description; + expect(description).toContain('Background agent execution is disabled for this agent.'); + expect(description).not.toContain('the subagent runs detached from this turn'); + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + run_in_background: true, + }); + + expect(result).toMatchObject({ + isError: true, + output: + 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.', + }); + expect(lifecycle.create).not.toHaveBeenCalled(); + }); + + it('does not consume a background task slot when validation fails before launch', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext( + lifecycle, + configServices(() => ({ + providers: {}, + task: { maxRunningTasks: 1 }, + })), + ); + + const invalid = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Invalid background resume', + resume: 'agent-existing', + subagent_type: 'explore', + run_in_background: true, + }); + const valid = await executeAgentTool(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(lifecycle.create).toHaveBeenCalledTimes(1); + completion.resolve({ summary: 'finished later' }); + }); + + it('returns an error when background registration hits the task limit', async () => { + const completions = [ + deferred<{ readonly summary: string }>(), + deferred<{ readonly summary: string }>(), + ]; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-first', 'agent-second'], + runCompletion: (_agentId, _request, options) => { + const next = completions.shift(); + if (next === undefined) throw new Error('unexpected run'); + options.signal.addEventListener( + 'abort', + () => { + next.reject(options.signal.reason); + }, + { once: true }, + ); + return next.promise; + }, + }); + const context = createAgentToolContext( + lifecycle, + configServices(() => ({ + providers: {}, + task: { maxRunningTasks: 1 }, + })), + ); + + const first = await executeAgentTool(context, { + prompt: 'Investigate first', + description: 'Find first', + run_in_background: true, + }); + const second = await executeAgentTool(context, { + prompt: 'Investigate second', + description: 'Find second', + run_in_background: true, + }); + + expect(first.output).toContain('status: running'); + expect(second).toMatchObject({ + isError: true, + output: 'Too many background tasks are already running.', + }); + expect(lifecycle.create).toHaveBeenCalledTimes(2); + completions[0]?.resolve({ summary: 'finished later' }); + }); + + it('logs background registration failures', async () => { + const { entries, logger } = captureLogs(); + const completions = [ + deferred<{ readonly summary: string }>(), + deferred<{ readonly summary: string }>(), + ]; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-first', 'agent-second'], + runCompletion: (_agentId, _request, options) => { + const next = completions.shift(); + if (next === undefined) throw new Error('unexpected run'); + options.signal.addEventListener('abort', () => next.reject(options.signal.reason), { + once: true, + }); + return next.promise; + }, + }); + const context = createAgentToolContext( + lifecycle, + configServices(() => ({ + providers: {}, + task: { maxRunningTasks: 1 }, + })), + sessionService(ILogService, logger), + ); + + await executeAgentTool(context, { + prompt: 'Investigate first', + description: 'Find first', + run_in_background: true, + }); + await executeAgentTool(context, { + prompt: 'Investigate second', + description: 'Find second', + run_in_background: true, + }); + + expect(entries).toContainEqual({ + level: 'warn', + message: 'background agent task registration failed', + payload: expect.objectContaining({ + toolCallId: 'call_agent', + agentId: 'agent-second', + subagentType: 'coder', + error: expect.any(Error), + }), + }); + completions[0]?.resolve({ summary: 'finished later' }); + }); + + it('returns tool errors and logs when spawning fails', async () => { + const error = new Error('missing subagent'); + const { entries, logger } = captureLogs(); + const lifecycle = createAgentLifecycleStub({ createError: error }); + const context = createAgentToolContext(lifecycle, sessionService(ILogService, logger)); + + const result = await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(result).toMatchObject({ + isError: true, + output: 'subagent error: missing subagent', + }); + expect(entries).toContainEqual({ + level: 'warn', + message: 'subagent launch failed', + payload: expect.objectContaining({ + toolCallId: 'call_agent', + runInBackground: false, + operation: 'spawn', + subagentType: 'coder', + error, + }), + }); + }); + + it('can detach a foreground subagent through the task manager', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + const tasks = context.get(IAgentTaskService); + + const running = executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + await vi.waitFor(() => { + expect(tasks.list(false)).toHaveLength(1); + }); + const task = tasks.list(false)[0]!; + + expect(task).toMatchObject({ + kind: 'agent', + detached: false, + agentId: 'agent-child', + }); + + tasks.detach(task.taskId); + const result = await running; + + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('automatic_notification: true'); + + completion.resolve({ summary: 'finished later' }); + await expect(tasks.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('does not recommend disabled task tools when a foreground subagent is detached', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + context.get(IAgentProfileService).update({ activeToolNames: ['Agent'] }); + const tasks = context.get(IAgentTaskService); + + const running = executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + await vi.waitFor(() => { + expect(tasks.list(false)).toHaveLength(1); + }); + const task = tasks.list(false)[0]!; + + tasks.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'); + + completion.resolve({ summary: 'finished later' }); + await expect(tasks.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('steers the AI away from waiting and gives a resume hint on background launch', async () => { + const completion = deferred<{ readonly summary: string }>(); + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: () => completion.promise, + }); + const context = createAgentToolContext(lifecycle); + + const result = await executeAgentTool(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('do NOT wait, poll, or call TaskOutput on it'); + expect(result.output).not.toContain('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/); + completion.resolve({ summary: 'finished later' }); + }); + + it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => { + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: (_agentId, _request, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + reject(options.signal.reason); + }, + { once: true }, + ); + }), + }); + const context = createAgentToolContext(lifecycle); + const controller = new AbortController(); + + const resultPromise = executeAgentTool( + context, + { prompt: 'Investigate', description: 'Find cause' }, + controller.signal, + ); + await vi.waitFor(() => { + expect(context.get(IAgentTaskService).list(false)).toHaveLength(1); + }); + 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 lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: (_agentId, _request, options) => + new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + reject(options.signal.reason); + }, + { once: true }, + ); + }), + }); + const context = createAgentToolContext(lifecycle); + + const resultPromise = executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + await vi.waitFor(() => { + expect(context.get(IAgentTaskService).list(false)).toHaveLength(1); + }); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + const result = await resultPromise; + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('agent_id: agent-child'); + expect(result.output).toContain('actual_subagent_type: coder'); + expect(result.output).toContain('status: failed'); + expect(result.output).toContain('subagent error: Agent timed out after 30 minutes.'); + expect(result.output).toContain('resume_hint:'); + expect(result.output).toContain('Agent(resume="agent-child", prompt="continue")'); + expect(result.output).toContain('do not set subagent_type'); + expect(result.output).toContain('retains its prior context'); + }); +}); + +describe('AgentSwarmToolInputSchema', () => { + const spawnInput: AgentSwarmToolInput = { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + subagent_type: 'explore', + }; + + it('accepts item-based swarms up to 128 subagents', () => { + expect(AgentSwarmToolInputSchema.safeParse(spawnInput).success).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + ...spawnInput, + items: Array.from({ length: 128 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(true); + }); + + it('rejects more than 128 item-based subagents in the JSON args schema', () => { + expect( + AgentSwarmToolInputSchema.safeParse({ + ...spawnInput, + items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(false); + }); + + it('allows resumed subagents without item-based spawns', () => { + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume one agent', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review', + }, + }).success, + ).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume two agents', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }).success, + ).toBe(true); + }); + + it('exposes subagent_type and resume_agent_ids parameters', () => { + const properties = agentSwarmSchemaProperties<{ description?: string }>(); + + expect(properties['subagent_type']?.description).toContain('defaults to coder'); + expect(properties['resume_agent_ids']?.description).toContain('Map of existing subagent'); + expect(Object.keys(properties).at(-1)).toBe('resume_agent_ids'); + expect(properties).not.toHaveProperty('run_in_background'); + expect(properties).not.toHaveProperty('timeout'); + expect(properties).not.toHaveProperty('model'); + }); +}); + +describe('AgentSwarm tool description', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + await ctx.dispose(); + }); + + function agentSwarmDescription(): string { + const tool = ctx.toolsData().find((entry) => entry.name === 'AgentSwarm'); + expect(tool).toBeDefined(); + return tool!.description; + } + + it('states the enforced input requirements', () => { + ctx = createTestAgent(); + + const description = agentSwarmDescription(); + + expect(description).toContain('at least 2'); + expect(description).toContain('{{item}}'); + expect(description.toLowerCase()).toContain('distinct'); + expect(description).toContain('128 subagents'); + }); + + it('states AgentSwarm must be the only tool call in a response', () => { + ctx = createTestAgent(); + + expect(agentSwarmDescription()).toContain( + 'If `AgentSwarm` is called, that call must be the only tool call in the response.', + ); + }); +}); + +describe('AgentSwarm tool execution contract', () => { + let ctx: TestAgentContext; + + afterEach(async () => { + await ctx.dispose(); + }); + + it('runs item-based swarms through the session swarm service and renders XML results', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => { + return args.tasks.map((task, index) => ({ + task, + agentId: `agent-explore-${String(index + 1)}`, + status: 'completed' as const, + result: index === 0 ? 'explore result a' : 'explore result b', + })); + }, + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + subagent_type: 'explore', + }, + signal, + }); + + expect(runSwarm).toHaveBeenCalledWith({ + callerAgentId: 'main', + tasks: [ + { + 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: 30 * 60 * 1000, + }, + { + 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: 30 * 60 * 1000, + }, + ], + }); + expect(result.output).toBe([ + '', + 'completed: 2', + 'explore result a', + 'explore result b', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('resumes mapped agents before spawning item subagents', async () => { + const persistedItems: Record = { + 'agent-old-1': 'src/old-a.ts', + 'agent-old-2': 'src/old-b.ts', + }; + const getSwarmItem = vi.fn( + async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId], + ); + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => { + return args.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 swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + 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', + }, + }, + signal, + }); + + expect(getSwarmItem).toHaveBeenCalledWith({ + callerAgentId: 'main', + agentId: 'agent-old-1', + }); + expect(getSwarmItem).toHaveBeenCalledWith({ + callerAgentId: 'main', + agentId: 'agent-old-2', + }); + expect(runSwarm).toHaveBeenCalledWith({ + callerAgentId: 'main', + tasks: [ + { + 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: 30 * 60 * 1000, + }, + { + 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: 30 * 60 * 1000, + }, + { + 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: 30 * 60 * 1000, + }, + ], + }); + expect(result.output).toBe([ + '', + 'completed: 3', + 'result 1', + 'result 2', + 'result 3', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('reports failed subagents inside the XML result without failing the tool', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + agentId: 'agent-coder-1', + status: 'completed' as const, + result: 'imports are stable', + }, + { + task: args.tasks[1]!, + agentId: 'agent-coder-2', + status: 'failed' as const, + error: 'Agent timed out after 30s.', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'completed: 1, failed: 1', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'Agent timed out after 30s.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('omits the resume hint when incomplete subagents have no agent ids', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + status: 'failed' as const, + error: 'Agent did not start.', + }, + { + task: args.tasks[1]!, + status: 'failed' as const, + error: 'Agent also did not start.', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'failed: 2', + 'Agent did not start.', + 'Agent also did not start.', + '', + ].join('\n')); + expect(result.output).not.toContain(''); + expect(result.isError).toBeUndefined(); + }); + + it('reports partial aborted subagents inside the XML result', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => [ + { + task: args.tasks[0]!, + agentId: 'agent-coder-1', + status: 'completed' as const, + result: 'imports are stable', + }, + { + task: args.tasks[1]!, + agentId: 'agent-coder-2', + status: 'aborted' as const, + state: 'started' as const, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + task: args.tasks[2]!, + status: 'aborted' as const, + state: 'not_started' as const, + error: 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ], + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + const result = await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }, + signal, + }); + + expect(result.output).toBe([ + '', + 'completed: 1, aborted: 2', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'imports are stable', + 'The user manually interrupted this subagent batch before this subagent finished.', + 'The user manually interrupted this subagent batch before this subagent was started.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('declares broad accesses and does not expose permission rule argument matching', async () => { + ctx = createTestAgent(); + + const execution = await agentSwarmTool(ctx).resolveExecution({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }); + + if (execution.isError === true) throw new Error('AgentSwarm resolveExecution returned an error'); + expect(execution.accesses).toEqual(ToolAccesses.all()); + expect(execution.approvalRule).toBe('AgentSwarm'); + expect(execution.matchesRule).toBeUndefined(); + expect(execution.description).toBe('Launching agent swarm: Review files'); + expect(execution.display).toMatchObject({ + kind: 'agent_call', + agent_name: 'swarm (2 subagents)', + prompt: 'Review files', + }); + }); + + it('counts resumed and item-based subagents in the display name', async () => { + ctx = createTestAgent(); + + const execution = await agentSwarmTool(ctx).resolveExecution({ + description: 'Finish review', + 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', + }, + }); + + if (execution.isError === true) throw new Error('AgentSwarm resolveExecution returned an error'); + expect(execution.display).toMatchObject({ + agent_name: 'swarm (3 subagents)', + prompt: 'Finish review', + }); + }); +}); + describe('Agent tools', () => { let context: IAgentContextMemoryService; let ctx: TestAgentContext; @@ -215,18 +1662,49 @@ describe('Agent tools', () => { }); }); - describe.skip('foreground Agent tool recovery', () => { - // TODO: rewrite against the new session/agentLifecycle/tools/agent surface. - // The old `AgentToolRunOverride` seam is gone; equivalent tests will stub - // `IAgentLifecycleService.spawn` + a fake child scope's prompt turn. - let runOverride: unknown; - + describe('foreground Agent tool recovery', () => { beforeEach(() => { - runOverride = undefined; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + runCompletion: async () => { + throw new Error('Subagent turn failed before completing its final summary: reason=max_tokens'); + }, + }); + ctx = createTestAgent( + sessionService(IAgentLifecycleService, lifecycle), + sessionService(ISessionCronService, cronStub), + ); + lifecycle.addHandle('main', 'agent'); }); it('continues after a foreground Agent tool returns a max_tokens failure', async () => { - expect(runOverride).toBeUndefined(); + ctx.mockNextResponse({ type: 'text', text: 'I will delegate.' }, agentCall()); + ctx.mockNextResponse({ type: 'text', text: 'I recovered from the subagent failure.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Use an agent' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.contextData().history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'tool', + toolCallId: 'call_agent', + content: [ + expect.objectContaining({ + text: expect.stringContaining('reason=max_tokens'), + }), + ], + }), + expect.objectContaining({ + role: 'assistant', + content: [ + expect.objectContaining({ + text: 'I recovered from the subagent failure.', + }), + ], + }), + ]), + ); }); }); diff --git a/packages/agent-core-v2/test/userTool/userTool.test.ts b/packages/agent-core-v2/test/userTool/userTool.test.ts index 180363316..da551d981 100644 --- a/packages/agent-core-v2/test/userTool/userTool.test.ts +++ b/packages/agent-core-v2/test/userTool/userTool.test.ts @@ -129,6 +129,45 @@ describe('AgentUserToolService (wire-backed)', () => { ]); }); + it('inherits currently registered parent user tools into another agent service', async () => { + svc.register(toolA); + svc.register(toolB); + svc.unregister(toolB.name); + + const ixChild = disposables.add(new TestInstantiationService()); + ixChild.stub(IFileSystemStorageService, new InMemoryStorageService()); + ixChild.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ixChild.set( + IAgentWireService, + new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'user-tool-child' }]), + ); + ixChild.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); + const childProfile = createProfileStub(); + ixChild.stub(IAgentProfileService, childProfile); + ixChild.stub(ISessionInteractionService, createInteractionStub()); + ixChild.set(IAgentUserToolService, new SyncDescriptor(AgentUserToolService)); + + const child = ixChild.get(IAgentUserToolService); + const childWire = ixChild.get(IAgentWireService); + const childRegistry = ixChild.get(IAgentToolRegistryService); + child.inheritUserTools(svc); + + expect(child.list()).toEqual([toolA]); + expect(modelOf(childWire).get(toolA.name)).toEqual(toolA); + expect(modelOf(childWire).has(toolB.name)).toBe(false); + expect(childRegistry.resolve(toolA.name)).toBeDefined(); + expect(childProfile.active.has(toolA.name)).toBe(true); + expect(childProfile.active.has(toolB.name)).toBe(false); + + const childRecords: PersistedRecord[] = []; + for await (const record of ixChild + .get(IAppendLogStore) + .read(SCOPE, 'user-tool-child')) { + childRecords.push(record); + } + expect(childRecords).toEqual([{ type: 'tools.register_user_tool', ...toolA }]); + }); + it('re-registering an equal tool is a no-op on the model (same reference)', () => { svc.register(toolA); const before = modelOf(wire); From d966865f4cac625eaaae58897ba9f87adc8b5132 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:32:08 +0800 Subject: [PATCH 04/11] chore: add changeset for agent swarm parity --- .changeset/agent-swarm-behavior-parity.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agent-swarm-behavior-parity.md diff --git a/.changeset/agent-swarm-behavior-parity.md b/.changeset/agent-swarm-behavior-parity.md new file mode 100644 index 000000000..c2b43f7fa --- /dev/null +++ b/.changeset/agent-swarm-behavior-parity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Agent and AgentSwarm handling for resumed, background, and failed subagents. From f5a63ccc917e8268e39391420cae54ad39f0e65c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:37:33 +0800 Subject: [PATCH 05/11] fix: align v2 compaction prompt --- .../fullCompaction/compaction-instruction.md | 133 ++++++++++-------- .../test/fullCompaction/full.test.ts | 24 +++- .../agent-core-v2/test/harness/snapshots.ts | 2 +- 3 files changed, 89 insertions(+), 70 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md index 49b0d80b4..d137e3ca3 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md +++ b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md @@ -1,69 +1,78 @@ +You are about to run out of context. Write a first-person handoff note to +yourself so you can seamlessly continue this task after the earlier +conversation is cleared. --- This message is a direct task, not part of the above conversation --- -You are now given a task to compact this conversation context according to specific priorities and output requirements. +Write the note as your own continuing train of thought — first person, present +tense, the way you would reason through the next move. Do not write a +third-party report about someone else's work, and do not impose rigid section +headings; let the shape follow the task. Write the note in the same language the +conversation has been using — do not switch to English just because these +instructions happen to be in English. -Output text only. DO NOT CALL ANY TOOLS. Calling tools will be rejected and fails the task. You already have all the information you need in the conversation history. You have only one chance. +Make the note self-sufficient: the next turn will see only your most recent user +messages and this note — every assistant message, tool call, and tool result +above will be gone. In your own words, preserve what you genuinely need to +continue: -The goal of compaction is to keep essential code patterns, technical details, and architectural decisions for continuing development without losing context after the above messages are cleared work. +- What the latest request is actually asking for: your reading of its intent and + any ambiguity you have already resolved — not a re-transcription, since what + fits is kept verbatim in your most recent messages. But those kept messages are + size-capped, so a long request is truncated there: if the latest request is + large (a big paste or file), preserve the parts at risk of being dropped — + above all the actual ask. If several requests are in play, say which one governs + the next move, and re-quote any still-relevant earlier request that may have + scrolled out of the kept messages. +- The instructions and constraints currently in force (user preferences, + project rules, environment and tooling limits) — condensed to what still + matters, keeping decisions you have already settled (what you chose and why) + separate from questions still open, so you neither silently reopen a closed + choice nor treat an undecided point as decided. +- What has actually been done, at high fidelity: keep the exact commands that + were run, the exact file paths touched, and whether each succeeded or failed — + and the results themselves, not just the commands: the concrete values + returned, the key lines or error text, the schema or signature a lookup + revealed, since re-running to recover them may be slow or impossible. Keep only + the final working version of any code; drop intermediate attempts and + already-resolved errors. +- What you still don't know: context the next step depends on that this + conversation never established — files or paths referenced but not yet read, + schemas or APIs assumed but unseen, questions the user has not answered. Name + these gaps so the next turn goes and checks them instead of assuming. +- The forward plan — and this is the moment to invest in it. Right now you + hold more context on this task than you ever will again; the next turn + resumes with less, so the plan you commit here is the one it will follow. + Give the exact next command or tool call, but don't stop at the next step: + set out the remaining sequence to finish, the decisions you have already + made for those upcoming steps (so the next turn doesn't reopen them), the + obstacles or edge cases you can already foresee and how you mean to handle + them, and any work you can commit to now — the exact patch, query, or shape + of the final answer you already know you will produce. Anything you settle + here is one less thing the next turn must rediscover. Include any required + format for the final answer. +Your TODO list is re-attached automatically below this note from its live +source, so do not transcribe it — copying it wastes space and can contradict the +live version. What that list cannot hold is the reasoning between tasks — why one +was reordered or dropped, or a decision on one that constrains another — so +record that instead. + +Be honest about uncertainty. If an earlier step claimed something was done but +was never verified (tests "passing", a fix "working", a file "created"), say so +plainly and treat it as unverified rather than fact — re-check before relying +on it. + +Be concise, and keep the note proportional to the task: a long multi-step task +warrants detail, but a trivial or nearly finished exchange needs only a sentence +or two — do not pad it out. Include the critical data, identifiers, and +references needed to continue, and omit anything that does not change the next +move. + +Respond with text only. Do not call any tools — you already have everything you +need in the conversation history. + +{% if customInstruction %} +Optional user instruction: {{ customInstruction }} - - - -1. **Current Task State**: What is being worked on RIGHT NOW -2. **Errors & Solutions**: All encountered errors and their resolutions -3. **Code Evolution**: Final working versions only (remove intermediate attempts) -4. **System Context**: Project structure, dependencies, environment setup -5. **Design Decisions**: Architectural choices and their rationale -6. **TODO Items**: Unfinished tasks and known issues - - - -## Current Focus - -[What we're working on now] - -## Environment - -- [Key setup/config points] -- ... - -## Completed Tasks - -- [Task]: [Brief outcome] -- ... - -## Active Issues - -- [Issue]: [Status/Next steps] -- ... - -## Code State - -### [Critical file name] - -[Brief description of the file's purpose and current state] - -``` -[The latest version of critical code snippets in this file, <20 lines] -``` - -### [Critical file name] - -- [Useful classes/methods/functions]: [Brief description/usage] -- ... - - - -## Important Context - -- [Any crucial information not covered above] -- ... - -## All User Messages - -- [Detailed non tool use user message] -- ... - - +{% endif %} diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 62169cde5..5539de086 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -1605,8 +1605,10 @@ describe('FullCompaction', () => { expect(ctx.llmCalls).toHaveLength(2); const [compactionCall, answerCall] = ctx.llmCalls; - expect(messageText(compactionCall?.history.at(-1))).toContain('')) { + if (text.includes('first-person handoff note')) { return ''; } return JSON.stringify(text); From b676635a597ab5b708108406c116f9a0453226a7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:44:21 +0800 Subject: [PATCH 06/11] fix: recover v2 compaction from plain 413 --- .../fullCompaction/fullCompactionService.ts | 33 ++++- .../test/fullCompaction/full.test.ts | 133 +++++++++++++++++- 2 files changed, 161 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 51504d1b9..7f659a809 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -15,7 +15,11 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentTurnService } from '#/agent/turn/turn'; import { ISessionTodoService } from '#/session/todo/sessionTodo'; import { renderTodoList, type TodoItem } from '#/session/todo/todoItem'; -import { APIContextOverflowError, APIEmptyResponseError } from '#/app/llmProtocol/errors'; +import { + APIContextOverflowError, + APIEmptyResponseError, + APIStatusError, +} from '#/app/llmProtocol/errors'; import { createUserMessage, type Message } from '#/app/llmProtocol/message'; import { type TokenUsage } from '#/app/llmProtocol/usage'; import { IEventBus } from '#/app/event/eventBus'; @@ -62,6 +66,7 @@ declare module '#/agent/wireRecord/wireRecord' { export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024; +const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5; type CompactionTelemetryProperties = Record; @@ -222,7 +227,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull context: LoopErrorContext, next: () => Promise, ): Promise { - if (!isContextOverflowError(context.error)) { + const isOverflow = + isContextOverflowError(context.error) || this.shouldRecoverFromPlain413(context.error); + if (!isOverflow) { await next(); return; } @@ -428,7 +435,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull break; } catch (error) { if ( - error instanceof APIContextOverflowError || + this.shouldRecoverFromCompactionOverflow(error, messages) || error instanceof CompactionTruncatedError || error instanceof APIEmptyResponseError ) { @@ -516,6 +523,26 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private tokenCountWithPending(): number { return this.contextSize.get().size; } + + private shouldRecoverFromPlain413( + error: unknown, + estimatedRequestTokens = this.tokenCountWithPending(), + ): boolean { + if (!(error instanceof APIStatusError) || error.statusCode !== 413) return false; + const maxContextTokens = this.profile.getModelCapabilities().max_context_tokens; + return ( + maxContextTokens > 0 && + estimatedRequestTokens >= maxContextTokens * OVERFLOW_STATUS_RECOVERY_RATIO + ); + } + + private shouldRecoverFromCompactionOverflow( + error: unknown, + messages: readonly Message[], + ): boolean { + if (error instanceof APIContextOverflowError) return true; + return this.shouldRecoverFromPlain413(error, estimateTokensForMessages(messages)); + } } function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult { diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 5539de086..bcebc7361 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -666,6 +666,48 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('reduces the compacted prefix and retries when compaction receives plain 413', async () => { + vi.useFakeTimers(); + const firstAttemptFailed = deferred(); + let attempts = 0; + const inputs: string[][] = []; + const generate: GenerateFn = async (_provider, _system, _tools, history) => { + attempts += 1; + inputs.push(inputHistorySnapshot(history)); + if (attempts === 1) { + firstAttemptFailed.resolve(); + throw new APIStatusError(413, 'Request Entity Too Large', 'req-compact-plain-413'); + } + return textResult('Recovered compacted summary.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 20_000, + }, + }); + ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(45_000)}`, 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + const compacted = ctx.once('full_compaction.complete'); + const completed = ctx.once('compaction.completed'); + + await ctx.rpc.beginCompaction({}); + await firstAttemptFailed.promise; + await vi.advanceTimersByTimeAsync(10_000); + await compacted; + await completed; + + expect(inputs).toHaveLength(2); + expect(inputs[1]!.length).toBeLessThan(inputs[0]!.length); + const compactedHistory = ctx.compactHistory(); + expect(compactedHistory.some((message) => message.text.includes('old assistant one'))).toBe(false); + expect(compactedHistory.some((message) => message.text.includes('Recovered compacted summary.'))).toBe(true); + vi.useRealTimers(); + await ctx.expectResumeMatches(); + }); + it('fails after exhausting retries when the model only ever returns thinking content', async () => { // End-to-end through the real kosong generate(): every attempt is think-only, // so generate() keeps throwing APIEmptyResponseError. Compaction shrinks the @@ -1744,14 +1786,101 @@ describe('FullCompaction', () => { "user: ", ], [ - "assistant: Overflow compacted summary.", - "user: Retry after provider overflow", + "user: old user one + + Retry after provider overflow", + "user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. + Overflow compacted summary.", ], ] `); await ctx.expectResumeMatches(); }); + it('recovers from plain 413 when estimated request is over effective max', async () => { + let callCount = 0; + const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + callCount += 1; + if (callCount === 1) { + throw new APIStatusError(413, 'Request Entity Too Large', 'req-plain-413'); + } + if (callCount === 2) { + return textResult('Plain 413 compacted summary.'); + } + await callbacks?.onMessagePart?.({ + type: 'text', + text: 'Recovered after plain 413 compaction.', + }); + return textResult('Recovered after plain 413 compaction.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 200_000, + }, + }); + ctx.appendExchange(1, 'old user one', `old assistant one ${'x'.repeat(600_000)}`, 150_000); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after plain 413' }] }); + const events = await ctx.untilTurnEnd(); + + expect(callCount).toBe(3); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'compaction.started', + args: { trigger: 'auto' }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'compaction.completed', + args: expect.objectContaining({ + result: expect.objectContaining({ + summary: 'Plain 413 compacted summary.', + }), + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: { turnId: 0, reason: 'completed' }, + }), + ); + await ctx.expectResumeMatches(); + }); + + it('does not compact plain 413 when estimated request is small', async () => { + const generate: GenerateFn = async () => { + throw new APIStatusError(413, 'Request Entity Too Large', 'req-small-413'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 200_000, + }, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] }); + const events = await ctx.untilTurnEnd(); + + expect(eventIndex(events, 'compaction.started')).toBe(-1); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'turn.ended', + args: expect.objectContaining({ turnId: 0, reason: 'failed' }), + }), + ); + await ctx.expectResumeMatches(); + }); + it('does not reset the step budget after provider context overflow compaction', async () => { let callCount = 0; const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { From c34eeb75bde833245d236ff71329e79bb386ff0c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:50:54 +0800 Subject: [PATCH 07/11] fix: report v2 compaction retry telemetry --- .../src/agent/fullCompaction/fullCompactionService.ts | 6 ++++++ packages/agent-core-v2/test/fullCompaction/full.test.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 7f659a809..b56204bb9 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -427,6 +427,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull messages, maxOutputSize: compactionMaxOutputSize, source: { type: 'operation', requestKind: 'full_compaction' }, + retry: { + maxAttempts: MAX_COMPACTION_RETRY_ATTEMPTS, + onRetry: () => { + retryCount += 1; + }, + }, }, undefined, signal, diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index bcebc7361..0af940c65 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -786,6 +786,7 @@ describe('FullCompaction', () => { await compacted; expect(attempts).toBe(2); + vi.useRealTimers(); await ctx.expectResumeMatches(); }); @@ -817,6 +818,7 @@ describe('FullCompaction', () => { await vi.advanceTimersByTimeAsync(10_000); expect(attempts).toBe(1); + vi.useRealTimers(); await ctx.expectResumeMatches(); }); @@ -988,6 +990,7 @@ describe('FullCompaction', () => { error_type: 'APIConnectionError', }), }); + vi.useRealTimers(); await ctx.expectResumeMatches(); }); From ca237a02641eca762fa009d97a6636caac5b6ad6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:58:52 +0800 Subject: [PATCH 08/11] fix: align v2 compaction auth guards --- .../fullCompaction/fullCompactionService.ts | 8 ++- .../src/app/model/modelResolverService.ts | 4 +- .../test/fullCompaction/full.test.ts | 58 +++++++++++++++++-- .../test/model/modelResolver.test.ts | 24 ++++---- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index b56204bb9..c2895752b 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -115,7 +115,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, - @IAgentTurnService turnService: IAgentTurnService, + @IAgentTurnService private readonly turn: IAgentTurnService, @IAgentLoopService loopService: IAgentLoopService, ) { super(); @@ -158,6 +158,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return false; const history = this.context.get(); + if (data.source === 'manual' && this.turn.getActiveTurn() !== undefined) { + throw new KimiError( + ErrorCodes.COMPACTION_UNABLE, + 'Cannot compact while a turn is active. Wait for it to finish, then retry.', + ); + } const tokenCount = estimateTokensForMessages(history); const compactedCount = this.strategy.computeCompactCount(history, data.source); if (compactedCount === 0) { diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index f35d81099..8081fc08b 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -285,7 +285,9 @@ export class ModelResolverService extends Disposable implements IModelResolver { async getAuth(options): Promise { const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef); if (tokenProvider === undefined) throw loginRequired(); - const apiKey = await tokenProvider.getAccessToken({ force: options?.force ?? false }); + const apiKey = await tokenProvider.getAccessToken( + options?.force === true ? { force: true } : undefined, + ); if (apiKey.trim().length === 0) throw loginRequired(); return { apiKey }; }, diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 0af940c65..c657cf455 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -19,7 +19,7 @@ import { microCompactionFlag } from '#/agent/microCompaction/flag'; import { estimateTokensForMessages } from '#/_base/utils/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness'; -import { appServices, testAgent } from '../harness'; +import { appServices, createCommandRunner, execEnvServices, testAgent } from '../harness'; import { IAgentFullCompactionService, IAgentMicroCompactionService, @@ -27,6 +27,7 @@ import { IAgentProfileService, ISessionTodoService, } from '#/index'; +import { IAgentTurnService } from '#/agent/turn/turn'; type GenerateFn = NonNullable; @@ -273,7 +274,7 @@ describe('FullCompaction', () => { compacted_count: 6, retry_count: 0, thinking_level: 'off', - input_other: 520, + input_other: 1181, output: 8, input_cache_read: 0, input_cache_creation: 0, @@ -282,6 +283,36 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('rejects a manual compaction while a turn is active', async () => { + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') })); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + tools: ['Bash'], + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall()); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] }); + const approval = await ctx.takeApprovalRequest(); + expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeDefined(); + + await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({ + code: 'compaction.unable', + message: 'Cannot compact while a turn is active. Wait for it to finish, then retry.', + }); + const events = ctx.newEvents(); + expect(eventIndex(events, 'full_compaction.begin')).toBe(-1); + expect(eventIndex(events, 'compaction.started')).toBe(-1); + expect(ctx.get(IAgentFullCompactionService).compacting).toBeNull(); + expect(ctx.llmCalls).toHaveLength(1); + + ctx.mockNextResponse({ type: 'text', text: 'Turn done.' }); + approval.respond({ decision: 'rejected', selectedLabel: 'reject' }); + await ctx.untilTurnEnd(); + expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeUndefined(); + }); + it('projects the compacted prefix before sending the summary request', async () => { const ctx = testAgent(); ctx.configure({ @@ -434,7 +465,12 @@ describe('FullCompaction', () => { expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']); expect(tokenCalls).toEqual([undefined, true, undefined]); expect(ctx.compactHistory()).toEqual([ - { role: 'assistant', text: 'Recovered compacted summary.' }, + { role: 'user', text: 'old user one' }, + { role: 'user', text: 'recent user two' }, + { + role: 'user', + text: expect.stringContaining('Recovered compacted summary.'), + }, ]); await ctx.expectResumeMatches(); }); @@ -1595,7 +1631,12 @@ describe('FullCompaction', () => { expect(ctx.llmCalls).toHaveLength(1); expect(ctx.compactHistory()).toEqual([ - { role: 'assistant', text: 'Compacted after no-op cancel.' }, + { role: 'user', text: 'old user one' }, + { role: 'user', text: 'recent user two' }, + { + role: 'user', + text: expect.stringContaining('Compacted after no-op cancel.'), + }, ]); await ctx.expectResumeMatches(); }); @@ -2490,6 +2531,15 @@ function textMessage(role: 'user' | 'assistant', text: string): Message { }; } +function bashCall(): ToolCall { + return { + type: 'function', + id: 'call_bash', + name: 'Bash', + arguments: JSON.stringify({ command: 'printf should-not-run', timeout: 60 }), + }; +} + function messageText(message: Message | undefined): string { return message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; } diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts index beefba2dd..880acfd1e 100644 --- a/packages/agent-core-v2/test/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/model/modelResolver.test.ts @@ -299,12 +299,12 @@ describe('ModelResolverService', () => { it('force-refreshes OAuth credentials and replays a request after 401', async () => { configureOAuthModel(); - const tokenCalls: boolean[] = []; + const tokenCalls: Array = []; const authKeys: string[] = []; resolveTokenProvider.mockReturnValue({ - getAccessToken: async (options: { readonly force: boolean }) => { - tokenCalls.push(options.force); - return options.force ? 'forced-refresh-token' : 'fresh-token'; + getAccessToken: async (options?: { readonly force?: boolean }) => { + tokenCalls.push(options?.force); + return options?.force === true ? 'forced-refresh-token' : 'fresh-token'; }, }); generateImpl = async (_system, _tools, _history, options) => { @@ -333,7 +333,7 @@ describe('ModelResolverService', () => { } expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']); - expect(tokenCalls).toEqual([false, true]); + expect(tokenCalls).toEqual([undefined, true]); expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'recovered' } }); }); @@ -341,8 +341,8 @@ describe('ModelResolverService', () => { configureOAuthModel(); const authKeys: string[] = []; resolveTokenProvider.mockReturnValue({ - getAccessToken: async (options: { readonly force: boolean }) => - options.force ? 'forced-refresh-token' : 'fresh-token', + getAccessToken: async (options?: { readonly force?: boolean }) => + options?.force === true ? 'forced-refresh-token' : 'fresh-token', }); generateImpl = async (_system, _tools, _history, options) => { authKeys.push(options?.auth?.apiKey ?? ''); @@ -393,12 +393,12 @@ describe('ModelResolverService', () => { it('force-refreshes OAuth credentials and replays video upload after 401', async () => { configureOAuthModel(); - const tokenCalls: boolean[] = []; + const tokenCalls: Array = []; const authKeys: string[] = []; resolveTokenProvider.mockReturnValue({ - getAccessToken: async (options: { readonly force: boolean }) => { - tokenCalls.push(options.force); - return options.force ? 'forced-refresh-token' : 'fresh-token'; + getAccessToken: async (options?: { readonly force?: boolean }) => { + tokenCalls.push(options?.force); + return options?.force === true ? 'forced-refresh-token' : 'fresh-token'; }, }); uploadVideoImpl = async (_input, options) => { @@ -416,7 +416,7 @@ describe('ModelResolverService', () => { videoUrl: { url: 'https://example.test/video' }, }); expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']); - expect(tokenCalls).toEqual([false, true]); + expect(tokenCalls).toEqual([undefined, true]); }); }); From d1a275de8f893c3cd3eade90fc7ca1c8626aa4da Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 18:00:44 +0800 Subject: [PATCH 09/11] fix: align v2 grep behavior with v1 --- .../src/app/agentProfileCatalog/system.md | 2 +- .../src/os/backends/node-local/tools/bash.md | 10 +-- .../src/os/backends/node-local/tools/grep.ts | 3 +- .../src/os/backends/node-local/tools/read.md | 4 +- .../os/backends/node-local/tools/rgLocator.ts | 45 ++++++---- .../agent-core-v2/test/fileTools/grep.test.ts | 85 +++++++++++++++++-- .../test/fileTools/rgLocator.test.ts | 76 ++++++++++------- 7 files changed, 163 insertions(+), 62 deletions(-) diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md index d1102d395..1d22556c0 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md @@ -84,7 +84,7 @@ The current working directory is `{{ KIMI_WORK_DIR }}`. This should be considere Use this as your basic understanding of the project structure. The tree only shows the first two levels for normal directories; entries marked "... and N more" indicate additional contents. Hidden directories are shown as entries only; their contents are intentionally omitted to reduce noise. -To inspect hidden paths the tree leaves out, prefer the dedicated tools over `ls -A`. `Glob` matches dotfiles by default — use `.*` for top-level dotfiles, or anchor on a directory such as `.github/**` or `.agents/**` to walk it; avoid bare `.git/**` or `node_modules/**`, which `Glob` traverses in full and will hit its result cap. Use `Read` for a known hidden file and `Grep` to search hidden file contents. `Grep` searches hidden files by default but skips VCS metadata (`.git` and the like) and filters secrets out of its results; `Read`, `Write`, and `Edit` refuse a fixed set of well-known secret files — `.env`, SSH private keys, and a few credential files — by design; that guard does not recognize every secret format, so judge other credential-bearing files yourself. `Bash` enforces none of these path or secret guards — it runs whatever command you give it — so the same discipline is on you there: do not use shell commands (`cat`, `cp`, `curl`, and the like) to read, copy, or transmit secret files, and stay inside the working directory unless the user has explicitly directed otherwise. +To inspect hidden paths the tree leaves out, prefer the dedicated tools over `ls -A`. `Glob` matches dotfiles by default — use `.*` for top-level dotfiles, or anchor on a directory such as `.github/**` or `.agents/**` to walk it; avoid bare `node_modules/**`-style dependency walks, which can flood the result cap; `.git/**` returns nothing at all — `Glob`, like `Grep`, always skips VCS metadata. Use `Read` for a known hidden file and `Grep` to search hidden file contents. `Grep` searches hidden files by default but skips VCS metadata (`.git` and the like) and filters secrets out of its results; `Read`, `Write`, and `Edit` refuse a fixed set of well-known secret files — `.env`, SSH private keys, and a few credential files — by design; that guard does not recognize every secret format, so judge other credential-bearing files yourself. `Bash` enforces none of these path or secret guards — it runs whatever command you give it — so the same discipline is on you there: do not use shell commands (`cat`, `cp`, `curl`, and the like) to read, copy, or transmit secret files, and stay inside the working directory unless the user has explicitly directed otherwise. The directory listing of current working directory is: diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md index a2afff7b4..cb237f917 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md @@ -11,19 +11,19 @@ Execute a `{{ SHELL_NAME }}` command. Use this for shell semantics — pipes, en The dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits. **Output:** -The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the output will end with a `Command failed with exit code: N` line stating the non-zero exit code. +The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead. -If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. +If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. **Guidelines for safety and security:** -- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. +- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call. - The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s. - Avoid using `..` to access files or directories outside of the working directory. - Avoid modifying files outside of the working directory unless explicitly instructed to do so. - Never run commands that require superuser privileges unless explicitly instructed to do so. **Guidelines for efficiency:** -- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la` +- Use `&&` to chain commands that genuinely depend on each other, e.g. `npm install && npm test`. Independent read-only commands (separate `git show`, `ls`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with `echo` separators. - Use `;` to run commands sequentially regardless of success/failure - Use `||` for conditional execution (run second command only if first fails) - Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands @@ -38,6 +38,6 @@ The following common command categories are usually available. Availability stil - Text and data processing: `wc`, `sort`, `uniq`, `cut`, `tr`, `diff`, `xargs` - Archives and compression: `tar`, `gzip`, `gunzip`, `zip`, `unzip` - Networking and transfer: `curl`, `wget`, `ping`, `ssh`, `scp` -- Version control: `git` +- Version control: `git`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the `gh` CLI when installed — it carries the user's GitHub auth and can return structured JSON - Process and system: `ps`, `kill`, `top`, `env`, `date`, `uname`, `whoami` - Language and package toolchains: `node`, `npm`, `pnpm`, `yarn`, `python`, `pip` (use whichever the project actually relies on) diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts index 90c306a3d..761069cf7 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/grep.ts @@ -442,7 +442,8 @@ export class GrepTool implements BuiltinTool { let mtime = 0; if (path !== undefined) { try { - mtime = (await this.fs.stat(path)).mtimeMs ?? 0; + const mtimeMs = (await this.fs.stat(path)).mtimeMs ?? 0; + mtime = Math.trunc(mtimeMs / 1000); } catch { // Keep stat failures visible; use mtime=0 so they sort after known files. } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/read.md b/packages/agent-core-v2/src/os/backends/node-local/tools/read.md index 79bdda810..6fbaeaef0 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/read.md +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/read.md @@ -1,13 +1,13 @@ Read a text file from the local filesystem. -If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files/directories matching a pattern. Use `Grep` only when the task is to search for unknown content or locations. +If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files matching a name pattern (Glob lists files only, never directories). Use `Grep` only when the task is to search for unknown content or locations. When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn. - Relative paths resolve against the working directory; a path outside the working directory must be absolute. - Returns up to {{ MAX_LINES }} lines or {{ MAX_BYTES_KB }} KB per call, whichever comes first; lines longer than {{ MAX_LINE_LENGTH }} chars are truncated mid-line. - Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the {{ MAX_LINES }}-line cap. -- Sensitive files (`.env` files, credential stores, SSH keys, and similar secrets) are refused to protect secrets; do not attempt to read them. +- Sensitive files (`.env` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: `.env.example` / `.env.sample` / `.env.template` and public SSH keys such as `id_rsa.pub` read normally. - Only UTF-8 text files can be read. Non-UTF-8 encodings, binary files, and files containing NUL bytes are refused; use `ReadMediaFile` for images or video, and Bash or an MCP tool for other binary formats. - Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed {{ MAX_LINES }}. - Output format: `\t` per line. diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts index e18bff523..40f970569 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/rgLocator.ts @@ -1,16 +1,16 @@ /** * `fileTools` domain — shared ripgrep (`rg`) binary locator. * - * Resolves the `rg` command used by Glob and Grep through a caller-supplied - * process probe, preferring the execution-environment PATH, then the vendor - * hook, then the app cache, and finally bootstrapping a pinned ripgrep - * archive into `/bin` when the caller permits it. - * Keeps callers that own a no-download fallback path opt-in-compatible. + * Resolves the `rg` command used by Glob and Grep, preferring a file found on + * PATH, then the vendor hook, then the app cache, and finally bootstrapping a + * pinned ripgrep archive into `/bin` when the + * caller permits it. File lookup intentionally avoids spawning `rg --version` + * so tool resolution has the same observable shape as v1. */ import { createHash } from 'node:crypto'; import { createWriteStream, existsSync } from 'node:fs'; -import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; @@ -105,22 +105,20 @@ async function resolveRgPath( } export async function findExistingRg( - probe: RgProbe, + _probe: RgProbe, shareDir: string = getShareDir(), allowCachedFallback = true, ): Promise { - const system = await probe.exec(['rg', '--version']).catch(() => ({ exitCode: -1 })); - if (system.exitCode === 0) { - return { path: 'rg', source: 'system-path' }; - } + const system = await findRgOnPath(); + if (system !== undefined) return { path: system, source: 'system-path' }; if (allowCachedFallback) { const vendorPath = getVendorRgPath(rgBinaryName()); - if (vendorPath !== undefined && (await isUsableRg(probe, vendorPath))) { + if (vendorPath !== undefined && (await isExecutableFile(vendorPath))) { return { path: vendorPath, source: 'vendor' }; } const cachePath = join(shareDir, 'bin', rgBinaryName()); - if (await isUsableRg(probe, cachePath)) { + if (await isExecutableFile(cachePath)) { return { path: cachePath, source: 'share-bin-cached' }; } } @@ -148,9 +146,24 @@ function getVendorRgPath(_binName: string): string | undefined { return undefined; } -async function isUsableRg(probe: RgProbe, path: string): Promise { - const run = await probe.exec([path, '--version']).catch(() => ({ exitCode: -1 })); - return run.exitCode === 0; +async function findRgOnPath(): Promise { + const pathEnv = process.env['PATH'] ?? ''; + const sep = process.platform === 'win32' ? ';' : ':'; + const binName = rgBinaryName(); + for (const dir of pathEnv.split(sep)) { + if (dir === '') continue; + const candidate = join(dir, binName); + if (await isExecutableFile(candidate)) return candidate; + } + return undefined; +} + +async function isExecutableFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } } export function detectTarget(): string | undefined { diff --git a/packages/agent-core-v2/test/fileTools/grep.test.ts b/packages/agent-core-v2/test/fileTools/grep.test.ts index 1c76eb157..7a8c7ac1f 100644 --- a/packages/agent-core-v2/test/fileTools/grep.test.ts +++ b/packages/agent-core-v2/test/fileTools/grep.test.ts @@ -2,20 +2,34 @@ import { Readable, type Writable } from 'node:stream'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult, ToolExecution, } from '#/agent/tool/toolContract'; -import { noopTelemetryService, type ITelemetryService } from '#/app/telemetry/telemetry'; +import { + AgentBuiltinToolsRegistrar, + IAgentBuiltinToolsRegistrar, +} from '#/agent/toolRegistry/builtinToolsRegistrar'; +import { + _clearToolContributionsForTests, + getToolContributions, + registerTool, +} from '#/agent/toolRegistry/toolContribution'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import type { PathClass } from '#/_base/execEnv/environmentProbe'; import { PathSecurityError } from '#/_base/tools/policies/path-access'; import { SENSITIVE_DOT_VARIANT_SUFFIXES } from '#/_base/tools/policies/sensitive'; import type { WorkspaceConfig } from '#/_base/tools/support/workspace'; -import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; -import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; -import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; +import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { type GrepInput, GrepInputSchema, @@ -177,7 +191,7 @@ function statResult(mtime: number): HostFileStat { isFile: true, isDirectory: false, size: 0, - mtimeMs: mtime, + mtimeMs: mtime * 1000, }; } @@ -264,6 +278,41 @@ afterEach(() => { }); describe('GrepTool', () => { + it('registers through the production tool contribution and DI path', () => { + const savedContributions = [...getToolContributions()]; + const disposables = new DisposableStore(); + try { + _clearToolContributionsForTests(); + registerTool(ProductionGrepTool); + + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + const kaos = createFakeKaos(); + reg.defineInstance(IHostProcessService, createTestProcessService(kaos)); + reg.defineInstance(IHostFileSystem, createTestFs(kaos)); + reg.defineInstance(IHostEnvironment, createTestEnv(kaos)); + reg.defineInstance(ISessionWorkspaceContext, stubWorkspaceContext('/workspace')); + reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.define(IAgentToolRegistryService, AgentToolRegistryService); + reg.define(IAgentBuiltinToolsRegistrar, AgentBuiltinToolsRegistrar); + }, + }); + + ix.get(IAgentBuiltinToolsRegistrar); + const tool = ix.get(IAgentToolRegistryService).resolve('Grep'); + + expect(tool).toBeInstanceOf(ProductionGrepTool); + expect(tool?.name).toBe('Grep'); + } finally { + disposables.dispose(); + _clearToolContributionsForTests(); + for (const contribution of savedContributions) { + registerTool(contribution.ctor, contribution.options); + } + } + }); + it('exposes current metadata and schema', () => { const tool = new GrepTool(createFakeKaos(), workspace); @@ -511,6 +560,32 @@ describe('GrepTool', () => { expect(stat).toHaveBeenCalledWith('/workspace/src/new.ts'); }); + it('keeps v1 tie order for files modified within the same second', async () => { + const stdout = ['/workspace/src/a.ts', '/workspace/src/b.ts', '/workspace/src/c.ts', ''].join( + '\n', + ); + const stat = vi.fn(async (path: string) => { + if (path === '/workspace/src/a.ts') { + return { ...statResult(1), mtimeMs: 1000 }; + } + if (path === '/workspace/src/b.ts') { + return { ...statResult(1), mtimeMs: 1500 }; + } + if (path === '/workspace/src/c.ts') { + return { ...statResult(2), mtimeMs: 2000 }; + } + throw new Error(`unexpected stat: ${path}`); + }); + const tool = new GrepTool( + createFakeKaos({ exec: vi.fn().mockResolvedValue(processWithOutput(stdout)), stat }), + { workspaceDir: '/workspace', additionalDirs: [] }, + ); + + const result = await executeTool(tool, context({ pattern: 'hit', head_limit: 0 })); + + expect(toolContentString(result)).toBe(['src/c.ts', 'src/a.ts', 'src/b.ts'].join('\n')); + }); + it('limits concurrent mtime stats while sorting files_with_matches', async () => { const filePaths = Array.from( { length: 40 }, diff --git a/packages/agent-core-v2/test/fileTools/rgLocator.test.ts b/packages/agent-core-v2/test/fileTools/rgLocator.test.ts index 523005abc..3c1e277c4 100644 --- a/packages/agent-core-v2/test/fileTools/rgLocator.test.ts +++ b/packages/agent-core-v2/test/fileTools/rgLocator.test.ts @@ -57,12 +57,20 @@ function deferred(): { describe('findExistingRg', () => { let fakeShare: string; + let savedPath: string | undefined; beforeEach(() => { fakeShare = join(tmpdir(), `kimi-rg-${String(Date.now())}-${String(Math.random()).slice(2)}`); mkdirSync(join(fakeShare, 'bin'), { recursive: true }); + savedPath = process.env['PATH']; + process.env['PATH'] = ''; }); afterEach(() => { rmSync(fakeShare, { recursive: true, force: true }); + if (savedPath === undefined) { + delete process.env['PATH']; + } else { + process.env['PATH'] = savedPath; + } }); it('returns undefined when no rg anywhere', async () => { @@ -72,18 +80,27 @@ describe('findExistingRg', () => { it('resolves from share-dir when cached', async () => { const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg'); - const probe = probeWith((args) => (args[0] === cached ? 0 : -1)); + writeFileSync(cached, 'fake rg'); + const probe = noRgProbe(); const result = await findExistingRg(probe, fakeShare); expect(result).toEqual({ path: cached, source: 'share-bin-cached' }); + expect(probe.exec).not.toHaveBeenCalled(); }); it('prefers system PATH over share-dir when both are available', async () => { - const probe = probeWith((args) => (args[0] === 'rg' ? 0 : -1)); + const binDir = join(fakeShare, 'path-bin'); + mkdirSync(binDir, { recursive: true }); + const systemRg = join(binDir, process.platform === 'win32' ? 'rg.exe' : 'rg'); + const cached = join(fakeShare, 'bin', process.platform === 'win32' ? 'rg.exe' : 'rg'); + writeFileSync(systemRg, 'fake system rg'); + writeFileSync(cached, 'fake cached rg'); + process.env['PATH'] = binDir; + const probe = noRgProbe(); const result = await findExistingRg(probe, fakeShare); - expect(result).toEqual({ path: 'rg', source: 'system-path' }); - expect(probe.exec).toHaveBeenCalledTimes(1); + expect(result).toEqual({ path: systemRg, source: 'system-path' }); + expect(probe.exec).not.toHaveBeenCalled(); }); }); @@ -181,6 +198,7 @@ describe('verifyArchiveChecksum', () => { describe('ensureRgPath download branch', () => { let fakeShare: string; let savedFetch: typeof globalThis.fetch | undefined; + let savedPath: string | undefined; beforeEach(() => { fakeShare = join( tmpdir(), @@ -188,6 +206,8 @@ describe('ensureRgPath download branch', () => { ); mkdirSync(join(fakeShare, 'bin'), { recursive: true }); savedFetch = globalThis.fetch; + savedPath = process.env['PATH']; + process.env['PATH'] = ''; }); afterEach(() => { rmSync(fakeShare, { recursive: true, force: true }); @@ -196,6 +216,11 @@ describe('ensureRgPath download branch', () => { } else { globalThis.fetch = savedFetch; } + if (savedPath === undefined) { + delete process.env['PATH']; + } else { + process.env['PATH'] = savedPath; + } vi.restoreAllMocks(); }); @@ -231,36 +256,15 @@ describe('ensureRgPath download branch', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('does not start bootstrap work when aborted after lookup misses', async () => { - const controller = new AbortController(); - const fetchMock = vi.fn(() => new Promise(() => {})); - globalThis.fetch = fetchMock as unknown as typeof fetch; - const cacheProbe = deferred<{ readonly exitCode: number }>(); - const probe: RgProbe & { exec: ReturnType } = { - exec: vi - .fn() - .mockResolvedValueOnce({ exitCode: -1 }) - .mockReturnValueOnce(cacheProbe.promise), - }; + it('does not run probe subprocesses while lookup misses', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('network unreachable')) as typeof fetch; + const probe = noRgProbe(); - const resultPromise = ensureRgPath(probe, { - shareDir: fakeShare, - signal: controller.signal, - allowCachedFallback: true, - }); + await expect( + ensureRgPath(probe, { shareDir: fakeShare, allowCachedFallback: true }), + ).rejects.toThrow(/network unreachable/); - await vi.waitFor(() => { - expect(probe.exec).toHaveBeenCalledTimes(2); - }); - controller.abort(); - await expect(resultPromise).rejects.toHaveProperty('name', 'AbortError'); - - cacheProbe.resolve({ exitCode: -1 }); - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - - expect(fetchMock).not.toHaveBeenCalled(); + expect(probe.exec).not.toHaveBeenCalled(); }); it('aborts the current caller wait while shared bootstrap work continues', async () => { @@ -376,6 +380,7 @@ describe('ensureRgPath Windows download branch', () => { let savedFetch: typeof globalThis.fetch | undefined; let savedArch: string; let savedPlatform: string; + let savedPath: string | undefined; beforeEach(() => { fakeShare = join( tmpdir(), @@ -383,6 +388,8 @@ describe('ensureRgPath Windows download branch', () => { ); mkdirSync(join(fakeShare, 'bin'), { recursive: true }); savedFetch = globalThis.fetch; + savedPath = process.env['PATH']; + process.env['PATH'] = ''; savedArch = process.arch; savedPlatform = process.platform; Object.defineProperty(process, 'arch', { value: 'x64' }); @@ -395,6 +402,11 @@ describe('ensureRgPath Windows download branch', () => { } else { globalThis.fetch = savedFetch; } + if (savedPath === undefined) { + delete process.env['PATH']; + } else { + process.env['PATH'] = savedPath; + } Object.defineProperty(process, 'arch', { value: savedArch }); Object.defineProperty(process, 'platform', { value: savedPlatform }); vi.restoreAllMocks(); From d1c70e53db9d66470c1ada1b183d02e5369a1e0f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 18:06:43 +0800 Subject: [PATCH 10/11] chore: remove agent swarm changeset --- .changeset/agent-swarm-behavior-parity.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/agent-swarm-behavior-parity.md diff --git a/.changeset/agent-swarm-behavior-parity.md b/.changeset/agent-swarm-behavior-parity.md deleted file mode 100644 index c2b43f7fa..000000000 --- a/.changeset/agent-swarm-behavior-parity.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix Agent and AgentSwarm handling for resumed, background, and failed subagents. From 65ad6ab5acf791d76fbbd1454340a424181594aa Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 18:07:44 +0800 Subject: [PATCH 11/11] fix(agent-core-v2): restore task resume parity --- .../src/agent/task/taskService.ts | 58 +++++++-- .../agent-core-v2/src/wire/wireService.ts | 2 +- .../agent-core-v2/src/wire/wireServiceImpl.ts | 21 ++- packages/agent-core-v2/test/harness/agent.ts | 123 +++++++++++++++--- .../agent-core-v2/test/store/store.test.ts | 6 +- .../test/task/rpc-events.test.ts | 12 +- .../agent-core-v2/test/task/service.test.ts | 14 ++ .../test/wireRecord/resume.test.ts | 25 ++-- 8 files changed, 209 insertions(+), 52 deletions(-) diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index d83a613d3..19f930cbe 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -36,6 +36,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAgentWireRecordService, type WireRecord } from '#/agent/wireRecord/wireRecord'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; import { @@ -150,6 +151,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IFileSystemStorageService byteStore: IFileSystemStorageService, @ISessionContext session: ISessionContext, @ITaskService private readonly taskService: ITaskService, + @IAgentWireRecordService wireRecord: IAgentWireRecordService, @IAgentWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, ) { @@ -170,20 +172,28 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } }), ); + this._register( + wireRecord.hooks.onRestoredRecord.register( + 'task-delivered-notifications', + async (ctx, next) => { + this.markDeliveredNotificationsFromRecord(ctx.record); + await next(); + }, + ), + ); } - private restoreAfterReplay(): void { + private async restoreAfterReplay(): Promise { // `wire.replay` has rebuilt `TaskModel` from the persisted task.started / // task.terminated records. Seed the restored "ghosts" from it first (the // wire-replay contribution), THEN load from disk and reconcile — all inside // this single onRestored handler so the ordering (wire ghosts -> disk // ghosts -> reconcile) holds. loadFromDisk / reconcile are async (disk - // I/O); they chain here and are never split across two hooks (splitting - // would lose or duplicate tasks). They run fire-and-forget relative to the - // synchronous `wire.replay`: there is no auto-started turn on resume, so - // the local-disk reconciliation completes before the first RPC turn. + // I/O); awaiting them keeps restore observable only after task state has + // reached the same shape as v1's resumed background-task manager. this.restoreGhostsFromWire(); - void this.loadFromDisk({ replace: false }).then(() => this.reconcile()); + await this.loadFromDisk({ replace: false }); + await this.reconcile(); } private restoreGhostsFromWire(): void { @@ -193,6 +203,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } } + private markDeliveredNotificationsFromRecord(record: WireRecord): void { + for (const origin of taskOriginsFromRecord(record)) { + this.markDeliveredNotification(origin); + } + } + registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string { const detached = options.detached ?? true; const timeoutMs = options.timeoutMs ?? task.timeoutMs; @@ -1028,14 +1044,40 @@ function newerRestoredTask( function isTaskOrigin(origin: unknown): origin is TaskOrigin { if (typeof origin !== 'object' || origin === null) return false; - const kind = (origin as { kind?: unknown }).kind; - return kind === 'task'; + const value = origin as Record; + return ( + value['kind'] === 'task' && + typeof value['taskId'] === 'string' && + typeof value['status'] === 'string' && + typeof value['notificationId'] === 'string' + ); } function notificationKey(origin: TaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } +function taskOriginsFromRecord(record: WireRecord): readonly TaskOrigin[] { + const raw = record as { + readonly type: string; + readonly message?: unknown; + readonly messages?: unknown; + }; + if (raw.type === 'context.append_message') { + return taskOriginFromMessage(raw.message); + } + if (raw.type === 'context.splice' && Array.isArray(raw.messages)) { + return raw.messages.flatMap(taskOriginFromMessage); + } + return []; +} + +function taskOriginFromMessage(message: unknown): readonly TaskOrigin[] { + if (typeof message !== 'object' || message === null) return []; + const origin = (message as { readonly origin?: unknown }).origin; + return isTaskOrigin(origin) ? [origin] : []; +} + function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { const baseLine = info.status === 'timed_out' diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 15e64f237..b11786f21 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -66,5 +66,5 @@ export interface IWireService { handler: (state: DeepReadonly, prev: DeepReadonly) => void, ): IDisposable; onEmission(handler: (emission: WireEmission) => void): IDisposable; - onRestored(handler: () => void): IDisposable; + onRestored(handler: () => void | Promise): IDisposable; } diff --git a/packages/agent-core-v2/src/wire/wireServiceImpl.ts b/packages/agent-core-v2/src/wire/wireServiceImpl.ts index 5cd716e16..b59ff675c 100644 --- a/packages/agent-core-v2/src/wire/wireServiceImpl.ts +++ b/packages/agent-core-v2/src/wire/wireServiceImpl.ts @@ -45,7 +45,7 @@ * Scope-agnostic. */ -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { Emitter } from '#/_base/event'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; @@ -103,7 +103,7 @@ export class WireService extends Disposable implements IWireService { private readonly derivedModels = new Map, ModelInstance>(); private readonly reducerIndex = new Map(); private readonly emissionEmitter = this._register(new Emitter()); - private readonly restoredEmitter = this._register(new Emitter()); + private readonly restoredHandlers = new Set<() => void | Promise>(); private dispatching = false; private queue: Op[] = []; @@ -147,8 +147,9 @@ export class WireService extends Disposable implements IWireService { return this.emissionEmitter.event(handler); } - onRestored(handler: () => void): IDisposable { - return this.restoredEmitter.event(handler); + onRestored(handler: () => void | Promise): IDisposable { + this.restoredHandlers.add(handler); + return toDisposable(() => this.restoredHandlers.delete(handler)); } attach(model: DerivedModelDef): IDisposable { @@ -214,7 +215,7 @@ export class WireService extends Disposable implements IWireService { } this.execute({ ops, silent: true }); await this.rehydrateModels(); - this.restoredEmitter.fire(undefined); + await this.fireRestored(); } async flush(): Promise { @@ -285,6 +286,16 @@ export class WireService extends Disposable implements IWireService { return { type: op.type, payload }; } + private async fireRestored(): Promise { + for (const handler of Array.from(this.restoredHandlers)) { + try { + await handler(); + } catch (error) { + onUnexpectedError(error); + } + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private appendToWireLog(record: PersistedRecord, model: ModelDef): void { if (this.log === undefined) return; diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index e5e893651..84c8c1b87 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -557,18 +557,22 @@ export function questionServices(service: ISessionQuestionService): TestAgentSer export function externalHookServices( hookRunner: Pick | undefined, ): TestAgentServiceOverride { - const runner: IExternalHooksRunnerService = - hookRunner === undefined - ? noopHookRunner - : isRunnerLike(hookRunner) - ? hookRunner - : { ...noopHookRunner, ...hookRunner }; return [ - appService(IExternalHooksRunnerService, runner), + appService(IExternalHooksRunnerService, resolveExternalHooksRunner(hookRunner)), agentService(IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService)), ]; } +function resolveExternalHooksRunner( + hookRunner: Pick | undefined, +): IExternalHooksRunnerService { + return hookRunner === undefined + ? noopHookRunner + : isRunnerLike(hookRunner) + ? hookRunner + : { ...noopHookRunner, ...hookRunner }; +} + function isRunnerLike( value: Pick, ): value is IExternalHooksRunnerService { @@ -979,6 +983,12 @@ export class AgentTestContext { if (options.telemetry !== undefined) { reg.defineInstance(ITelemetryService, options.telemetry); } + if (options.hookEngine !== undefined) { + reg.defineInstance( + IExternalHooksRunnerService, + resolveExternalHooksRunner(options.hookEngine), + ); + } reg.defineInstance(IHostTerminalService, createHostTerminalService()); // The real `HostEnvironmentService` probes the host asynchronously (`ready`); // builtin tools (e.g. `BashTool`) read `osKind`/`shellName` synchronously at @@ -1065,9 +1075,7 @@ export class AgentTestContext { ); reg.defineDescriptor( IAgentExternalHooksService, - new SyncDescriptor(AgentExternalHooksService, [ - options.hookEngine === undefined ? {} : { hookEngine: options.hookEngine }, - ]), + new SyncDescriptor(AgentExternalHooksService), ); reg.defineDescriptor( IAgentMicroCompactionService, @@ -1181,7 +1189,6 @@ export class AgentTestContext { private async replayRestoredRecordsSince(restoredStart: number): Promise { const restored = this.wireRecord.getRecords().slice(restoredStart); - if (restored.length === 0) return; await this.get(IAgentWireService).replay(...(restored as readonly PersistedRecord[])); } @@ -1545,6 +1552,7 @@ export class AgentTestContext { await this.drainWirePersistence(); const profile = this.get(IAgentProfileService); const configSnapshot = structuredClone(this.get(IConfigService).getAll() as KimiConfig); + let resumedThroughRecord = this.recordHistory.length; const resumed = createTestAgent( { autoConfigure: false, cwd: profile.data().cwd }, ...this.serviceOverrides, @@ -1558,6 +1566,13 @@ export class AgentTestContext { try { await resumed.restorePersisted(); await resumed.waitForSessionMetadata(); + for (let i = 0; i < 5; i += 1) { + await this.drainWirePersistence(); + if (this.recordHistory.length === resumedThroughRecord) break; + const nextRecords = this.recordHistory.slice(resumedThroughRecord).map(cloneRecord); + resumedThroughRecord = this.recordHistory.length; + await resumed.restore(nextRecords); + } // oxlint-disable-next-line jest/no-standalone-expect expect(resumeStateSnapshot(resumed)).toEqual(resumeStateSnapshot(this)); @@ -1572,11 +1587,22 @@ export class AgentTestContext { } private async drainWirePersistence(): Promise { - for (let i = 0; i < 5; i += 1) { - await Promise.resolve(); - } const wireRecord = this.get(IAgentWireRecordService); - await wireRecord.flush(); + let lastRecordCount = -1; + for (let i = 0; i < 25; i += 1) { + for (let j = 0; j < 5; j += 1) { + await Promise.resolve(); + } + await new Promise((resolve) => setImmediate(resolve)); + await wireRecord.flush(); + if ( + this.recordHistory.length === lastRecordCount && + pendingTaskNotificationKeys(this.recordHistory).length === 0 + ) { + return; + } + lastRecordCount = this.recordHistory.length; + } } async close(_reason = 'Agent runtime test closed'): Promise { @@ -1989,6 +2015,73 @@ function isSystemReminderMessage(message: ContextMessage): boolean { return text.startsWith(''); } +function pendingTaskNotificationKeys(records: readonly PersistedWireRecord[]): readonly string[] { + const terminal = new Set(); + const delivered = new Set(); + for (const record of records) { + if (record.type === 'task.terminated') { + const info = record['info']; + if (isTaskInfoLike(info) && info.detached !== false && info.terminalNotificationSuppressed !== true) { + terminal.add(taskNotificationKey(info.taskId, info.status)); + } + continue; + } + for (const message of contextMessagesFromRecord(record)) { + const origin = message.origin; + if (isTaskOriginLike(origin)) { + delivered.add(`${origin.taskId}\0${origin.status}\0${origin.notificationId}`); + } + } + } + return [...terminal].filter((key) => !delivered.has(key)); +} + +function contextMessagesFromRecord(record: PersistedWireRecord): readonly ContextMessage[] { + if (record.type === 'context.append_message') { + const message = record['message']; + return isContextMessageLike(message) ? [message] : []; + } + if (record.type === 'context.splice') { + const messages = record['messages']; + return Array.isArray(messages) + ? messages.filter(isContextMessageLike) + : []; + } + return []; +} + +function isContextMessageLike(value: unknown): value is ContextMessage { + return typeof value === 'object' && value !== null && 'role' in value; +} + +function isTaskInfoLike(value: unknown): value is { + readonly taskId: string; + readonly status: string; + readonly detached?: boolean; + readonly terminalNotificationSuppressed?: boolean; +} { + if (typeof value !== 'object' || value === null) return false; + const info = value as Record; + return typeof info['taskId'] === 'string' && typeof info['status'] === 'string'; +} + +function isTaskOriginLike(value: unknown): value is { + readonly taskId: string; + readonly status: string; + readonly notificationId: string; +} { + if (typeof value !== 'object' || value === null) return false; + const origin = value as Record; + return origin['kind'] === 'task' && + typeof origin['taskId'] === 'string' && + typeof origin['status'] === 'string' && + typeof origin['notificationId'] === 'string'; +} + +function taskNotificationKey(taskId: string, status: string): string { + return `${taskId}\0${status}\0task:${taskId}:${status}`; +} + function configStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot['config'] { const profile = ctx.get(IAgentProfileService); const data = profile.data(); diff --git a/packages/agent-core-v2/test/store/store.test.ts b/packages/agent-core-v2/test/store/store.test.ts index 37b8e4fc2..1106ff054 100644 --- a/packages/agent-core-v2/test/store/store.test.ts +++ b/packages/agent-core-v2/test/store/store.test.ts @@ -136,7 +136,11 @@ describe('WireService', () => { let changes = 0; let restored = 0; disposables.add(replayed.subscribe(CounterModel, () => (changes += 1))); - disposables.add(replayed.onRestored(() => (restored += 1))); + disposables.add( + replayed.onRestored(() => { + restored += 1; + }), + ); await replayed.replay(...records); diff --git a/packages/agent-core-v2/test/task/rpc-events.test.ts b/packages/agent-core-v2/test/task/rpc-events.test.ts index 25aa7d9da..c8ee3127a 100644 --- a/packages/agent-core-v2/test/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/task/rpc-events.test.ts @@ -212,7 +212,7 @@ function createAgentTaskService(options: { launched: Promise.resolve(undefined), }); const context = ctx.get(IAgentContextMemoryService); - const spliceHistorySpy = vi.spyOn(context, 'splice'); + const appendHistorySpy = vi.spyOn(context, 'append'); const agent: FakeTaskAgent = { emittedEvents, @@ -224,7 +224,7 @@ function createAgentTaskService(options: { ? undefined : { task: { maxRunningTasks: options.maxRunningTasks } }, telemetry: { track }, - context: { appendUserMessage: spliceHistorySpy }, + context: { appendUserMessage: appendHistorySpy }, turn: { steer: steerSpy }, hooks: options.hooks, }; @@ -254,12 +254,8 @@ async function cleanupSessionDir( } function firstAppendedContextMessage(agent: FakeTaskAgent): TestContextMessage { - const call = agent.context.appendUserMessage.mock.calls[0] as unknown as [ - number, - number, - readonly TestContextMessage[], - ]; - const message = call[2].at(-1); + const call = agent.context.appendUserMessage.mock.calls[0] as unknown as TestContextMessage[]; + const message = call.at(-1); if (message === undefined) throw new Error('Expected an appended context message'); return message; } diff --git a/packages/agent-core-v2/test/task/service.test.ts b/packages/agent-core-v2/test/task/service.test.ts index 701837031..333b7580a 100644 --- a/packages/agent-core-v2/test/task/service.test.ts +++ b/packages/agent-core-v2/test/task/service.test.ts @@ -17,6 +17,8 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; +import { IEventBus } from '#/app/event/eventBus'; +import { ITaskService } from '#/app/task/task'; import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs'; @@ -54,6 +56,18 @@ describe('AgentTaskService', () => { ix = disposables.add(new TestInstantiationService()); ix.stub(IAgentWireRecordService, stubWireRecord()); ix.stub(IAgentWireService, stubWireService()); + ix.stub(IEventBus, { + publish: () => {}, + subscribe: () => toDisposable(() => {}), + }); + ix.stub(ITaskService, { + run: () => { + throw new Error('ITaskService.run is not used by this test'); + }, + defer: () => { + throw new Error('ITaskService.defer is not used by this test'); + }, + }); ix.stub(IAgentContextMemoryService, stubContextMemory()); ix.stub(ITelemetryService, { track: () => {} }); ix.stub(IAgentToolRegistryService, { diff --git a/packages/agent-core-v2/test/wireRecord/resume.test.ts b/packages/agent-core-v2/test/wireRecord/resume.test.ts index 7056761ba..e92b3a238 100644 --- a/packages/agent-core-v2/test/wireRecord/resume.test.ts +++ b/packages/agent-core-v2/test/wireRecord/resume.test.ts @@ -518,22 +518,19 @@ describe('Agent resume', () => { message.origin.taskId === 'agent-new00000', ), ).toBe(true); - // The newly delivered notification is persisted as a v1.5 - // `context.splice` (append) record, not the legacy - // `context.append_message`. + // The newly delivered notification is persisted through the current + // context append primitive. expect(persistence.appended).toContainEqual( expect.objectContaining({ - type: 'context.splice', - messages: expect.arrayContaining([ - expect.objectContaining({ - origin: { - kind: 'task', - taskId: 'agent-new00000', - status: 'completed', - notificationId: 'task:agent-new00000:completed', - }, - }), - ]), + type: 'context.append_message', + message: expect.objectContaining({ + origin: { + kind: 'task', + taskId: 'agent-new00000', + status: 'completed', + notificationId: 'task:agent-new00000:completed', + }, + }), }), ); } finally {