Download src/agents/subagents/spawn/acp-spawn.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 123 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/subagents/spawn/acp-spawn.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/subagents/spawn/acp-spawn.test.ts
-
curl -L -o acp-spawn.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/subagents/spawn/acp-spawn.test.ts
123 kB
| /** Tests ACP spawn planning, policy gates, bindings, cleanup, and parent stream setup. */ | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import type { AcpRuntime } from "@openclaw/acp-core/runtime/types"; | |
| import { expectDefined } from "@openclaw/normalization-core"; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import type { AcpInitializeSessionInput } from "../../../acp/control-plane/manager.types.js"; | |
| import { | |
| registerAcpRuntimeBackend, | |
| testing as acpRuntimeRegistryTesting, | |
| } from "../../../acp/runtime/registry.js"; | |
| import { createExecutionIdentityAdmissionToken } from "../../../audit/execution-identity-admission.js"; | |
| import type { ThinkLevel } from "../../../auto-reply/thinking.shared.js"; | |
| import { getLoadedChannelPluginForRead } from "../../../channels/plugins/registry-loaded.js"; | |
| import type { SessionEntry } from "../../../config/sessions/types.js"; | |
| import type { OpenClawConfig } from "../../../config/types.openclaw.js"; | |
| import { readAgentRuntimeExecutionLineage } from "../../../gateway/agent-runtime-execution-lineage.js"; | |
| import type { AgentRuntimeIdentity } from "../../../gateway/agent-runtime-identity-token.js"; | |
| import { readInProcessAgentRuntimeIdentity } from "../../../gateway/in-process-agent-runtime-identity.js"; | |
| import type { dispatchGatewayMethodInProcess } from "../../../gateway/server-plugins.js"; | |
| import { | |
| claimAgentRunDelegatedAuthority, | |
| releaseAgentRunDelegatedAuthority, | |
| } from "../../../infra/agent-run-registry.js"; | |
| import { | |
| testing as sessionBindingServiceTesting, | |
| registerSessionBindingAdapter, | |
| type SessionBindingAdapter, | |
| type SessionBindingPlacement, | |
| type SessionBindingRecord, | |
| } from "../../../infra/outbound/session-binding-service.js"; | |
| import { setActivePluginRegistry } from "../../../plugins/runtime.js"; | |
| import { | |
| createChannelTestPluginBase, | |
| createTestRegistry, | |
| } from "../../../test-utils/channel-plugins.js"; | |
| import { normalizeSessionDeliveryState } from "../../../utils/delivery-context.shared.js"; | |
| import { createOperationalRunInstanceRef } from "../../admitted-run-context.js"; | |
| import { reserveChildAdmissionSlot } from "../../child-admission.js"; | |
| import { withGatewayToolCallerIdentity } from "../../tools/gateway-caller-context.js"; | |
| import { withParentExecutionIdentity } from "./execution-identity-spawn-context.js"; | |
| import { setSubagentSpawnDepsForTest } from "./subagent-spawn-deps.js"; | |
| type SessionBindingAdapterCapabilities = NonNullable<SessionBindingAdapter["capabilities"]>; | |
| function createDefaultSpawnConfig(): OpenClawConfig { | |
| return { | |
| acp: { | |
| enabled: true, | |
| backend: "acpx", | |
| allowedAgents: ["codex"], | |
| }, | |
| agents: { | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| session: { | |
| mainKey: "main", | |
| scope: "per-sender", | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| }, | |
| }; | |
| } | |
| const hoisted = vi.hoisted(() => { | |
| const callGatewayMock = vi.fn(); | |
| const sessionBindingBindMock = vi.fn(); | |
| const sessionBindingUnbindMock = vi.fn(); | |
| const sessionBindingResolveByConversationMock = vi.fn(); | |
| const sessionBindingListBySessionMock = vi.fn(); | |
| const closeSessionMock = vi.fn(); | |
| const initializeSessionMock = vi.fn(); | |
| const getAcpSessionManagerMock = vi.fn(); | |
| const startAcpSpawnParentStreamRelayMock = vi.fn(); | |
| const loadSessionStoreMock = vi.fn(); | |
| const readAcpSessionMetaMock = vi.fn(); | |
| const resolveStorePathMock = vi.fn(); | |
| const resolveSessionTranscriptFileMock = vi.fn(); | |
| const areHeartbeatsEnabledMock = vi.fn(); | |
| const normalizeChannelIdMock = vi.fn((channelId: string) => { | |
| const normalized = channelId.trim().toLowerCase(); | |
| return normalized || null; | |
| }); | |
| const cleanupFailedAcpSpawnMock = vi.fn(); | |
| const closeRuntimeOnFailureMock = vi.fn(); | |
| const registerSubagentRunMock = vi.fn(); | |
| const countActiveRunsForSessionMock = vi.fn(); | |
| const getSubagentRunByChildSessionKeyMock = vi.fn(); | |
| const listTasksForOwnerKeyMock = vi.fn(); | |
| const upsertSessionEntryMock = vi.fn(); | |
| const createSessionAccessorMock = () => { | |
| const resolveMockStorePath = (scope: { | |
| agentId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| storePath?: string; | |
| }): string => | |
| scope.storePath ?? | |
| resolveStorePathMock(undefined, { | |
| agentId: scope.agentId, | |
| env: scope.env, | |
| }); | |
| const loadMockEntry = (scope: { | |
| agentId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| sessionKey: string; | |
| storePath?: string; | |
| }): SessionEntry | undefined => { | |
| const store = loadSessionStoreMock(resolveMockStorePath(scope)) as Record< | |
| string, | |
| SessionEntry | |
| >; | |
| return store[scope.sessionKey]; | |
| }; | |
| const listMockEntries = ( | |
| scope: { | |
| agentId?: string; | |
| env?: NodeJS.ProcessEnv; | |
| storePath?: string; | |
| } = {}, | |
| ) => { | |
| const store = loadSessionStoreMock(resolveMockStorePath(scope)) as Record< | |
| string, | |
| SessionEntry | |
| >; | |
| return Object.entries(store).map(([sessionKey, entry]) => ({ sessionKey, entry })); | |
| }; | |
| return { | |
| listSessionEntries: listMockEntries, | |
| listSessionEntriesReadOnly: listMockEntries, | |
| loadSessionEntry: loadMockEntry, | |
| loadSessionEntryReadOnly: loadMockEntry, | |
| upsertSessionEntryCore: async (scope: unknown, patch: SessionEntry) => | |
| await upsertSessionEntryMock(scope, patch), | |
| resolveSessionTranscriptRuntimeTarget: async (scope: { | |
| agentId: string; | |
| sessionId: string; | |
| sessionKey: string; | |
| storePath?: string; | |
| threadId?: string | number; | |
| }) => { | |
| const store = scope.storePath | |
| ? (loadSessionStoreMock(scope.storePath) as Record<string, SessionEntry>) | |
| : undefined; | |
| const resolved = await resolveSessionTranscriptFileMock({ | |
| ...scope, | |
| ...(store ? { sessionStore: store } : {}), | |
| sessionEntry: loadMockEntry(scope), | |
| }); | |
| return { | |
| agentId: scope.agentId, | |
| sessionFile: resolved.sessionFile, | |
| sessionId: scope.sessionId, | |
| sessionKey: scope.sessionKey, | |
| }; | |
| }, | |
| }; | |
| }; | |
| const state = { | |
| cfg: createDefaultSpawnConfig(), | |
| }; | |
| return { | |
| callGatewayMock, | |
| sessionBindingBindMock, | |
| sessionBindingUnbindMock, | |
| sessionBindingResolveByConversationMock, | |
| sessionBindingListBySessionMock, | |
| closeSessionMock, | |
| initializeSessionMock, | |
| getAcpSessionManagerMock, | |
| startAcpSpawnParentStreamRelayMock, | |
| loadSessionStoreMock, | |
| readAcpSessionMetaMock, | |
| resolveStorePathMock, | |
| resolveSessionTranscriptFileMock, | |
| areHeartbeatsEnabledMock, | |
| normalizeChannelIdMock, | |
| cleanupFailedAcpSpawnMock, | |
| closeRuntimeOnFailureMock, | |
| registerSubagentRunMock, | |
| countActiveRunsForSessionMock, | |
| getSubagentRunByChildSessionKeyMock, | |
| listTasksForOwnerKeyMock, | |
| upsertSessionEntryMock, | |
| createSessionAccessorMock, | |
| state, | |
| }; | |
| }); | |
| vi.mock("../../../acp/control-plane/manager.js", () => ({ | |
| getAcpSessionManager: hoisted.getAcpSessionManagerMock, | |
| })); | |
| vi.mock("../../../acp/control-plane/spawn.js", () => ({ | |
| cleanupFailedAcpSpawn: hoisted.cleanupFailedAcpSpawnMock, | |
| })); | |
| vi.mock("../../../acp/runtime/session-meta.js", () => ({ | |
| readAcpSessionMeta: (params: unknown) => hoisted.readAcpSessionMetaMock(params), | |
| })); | |
| vi.mock("../../../channels/plugins/index.js", () => ({ | |
| getChannelPlugin: (channelId: string) => getLoadedChannelPluginForRead(channelId), | |
| getLoadedChannelPlugin: (channelId: string) => getLoadedChannelPluginForRead(channelId), | |
| normalizeChannelId: hoisted.normalizeChannelIdMock, | |
| })); | |
| vi.mock("../../../channels/plugins/registry.js", () => ({ | |
| getChannelPlugin: (channelId: string) => getLoadedChannelPluginForRead(channelId), | |
| getLoadedChannelPlugin: (channelId: string) => getLoadedChannelPluginForRead(channelId), | |
| normalizeChannelId: hoisted.normalizeChannelIdMock, | |
| })); | |
| vi.mock("../../../config/sessions/paths.js", () => ({ | |
| resolveSessionStorePathCore: hoisted.resolveStorePathMock, | |
| })); | |
| vi.mock("../../../config/sessions/session-accessor.js", () => hoisted.createSessionAccessorMock()); | |
| vi.mock("../../../config/sessions.js", () => ({ | |
| loadSessionStore: hoisted.loadSessionStoreMock, | |
| resolveAgentIdFromSessionKey: (sessionKey: string) => | |
| sessionKey.match(/^agent:([^:]+)/)?.[1] ?? "main", | |
| resolveSessionStorePathCore: hoisted.resolveStorePathMock, | |
| })); | |
| vi.mock("../../../config/config.js", () => ({ | |
| getRuntimeConfig: () => hoisted.state.cfg, | |
| })); | |
| vi.mock("../../../config/sessions/transcript.js", () => ({ | |
| resolveSessionTranscriptFile: hoisted.resolveSessionTranscriptFileMock, | |
| })); | |
| vi.mock("../../../gateway/call.js", () => ({ | |
| callGateway: hoisted.callGatewayMock, | |
| })); | |
| vi.mock("../../../infra/heartbeat-wake.js", () => ({ | |
| areHeartbeatsEnabled: hoisted.areHeartbeatsEnabledMock, | |
| })); | |
| vi.mock("./acp-spawn-parent-stream.js", () => ({ | |
| startAcpSpawnParentStreamRelay: hoisted.startAcpSpawnParentStreamRelayMock, | |
| })); | |
| vi.mock("../registry/subagent-registry.js", () => ({ | |
| countActiveRunsForSession: hoisted.countActiveRunsForSessionMock, | |
| // ACP registration deliberately moved behind the shared spawn pipeline. | |
| registerSubagentRun: hoisted.registerSubagentRunMock, | |
| })); | |
| vi.mock("../registry/subagent-registry-read.js", () => ({ | |
| getSubagentRunByChildSessionKey: hoisted.getSubagentRunByChildSessionKeyMock, | |
| })); | |
| vi.mock("../../../tasks/runtime-internal.js", () => ({ | |
| listTasksForOwnerKey: hoisted.listTasksForOwnerKeyMock, | |
| })); | |
| const { spawnAcpDirect } = await import("./acp-spawn.js"); | |
| type SpawnRequest = Parameters<typeof spawnAcpDirect>[0]; | |
| type SpawnContext = Parameters<typeof spawnAcpDirect>[1]; | |
| type SpawnResult = Awaited<ReturnType<typeof spawnAcpDirect>>; | |
| type AgentCallParams = { | |
| deliver?: boolean; | |
| channel?: string; | |
| to?: string; | |
| threadId?: string; | |
| lane?: string; | |
| timeout?: number; | |
| }; | |
| type CrossAgentWorkspaceFixture = { | |
| workspaceRoot: string; | |
| mainWorkspace: string; | |
| targetWorkspace: string; | |
| }; | |
| function replaceSpawnConfig(next: OpenClawConfig): void { | |
| const current = hoisted.state.cfg as Record<string, unknown>; | |
| for (const key of Object.keys(current)) { | |
| delete current[key]; | |
| } | |
| Object.assign(current, next); | |
| } | |
| function createSessionBindingCapabilities(): SessionBindingAdapterCapabilities { | |
| return { | |
| bindSupported: true, | |
| unbindSupported: true, | |
| placements: ["current", "child"] satisfies SessionBindingPlacement[], | |
| }; | |
| } | |
| function createSessionBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord { | |
| return { | |
| bindingId: "default:child-thread", | |
| targetSessionKey: "agent:codex:acp:s1", | |
| targetKind: "session", | |
| conversation: { | |
| channel: "discord", | |
| accountId: "default", | |
| conversationId: "child-thread", | |
| parentConversationId: "parent-channel", | |
| }, | |
| status: "active", | |
| boundAt: Date.now(), | |
| metadata: { | |
| agentId: "codex", | |
| boundBy: "system", | |
| }, | |
| ...overrides, | |
| }; | |
| } | |
| function createRelayHandle(overrides?: { | |
| dispose?: ReturnType<typeof vi.fn>; | |
| notifyStarted?: ReturnType<typeof vi.fn>; | |
| }) { | |
| return { | |
| dispose: overrides?.dispose ?? vi.fn(), | |
| notifyStarted: overrides?.notifyStarted ?? vi.fn(), | |
| }; | |
| } | |
| function expectResolvedIntroTextInBindMetadata(): void { | |
| const callWithMetadata = hoisted.sessionBindingBindMock.mock.calls.find( | |
| (call: unknown[]) => | |
| typeof (call[0] as { metadata?: { introText?: unknown } } | undefined)?.metadata | |
| ?.introText === "string", | |
| ); | |
| const introText = | |
| (callWithMetadata?.[0] as { metadata?: { introText?: string } } | undefined)?.metadata | |
| ?.introText ?? ""; | |
| expect(introText.includes("session ids: pending (available after the first reply)")).toBe(false); | |
| } | |
| function createSpawnRequest(overrides?: Partial<SpawnRequest>): SpawnRequest { | |
| return { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "run", | |
| ...overrides, | |
| }; | |
| } | |
| function createRequesterContext(overrides?: Partial<SpawnContext>): SpawnContext { | |
| return { | |
| agentSessionKey: "agent:main:telegram:direct:6098642967", | |
| agentChannel: "telegram", | |
| agentAccountId: "default", | |
| agentTo: "telegram:6098642967", | |
| agentThreadId: "1", | |
| ...overrides, | |
| }; | |
| } | |
| async function createCrossAgentWorkspaceFixture(options?: { | |
| targetDirName?: string; | |
| createTargetWorkspace?: boolean; | |
| }): Promise<CrossAgentWorkspaceFixture> { | |
| const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-spawn-")); | |
| const mainWorkspace = path.join(workspaceRoot, "main"); | |
| const targetWorkspace = path.join(workspaceRoot, options?.targetDirName?.trim() || "claude-code"); | |
| await fs.mkdir(mainWorkspace, { recursive: true }); | |
| if (options?.createTargetWorkspace !== false) { | |
| await fs.mkdir(targetWorkspace, { recursive: true }); | |
| } | |
| return { | |
| workspaceRoot, | |
| mainWorkspace, | |
| targetWorkspace, | |
| }; | |
| } | |
| function configureCrossAgentWorkspaceSpawn(fixture: CrossAgentWorkspaceFixture): void { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| ...hoisted.state.cfg.acp, | |
| allowedAgents: ["codex", "claude-code"], | |
| }, | |
| agents: { | |
| list: [ | |
| { | |
| id: "main", | |
| default: true, | |
| workspace: fixture.mainWorkspace, | |
| }, | |
| { | |
| id: "claude-code", | |
| workspace: fixture.targetWorkspace, | |
| }, | |
| ], | |
| }, | |
| }); | |
| } | |
| function findAgentGatewayCall(): { method?: string; params?: Record<string, unknown> } | undefined { | |
| return hoisted.callGatewayMock.mock.calls | |
| .map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> }) | |
| .find((request) => request.method === "agent"); | |
| } | |
| function expectFailedSpawn( | |
| result: SpawnResult, | |
| status?: "error" | "forbidden", | |
| ): Extract<SpawnResult, { status: "error" | "forbidden" }> { | |
| if (status) { | |
| expect(result.status).toBe(status); | |
| } else { | |
| expect(result.status).not.toBe("accepted"); | |
| } | |
| if (result.status === "accepted") { | |
| throw new Error("Expected ACP spawn to fail"); | |
| } | |
| return result; | |
| } | |
| function expectAcceptedSpawn(result: SpawnResult): Extract<SpawnResult, { status: "accepted" }> { | |
| expect(result.status).toBe("accepted"); | |
| if (result.status !== "accepted") { | |
| throw new Error("Expected ACP spawn to be accepted"); | |
| } | |
| return result; | |
| } | |
| function expectRecordFields( | |
| record: unknown, | |
| expected: Record<string, unknown>, | |
| ): Record<string, unknown> { | |
| if (!record || typeof record !== "object") { | |
| throw new Error("Expected record"); | |
| } | |
| const actual = record as Record<string, unknown>; | |
| for (const [key, value] of Object.entries(expected)) { | |
| expect(actual[key]).toEqual(value); | |
| } | |
| return actual; | |
| } | |
| function firstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] { | |
| const call = mock.mock.calls[0]; | |
| if (!call) { | |
| throw new Error(`Expected ${label} to be called`); | |
| } | |
| return call; | |
| } | |
| function latestMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] { | |
| const call = mock.mock.calls[mock.mock.calls.length - 1]; | |
| if (!call) { | |
| throw new Error(`Expected ${label} to be called`); | |
| } | |
| return call; | |
| } | |
| function latestBindingInput(): Record<string, unknown> { | |
| return expectRecordFields(latestMockCall(hoisted.sessionBindingBindMock, "session bind")[0], {}); | |
| } | |
| function gatewayRequests(): Array<{ method?: string; params?: Record<string, unknown> }> { | |
| return hoisted.callGatewayMock.mock.calls.map( | |
| (call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> }, | |
| ); | |
| } | |
| function gatewayRequest(method: string): { method?: string; params?: Record<string, unknown> } { | |
| const request = gatewayRequests().find((candidate) => candidate.method === method); | |
| if (!request) { | |
| throw new Error(`Expected gateway request for ${method}`); | |
| } | |
| return request; | |
| } | |
| function expectGatewayMethodNotCalled(method: string): void { | |
| expect(gatewayRequests().some((request) => request.method === method)).toBe(false); | |
| } | |
| function expectCreatedSessionFields(expected: Record<string, unknown>): void { | |
| expectRecordFields(firstMockCall(hoisted.upsertSessionEntryMock, "session create")[1], expected); | |
| } | |
| function expectInitializeSessionFields(expected: Record<string, unknown>): Record<string, unknown> { | |
| return expectRecordFields( | |
| firstMockCall(hoisted.initializeSessionMock, "session initialization")[0], | |
| expected, | |
| ); | |
| } | |
| function expectBindingCallFields(expected: { | |
| conversation?: Record<string, unknown>; | |
| metadata?: Record<string, unknown>; | |
| placement?: string; | |
| targetKind?: string; | |
| }): Record<string, unknown> { | |
| const input = expectRecordFields(latestBindingInput(), { | |
| ...(expected.placement ? { placement: expected.placement } : {}), | |
| ...(expected.targetKind ? { targetKind: expected.targetKind } : {}), | |
| }); | |
| if (expected.conversation) { | |
| expectRecordFields(input.conversation, expected.conversation); | |
| } | |
| if (expected.metadata) { | |
| expectRecordFields(input.metadata, expected.metadata); | |
| } | |
| return input; | |
| } | |
| function expectRelayCallFields(expected: Record<string, unknown>, callIndex = 0): void { | |
| expectRecordFields( | |
| hoisted.startAcpSpawnParentStreamRelayMock.mock.calls[callIndex]?.[0], | |
| expected, | |
| ); | |
| } | |
| function expectAgentGatewayCall(overrides: AgentCallParams): void { | |
| const agentCall = gatewayRequest("agent"); | |
| expect(agentCall?.params?.deliver).toBe(overrides.deliver); | |
| expect(agentCall?.params?.channel).toBe(overrides.channel); | |
| expect(agentCall?.params?.to).toBe(overrides.to); | |
| expect(agentCall?.params?.threadId).toBe(overrides.threadId); | |
| if (Object.hasOwn(overrides, "lane")) { | |
| expect(agentCall?.params?.lane).toBe(overrides.lane); | |
| } | |
| if (Object.hasOwn(overrides, "timeout")) { | |
| expect(agentCall?.params?.timeout).toBe(overrides.timeout); | |
| } | |
| } | |
| function resolveMatrixRoomTargetForTest(value: string | undefined): string | undefined { | |
| const trimmed = value?.trim(); | |
| if (!trimmed) { | |
| return undefined; | |
| } | |
| const normalized = trimmed.replace(/^(?:matrix:)?(?:channel:|room:)/iu, "").trim(); | |
| return normalized || undefined; | |
| } | |
| function enableMatrixAcpThreadBindings(): void { | |
| hoisted.state.cfg.channels = { | |
| ...hoisted.state.cfg.channels, | |
| matrix: { | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| }, | |
| }; | |
| const matrixPlugin = { | |
| ...createChannelTestPluginBase({ id: "matrix" }), | |
| conversationBindings: { | |
| defaultTopLevelPlacement: "child", | |
| }, | |
| messaging: { | |
| resolveDeliveryTarget: ({ | |
| conversationId, | |
| parentConversationId, | |
| }: { | |
| conversationId: string; | |
| parentConversationId?: string; | |
| }) => { | |
| const parent = resolveMatrixRoomTargetForTest(parentConversationId); | |
| const child = conversationId.trim(); | |
| return parent ? { to: `room:${parent}`, threadId: child } : { to: `room:${child}` }; | |
| }, | |
| resolveInboundConversation: ({ | |
| to, | |
| threadId, | |
| }: { | |
| to?: string; | |
| threadId?: string | number; | |
| }) => { | |
| const parent = resolveMatrixRoomTargetForTest(to); | |
| const thread = threadId != null ? String(threadId).trim() : ""; | |
| return thread && parent | |
| ? { conversationId: thread, parentConversationId: parent } | |
| : parent | |
| ? { conversationId: parent } | |
| : undefined; | |
| }, | |
| }, | |
| }; | |
| setActivePluginRegistry( | |
| createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), | |
| ); | |
| registerSessionBindingAdapter({ | |
| channel: "matrix", | |
| accountId: "default", | |
| capabilities: createSessionBindingCapabilities(), | |
| bind: async (input) => await hoisted.sessionBindingBindMock(input), | |
| listBySession: (targetSessionKey) => hoisted.sessionBindingListBySessionMock(targetSessionKey), | |
| resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), | |
| unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), | |
| }); | |
| } | |
| function enableLineCurrentConversationBindings(): void { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| channels: { | |
| ...hoisted.state.cfg.channels, | |
| line: { | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const linePlugin = { | |
| ...createChannelTestPluginBase({ id: "line" }), | |
| messaging: { | |
| resolveInboundConversation: ({ | |
| conversationId, | |
| to, | |
| }: { | |
| conversationId?: string; | |
| to?: string; | |
| }) => { | |
| const source = (conversationId ?? to ?? "").trim(); | |
| const normalized = | |
| source.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1]?.trim() ?? source; | |
| return normalized ? { conversationId: normalized } : undefined; | |
| }, | |
| }, | |
| }; | |
| setActivePluginRegistry( | |
| createTestRegistry([{ pluginId: "line", plugin: linePlugin, source: "test" }]), | |
| ); | |
| registerSessionBindingAdapter({ | |
| channel: "line", | |
| accountId: "default", | |
| capabilities: { | |
| bindSupported: true, | |
| unbindSupported: true, | |
| placements: ["current"] satisfies SessionBindingPlacement[], | |
| }, | |
| bind: async (input) => await hoisted.sessionBindingBindMock(input), | |
| listBySession: (targetSessionKey) => hoisted.sessionBindingListBySessionMock(targetSessionKey), | |
| resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), | |
| unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), | |
| }); | |
| } | |
| function enableTelegramCurrentConversationBindings(): void { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| session: { | |
| ...hoisted.state.cfg.session, | |
| threadBindings: { | |
| ...hoisted.state.cfg.session?.threadBindings, | |
| enabled: true, | |
| }, | |
| }, | |
| }); | |
| const telegramPlugin = { | |
| ...createChannelTestPluginBase({ id: "telegram" }), | |
| messaging: { | |
| resolveInboundConversation: ({ | |
| conversationId, | |
| to, | |
| threadId, | |
| }: { | |
| conversationId?: string; | |
| to?: string; | |
| threadId?: string | number; | |
| }) => { | |
| const source = (conversationId ?? to ?? "").trim(); | |
| const normalized = source.replace(/^telegram:(?:group:|channel:|direct:)?/i, ""); | |
| const explicitThreadId = threadId == null ? "" : String(threadId).trim(); | |
| if (/^-?\d+$/.test(normalized) && /^\d+$/.test(explicitThreadId)) { | |
| return { conversationId: `${normalized}:topic:${explicitThreadId}` }; | |
| } | |
| const topicMatch = /^(-?\d+):topic:(\d+)$/i.exec(normalized); | |
| if (topicMatch?.[1] && topicMatch[2]) { | |
| return { conversationId: `${topicMatch[1]}:topic:${topicMatch[2]}` }; | |
| } | |
| return /^-?\d+$/.test(normalized) ? { conversationId: normalized } : undefined; | |
| }, | |
| }, | |
| }; | |
| setActivePluginRegistry( | |
| createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), | |
| ); | |
| registerSessionBindingAdapter({ | |
| channel: "telegram", | |
| accountId: "default", | |
| capabilities: { | |
| bindSupported: true, | |
| unbindSupported: true, | |
| placements: ["current"] satisfies SessionBindingPlacement[], | |
| }, | |
| bind: async (input) => await hoisted.sessionBindingBindMock(input), | |
| listBySession: (targetSessionKey) => hoisted.sessionBindingListBySessionMock(targetSessionKey), | |
| resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), | |
| unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), | |
| }); | |
| } | |
| describe("spawnAcpDirect", () => { | |
| beforeEach(() => { | |
| setActivePluginRegistry(createTestRegistry()); | |
| acpRuntimeRegistryTesting.resetAcpRuntimeBackendsForTests(); | |
| replaceSpawnConfig(createDefaultSpawnConfig()); | |
| hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true); | |
| hoisted.cleanupFailedAcpSpawnMock.mockReset().mockResolvedValue(undefined); | |
| hoisted.closeRuntimeOnFailureMock.mockReset().mockResolvedValue(undefined); | |
| hoisted.registerSubagentRunMock.mockReset(); | |
| hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0); | |
| hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null); | |
| hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]); | |
| hoisted.upsertSessionEntryMock | |
| .mockReset() | |
| .mockImplementation(async (_scope: unknown, patch: Partial<SessionEntry>) => ({ | |
| ...patch, | |
| sessionId: patch.sessionId ?? "sess-123", | |
| updatedAt: patch.updatedAt ?? Date.now(), | |
| })); | |
| hoisted.callGatewayMock.mockReset(); | |
| hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { | |
| const args = argsUnknown as { method?: string }; | |
| if (args.method === "sessions.patch") { | |
| return { ok: true }; | |
| } | |
| if (args.method === "agent") { | |
| return { runId: "run-1" }; | |
| } | |
| if (args.method === "sessions.delete") { | |
| return { ok: true }; | |
| } | |
| return {}; | |
| }); | |
| hoisted.closeSessionMock.mockReset().mockResolvedValue({ | |
| runtimeClosed: true, | |
| metaCleared: false, | |
| }); | |
| hoisted.getAcpSessionManagerMock.mockReset().mockReturnValue({ | |
| initializeSession: async (params: AcpInitializeSessionInput) => | |
| await hoisted.initializeSessionMock(params), | |
| closeSession: async (params: unknown) => await hoisted.closeSessionMock(params), | |
| }); | |
| hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { | |
| const args = argsUnknown as AcpInitializeSessionInput; | |
| const runtimeSessionName = `${args.sessionKey}:runtime`; | |
| const cwd = typeof args.cwd === "string" ? args.cwd : undefined; | |
| return { | |
| closeRuntimeOnFailure: hoisted.closeRuntimeOnFailureMock, | |
| runtime: { | |
| close: vi.fn().mockResolvedValue(undefined), | |
| }, | |
| handle: { | |
| sessionKey: args.sessionKey, | |
| backend: "acpx", | |
| runtimeSessionName, | |
| ...(cwd ? { cwd } : {}), | |
| agentSessionId: "codex-inner-1", | |
| backendSessionId: "acpx-1", | |
| }, | |
| meta: { | |
| backend: "acpx", | |
| agent: args.agent, | |
| runtimeSessionName, | |
| ...(cwd ? { runtimeOptions: { cwd }, cwd } : {}), | |
| identity: { | |
| state: "pending", | |
| source: "ensure", | |
| acpxSessionId: "acpx-1", | |
| agentSessionId: "codex-inner-1", | |
| lastUpdatedAt: Date.now(), | |
| }, | |
| mode: args.mode, | |
| state: "idle", | |
| lastActivityAt: Date.now(), | |
| }, | |
| }; | |
| }); | |
| hoisted.sessionBindingBindMock | |
| .mockReset() | |
| .mockImplementation( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "discord", | |
| accountId: input.conversation.accountId, | |
| conversationId: "child-thread", | |
| parentConversationId: "parent-channel", | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| webhookId: "wh-1", | |
| }, | |
| }), | |
| ); | |
| hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); | |
| hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); | |
| hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); | |
| sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); | |
| registerSessionBindingAdapter({ | |
| channel: "discord", | |
| accountId: "default", | |
| capabilities: createSessionBindingCapabilities(), | |
| bind: async (input) => await hoisted.sessionBindingBindMock(input), | |
| listBySession: (targetSessionKey) => | |
| hoisted.sessionBindingListBySessionMock(targetSessionKey), | |
| resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), | |
| unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), | |
| }); | |
| hoisted.startAcpSpawnParentStreamRelayMock | |
| .mockReset() | |
| .mockImplementation(() => createRelayHandle()); | |
| hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json"); | |
| hoisted.readAcpSessionMetaMock.mockReset().mockReturnValue(undefined); | |
| hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { | |
| const store: Record<string, { sessionId: string; updatedAt: number }> = {}; | |
| return new Proxy(store, { | |
| get(_target, prop) { | |
| if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { | |
| return { sessionId: "sess-123", updatedAt: Date.now() }; | |
| } | |
| return undefined; | |
| }, | |
| }); | |
| }); | |
| hoisted.resolveSessionTranscriptFileMock | |
| .mockReset() | |
| .mockImplementation(async (params: unknown) => { | |
| const typed = params as { threadId?: string }; | |
| const sessionFile = typed.threadId | |
| ? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl` | |
| : "/tmp/agents/codex/sessions/sess-123.jsonl"; | |
| return { | |
| sessionFile, | |
| sessionEntry: { | |
| sessionId: "sess-123", | |
| updatedAt: Date.now(), | |
| sessionFile, | |
| }, | |
| }; | |
| }); | |
| }); | |
| afterEach(() => { | |
| setActivePluginRegistry(createTestRegistry()); | |
| acpRuntimeRegistryTesting.resetAcpRuntimeBackendsForTests(); | |
| sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); | |
| }); | |
| it("spawns ACP session, binds a new thread, and dispatches initial task", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| agentThreadId: "requester-thread", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.childSessionKey).toMatch(/^agent:codex:acp:/); | |
| expect(accepted.runId).toBe("run-1"); | |
| expect(accepted.mode).toBe("session"); | |
| expect(accepted.inlineDelivery).toBe(true); | |
| expect(accepted.expectsCompletionMessage).toBe(false); | |
| expectCreatedSessionFields({ | |
| spawnedBy: "agent:main:main", | |
| completionOwnerSessionKey: "agent:main:main", | |
| inheritedToolPolicyVersion: 1, | |
| parentSessionKey: "agent:main:main", | |
| createdVia: "spawn", | |
| createdActor: { type: "agent", id: "main" }, | |
| createdAt: expect.any(Number), | |
| }); | |
| expectBindingCallFields({ | |
| targetKind: "session", | |
| placement: "child", | |
| }); | |
| const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex( | |
| (call: unknown[]) => (call[0] as { method?: string }).method === "agent", | |
| ); | |
| const createCallOrder = expectDefined( | |
| hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0], | |
| "hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0] test invariant", | |
| ); | |
| const initializeCallOrder = expectDefined( | |
| hoisted.initializeSessionMock.mock.invocationCallOrder[0], | |
| "hoisted.initializeSessionMock.mock.invocationCallOrder[0] test invariant", | |
| ); | |
| const agentCallOrder = expectDefined( | |
| hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex], | |
| "hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant", | |
| ); | |
| expect(typeof createCallOrder).toBe("number"); | |
| expect(typeof initializeCallOrder).toBe("number"); | |
| expect(typeof agentCallOrder).toBe("number"); | |
| expect(createCallOrder < initializeCallOrder).toBe(true); | |
| expect(initializeCallOrder < agentCallOrder).toBe(true); | |
| expectResolvedIntroTextInBindMetadata(); | |
| const agentCall = gatewayRequest("agent"); | |
| expect(agentCall?.params?.sessionKey).toMatch(/^agent:codex:acp:/); | |
| expect(agentCall?.params?.to).toBe("channel:child-thread"); | |
| expect(agentCall?.params?.threadId).toBe("child-thread"); | |
| expect(agentCall?.params?.deliver).toBe(true); | |
| expect(agentCall?.params?.lane).toBe("subagent"); | |
| expect(agentCall?.params?.acpTurnSource).toBe("manual_spawn"); | |
| // ACP registration must leave taskRowOwnership absent so the registry | |
| // falls back to best-effort task-row creation (subagent-registry-run-launch.ts). | |
| // Native/in-process spawn forwards "required" instead (subagent-spawn.ts); | |
| // if ACP ever claimed "required" here, a failed task-row write would abort | |
| // an ACP run the registry never actually owns. | |
| const registeredAcpRun = expectRecordFields( | |
| firstMockCall(hoisted.registerSubagentRunMock, "ACP subagent registration")[0], | |
| {}, | |
| ); | |
| expect(registeredAcpRun.taskRowOwnership).toBeUndefined(); | |
| const initInput = expectInitializeSessionFields({ | |
| agent: "codex", | |
| mode: "persistent", | |
| }); | |
| expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); | |
| const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map( | |
| (call: unknown[]) => call[0] as { threadId?: string }, | |
| ); | |
| expect(transcriptCalls).toHaveLength(2); | |
| expect(transcriptCalls[0]?.threadId).toBeUndefined(); | |
| expect(transcriptCalls[1]?.threadId).toBe("child-thread"); | |
| }); | |
| it("reconciles a transport-ambiguous ACP dispatch so an accepted run is surfaced instead of misreported as dispatch_failed", async () => { | |
| let agentDispatchAttempts = 0; | |
| // A plain Error whose message matches isGatewayRpcUnavailableError (the gateway | |
| // timeout transport shape) models "the gateway may have accepted the ACP run | |
| // before the ack was lost" - distinct from a genuine dispatch rejection. The | |
| // reconcile lives on the shared subagent gateway seam, so the ACP launch (which | |
| // replays with the same childIdem idempotency key) surfaces the accepted run. | |
| hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { | |
| const args = argsUnknown as { method?: string }; | |
| if (args.method === "agent") { | |
| agentDispatchAttempts += 1; | |
| if (agentDispatchAttempts === 1) { | |
| throw new Error("gateway timeout after 60000ms"); | |
| } | |
| return { runId: "accepted-acp-run", status: "in_flight" }; | |
| } | |
| if (args.method === "sessions.patch") { | |
| return { ok: true }; | |
| } | |
| return args.method === "sessions.delete" ? { ok: true } : {}; | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "ambiguous ACP child", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| agentThreadId: "requester-thread", | |
| }, | |
| ); | |
| // The reconcile replay reuses the same childIdem idempotency key; the gateway | |
| // surfaces the already-accepted run, so the caller must not conclude the ACP | |
| // child never started. | |
| expect(agentDispatchAttempts).toBe(2); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.runId).toBe("accepted-acp-run"); | |
| expect(accepted.childSessionKey).toMatch(/^agent:codex:acp:/); | |
| }); | |
| it("does not register an ACP child when reconciliation finds a terminal run", async () => { | |
| let agentDispatchAttempts = 0; | |
| hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { | |
| const args = argsUnknown as { method?: string }; | |
| if (args.method === "agent" && ++agentDispatchAttempts === 1) { | |
| throw new Error("gateway timeout after 60000ms"); | |
| } | |
| return args.method === "agent" | |
| ? { runId: "stopped-acp-run", status: "timeout" } | |
| : { ok: true }; | |
| }); | |
| const result = await spawnAcpDirect( | |
| { task: "ambiguous ACP child", agentId: "codex", mode: "run" }, | |
| { agentSessionKey: "agent:main:main" }, | |
| ); | |
| expect(agentDispatchAttempts).toBe(2); | |
| expect(expectFailedSpawn(result).error).toContain("no active subagent run (status: timeout)"); | |
| expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled(); | |
| }); | |
| it("forwards ACP lineage with unsupported external native actions and the exact parent token", async () => { | |
| const parentToken = createExecutionIdentityAdmissionToken("parent-run", { | |
| contextId: "parent-context", | |
| executionId: "parent-execution", | |
| }); | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| logging: { audit: { enabled: true, executionIdentity: true } }, | |
| }); | |
| const operationalRunInstance = createOperationalRunInstanceRef("parent-run"); | |
| const authority = claimAgentRunDelegatedAuthority(operationalRunInstance); | |
| let capturedIdentity: AgentRuntimeIdentity | undefined; | |
| setSubagentSpawnDepsForTest({ | |
| hasInProcessGatewayContext: () => true, | |
| dispatchGatewayMethodInProcess: async <T>( | |
| _method: string, | |
| _params: Record<string, unknown>, | |
| options?: NonNullable<Parameters<typeof dispatchGatewayMethodInProcess>[2]>, | |
| ) => { | |
| capturedIdentity = readInProcessAgentRuntimeIdentity(options); | |
| return { runId: "acp-child-run" } as T; | |
| }, | |
| }); | |
| try { | |
| const result = await withGatewayToolCallerIdentity( | |
| { | |
| agentId: "main", | |
| sessionKey: "agent:main:telegram:direct:6098642967", | |
| operationalRunInstance, | |
| executionIdentityToken: parentToken, | |
| }, | |
| () => | |
| spawnAcpDirect( | |
| createSpawnRequest(), | |
| withParentExecutionIdentity(createRequesterContext(), parentToken), | |
| ), | |
| ); | |
| expectAcceptedSpawn(result); | |
| expect(capturedIdentity?.executionIdentity).toBe(parentToken); | |
| expect(readAgentRuntimeExecutionLineage(capturedIdentity?.sessionSpawnContext)).toMatchObject( | |
| { | |
| relation: "sessions_spawn", | |
| requesterRef: "agent:main:telegram:direct:6098642967", | |
| controllerRef: "agent:main:telegram:direct:6098642967", | |
| externalNativeActions: "unsupported", | |
| runtimeAssuranceRefs: ["spawn-runtime:acp"], | |
| }, | |
| ); | |
| } finally { | |
| releaseAgentRunDelegatedAuthority(authority); | |
| setSubagentSpawnDepsForTest(); | |
| } | |
| }); | |
| it.each([ | |
| { | |
| scenario: "explicit global backend accepts its owner", | |
| persistedBackend: "acpx", | |
| accepted: true, | |
| expectedBackend: "acpx", | |
| }, | |
| { | |
| scenario: "explicit global backend rejects another owner", | |
| persistedBackend: "fallback", | |
| accepted: false, | |
| expectedBackend: "acpx", | |
| }, | |
| { | |
| scenario: "target agent backend overrides the global backend", | |
| persistedBackend: "fallback", | |
| targetBackend: "fallback", | |
| accepted: true, | |
| expectedBackend: "fallback", | |
| }, | |
| { | |
| scenario: "target agent backend rejects the global backend owner", | |
| persistedBackend: "acpx", | |
| targetBackend: "fallback", | |
| accepted: false, | |
| expectedBackend: "fallback", | |
| }, | |
| { | |
| scenario: "auto-selected healthy backend rejects another owner", | |
| persistedBackend: "fallback", | |
| autoSelectBackend: true, | |
| accepted: false, | |
| expectedBackend: "primary", | |
| }, | |
| { | |
| scenario: "auto-selected healthy backend accepts its owner", | |
| persistedBackend: "primary", | |
| autoSelectBackend: true, | |
| accepted: true, | |
| expectedBackend: "primary", | |
| }, | |
| ])( | |
| "allows requester-owned ACP resume IDs only for the effective backend ($scenario)", | |
| async ({ persistedBackend, targetBackend, autoSelectBackend, accepted, expectedBackend }) => { | |
| if (targetBackend) { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| list: [ | |
| { | |
| id: "reviewer", | |
| runtime: { | |
| type: "acp", | |
| acp: { agent: "codex", backend: targetBackend }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| } | |
| if (autoSelectBackend) { | |
| const { backend: _configuredBackend, ...acpWithoutBackend } = hoisted.state.cfg.acp ?? {}; | |
| replaceSpawnConfig({ ...hoisted.state.cfg, acp: acpWithoutBackend }); | |
| const runtime: AcpRuntime = { | |
| async ensureSession(input) { | |
| return { | |
| sessionKey: input.sessionKey, | |
| backend: "primary", | |
| runtimeSessionName: input.sessionKey, | |
| }; | |
| }, | |
| async *runTurn() {}, | |
| async cancel() {}, | |
| async close() {}, | |
| }; | |
| registerAcpRuntimeBackend({ id: "unhealthy", runtime, healthy: () => false }); | |
| registerAcpRuntimeBackend({ id: "primary", runtime, healthy: () => true }); | |
| registerAcpRuntimeBackend({ id: "fallback", runtime, healthy: () => true }); | |
| } | |
| const resumeSessionId = "codex-inner-resume"; | |
| const ownedSessionKey = "agent:codex:acp:owned"; | |
| hoisted.loadSessionStoreMock.mockReturnValue({ | |
| [ownedSessionKey]: { | |
| sessionId: "sess-owned", | |
| updatedAt: Date.now(), | |
| spawnedBy: "agent:main:main", | |
| } satisfies SessionEntry, | |
| }); | |
| hoisted.readAcpSessionMetaMock.mockImplementation((paramsUnknown: unknown) => { | |
| const params = paramsUnknown as { sessionKey?: string }; | |
| return params.sessionKey === ownedSessionKey | |
| ? { | |
| backend: persistedBackend, | |
| agent: "codex", | |
| runtimeSessionName: "codex", | |
| identity: { | |
| state: "resolved", | |
| source: "ensure", | |
| agentSessionId: resumeSessionId, | |
| acpxSessionId: "acpx-owned", | |
| lastUpdatedAt: Date.now(), | |
| }, | |
| mode: "oneshot", | |
| state: "idle", | |
| lastActivityAt: Date.now(), | |
| } | |
| : undefined; | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Resume owned ACP session", | |
| agentId: targetBackend ? "reviewer" : "codex", | |
| resumeSessionId, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| if (accepted) { | |
| expectAcceptedSpawn(result); | |
| expectInitializeSessionFields({ resumeSessionId, backendId: expectedBackend }); | |
| return; | |
| } | |
| expectRecordFields(result, { | |
| status: "forbidden", | |
| errorCode: "resume_forbidden", | |
| }); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| }, | |
| ); | |
| it("rejects ACP resume IDs not recorded for the requester session", async () => { | |
| const otherSessionKey = "agent:codex:acp:other"; | |
| hoisted.loadSessionStoreMock.mockReturnValue({ | |
| [otherSessionKey]: { | |
| sessionId: "sess-other", | |
| updatedAt: Date.now(), | |
| spawnedBy: "agent:other:main", | |
| } satisfies SessionEntry, | |
| }); | |
| hoisted.readAcpSessionMetaMock.mockImplementation((paramsUnknown: unknown) => { | |
| const params = paramsUnknown as { sessionKey?: string }; | |
| return params.sessionKey === otherSessionKey | |
| ? { | |
| backend: "acpx", | |
| agent: "codex", | |
| runtimeSessionName: "codex", | |
| identity: { | |
| state: "resolved", | |
| source: "ensure", | |
| agentSessionId: "codex-inner-other", | |
| acpxSessionId: "acpx-other", | |
| lastUpdatedAt: Date.now(), | |
| }, | |
| mode: "oneshot", | |
| state: "idle", | |
| lastActivityAt: Date.now(), | |
| } | |
| : undefined; | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Resume other ACP session", | |
| agentId: "codex", | |
| resumeSessionId: "codex-inner-other", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectRecordFields(result, { | |
| status: "forbidden", | |
| errorCode: "resume_forbidden", | |
| }); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("passes model and thinking overrides into ACP session initialization", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| model: "openai/gpt-5.4", | |
| thinking: "high", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| const initInput = expectInitializeSessionFields({ | |
| agent: "codex", | |
| runtimeOptions: { | |
| model: "openai/gpt-5.4", | |
| thinking: "high", | |
| }, | |
| }); | |
| expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); | |
| }); | |
| it("strips an inherited OpenClaw auth profile before ACP initialization", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| model: "openai/gpt-5.6-luna@openai:test-profile", | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { task: "Investigate flaky tests", agentId: "codex" }, | |
| { agentSessionKey: "agent:main:main" }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| const initInput = expectInitializeSessionFields({ agent: "codex" }); | |
| expect(initInput.runtimeOptions).toEqual( | |
| expect.objectContaining({ model: "openai/gpt-5.6-luna" }), | |
| ); | |
| }); | |
| it("rejects an explicit OpenClaw auth profile for ACP runtimes", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| model: "openai/gpt-5.6-luna@openai:test-profile", | |
| }, | |
| { agentSessionKey: "agent:main:main" }, | |
| ); | |
| expect(result.status).toBe("error"); | |
| expect(result).toHaveProperty( | |
| "error", | |
| "ACP model overrides cannot select OpenClaw auth profiles; configure credentials in the ACP runtime instead.", | |
| ); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| }); | |
| it("applies existing subagent model and model-profile thinking defaults to ACP runtime options", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| model: "openai/gpt-5.4", | |
| }, | |
| models: { | |
| "openai/gpt-5.4": { | |
| params: { thinking: "high" }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expectInitializeSessionFields({ | |
| agent: "codex", | |
| runtimeOptions: { | |
| model: "openai/gpt-5.4", | |
| thinking: "high", | |
| }, | |
| }); | |
| }); | |
| it("uses configured runtime=acp agent defaults before launching the external ACP agent", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| list: [ | |
| { | |
| id: "codex-acp", | |
| runtime: { | |
| type: "acp", | |
| acp: { agent: "codex" }, | |
| }, | |
| subagents: { | |
| model: "openai/gpt-5.5", | |
| thinking: "low", | |
| }, | |
| }, | |
| ], | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex-acp", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expectInitializeSessionFields({ | |
| agent: "codex", | |
| runtimeOptions: { | |
| model: "openai/gpt-5.5", | |
| thinking: "low", | |
| }, | |
| }); | |
| }); | |
| it.each<{ | |
| scenario: string; | |
| model?: string; | |
| ownerThinking?: ThinkLevel; | |
| globalThinking?: ThinkLevel; | |
| modelThinking?: ThinkLevel; | |
| subagentThinking?: ThinkLevel; | |
| globalSubagentThinking?: ThinkLevel; | |
| thinking?: ThinkLevel; | |
| expectedThinking?: ThinkLevel; | |
| expectedThinkingExplicit?: boolean; | |
| backend?: string; | |
| }>([ | |
| { | |
| scenario: "configured primary model with global thinking default", | |
| model: "anthropic/claude-sonnet-4-6", | |
| globalThinking: "off", | |
| expectedThinking: "off", | |
| }, | |
| { | |
| scenario: "owner default before model and global defaults", | |
| model: "anthropic/claude-sonnet-4-6", | |
| ownerThinking: "off", | |
| modelThinking: "adaptive", | |
| globalThinking: "high", | |
| expectedThinking: "off", | |
| }, | |
| { | |
| scenario: "owner default without a model override", | |
| ownerThinking: "off", | |
| globalThinking: "high", | |
| expectedThinking: "off", | |
| }, | |
| { | |
| scenario: "target subagent default before owner default", | |
| ownerThinking: "off", | |
| subagentThinking: "low", | |
| expectedThinking: "low", | |
| }, | |
| { | |
| scenario: "global subagent default before owner default", | |
| ownerThinking: "off", | |
| globalSubagentThinking: "medium", | |
| expectedThinking: "medium", | |
| }, | |
| { | |
| scenario: "explicit thinking before subagent and owner defaults", | |
| ownerThinking: "off", | |
| subagentThinking: "low", | |
| thinking: "high", | |
| expectedThinking: "high", | |
| }, | |
| { | |
| scenario: "explicit max thinking for Codex", | |
| globalSubagentThinking: "low", | |
| thinking: "max", | |
| expectedThinking: "max", | |
| expectedThinkingExplicit: true, | |
| }, | |
| { | |
| scenario: "inherited max thinking for Codex", | |
| model: "openai/gpt-5.6-sol", | |
| globalSubagentThinking: "max", | |
| expectedThinking: "max", | |
| expectedThinkingExplicit: false, | |
| }, | |
| { | |
| scenario: "inherited max thinking for Codex on another ACP backend", | |
| model: "openai/gpt-5.6-sol", | |
| globalSubagentThinking: "max", | |
| expectedThinking: "max", | |
| expectedThinkingExplicit: false, | |
| backend: "alternate", | |
| }, | |
| { | |
| scenario: "harness defaults without an owner or model override", | |
| globalThinking: "high", | |
| }, | |
| ])( | |
| "resolves configured ACP spawn model and thinking ($scenario)", | |
| async ({ | |
| model, | |
| ownerThinking, | |
| globalThinking, | |
| modelThinking, | |
| subagentThinking, | |
| globalSubagentThinking, | |
| thinking, | |
| expectedThinking, | |
| expectedThinkingExplicit, | |
| backend, | |
| }) => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| list: [ | |
| { | |
| id: "codex-acp", | |
| runtime: { | |
| type: "acp", | |
| acp: { agent: "codex", ...(backend ? { backend } : {}) }, | |
| }, | |
| model, | |
| thinkingDefault: ownerThinking, | |
| subagents: { thinking: subagentThinking }, | |
| }, | |
| ], | |
| defaults: { | |
| thinkingDefault: globalThinking, | |
| ...(model && modelThinking | |
| ? { models: { [model]: { params: { thinking: modelThinking } } } } | |
| : {}), | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| thinking: globalSubagentThinking, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { task: "Investigate flaky tests", agentId: "codex-acp", thinking }, | |
| { agentSessionKey: "agent:main:main" }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expectInitializeSessionFields({ | |
| agent: "codex", | |
| backendId: backend ?? "acpx", | |
| ...(expectedThinkingExplicit !== undefined | |
| ? { thinkingExplicit: expectedThinkingExplicit } | |
| : {}), | |
| runtimeOptions: | |
| model || expectedThinking | |
| ? { | |
| ...(model ? { model } : {}), | |
| ...(expectedThinking ? { thinking: expectedThinking } : {}), | |
| } | |
| : undefined, | |
| }); | |
| }, | |
| ); | |
| it("applies ACP spawn run timeout to runtime options and dispatch", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| runTimeoutSeconds: 45, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expect(result).toHaveProperty("runTimeoutSeconds", 45); | |
| const initInput = expectInitializeSessionFields({ | |
| agent: "codex", | |
| runtimeOptions: { | |
| timeoutSeconds: 45, | |
| }, | |
| }); | |
| expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); | |
| const agentCall = findAgentGatewayCall(); | |
| expect(agentCall?.params?.lane).toBe("subagent"); | |
| expect(agentCall?.params?.timeout).toBe(45); | |
| }); | |
| it("passes zero timeout through to the gateway no-timeout path", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| runTimeoutSeconds: 0, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expect(result).toHaveProperty("runTimeoutSeconds", 0); | |
| const initInput = expectInitializeSessionFields({ agent: "codex" }); | |
| expect(initInput.runtimeOptions).toBeUndefined(); | |
| const agentCall = findAgentGatewayCall(); | |
| expect(agentCall?.params?.timeout).toBe(0); | |
| }); | |
| it("uses configured subagent timeout for ACP spawns", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| runTimeoutSeconds: 120, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expect(result).toHaveProperty("runTimeoutSeconds", 120); | |
| expectInitializeSessionFields({ | |
| agent: "codex", | |
| runtimeOptions: { | |
| timeoutSeconds: 120, | |
| }, | |
| }); | |
| const agentCall = findAgentGatewayCall(); | |
| expect(agentCall?.params?.timeout).toBe(120); | |
| }); | |
| it("caps configured ACP runtime timeout without shortening spawn tracking", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| runTimeoutSeconds: 172_800, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| expect(result).toHaveProperty("runTimeoutSeconds", 172_800); | |
| expectInitializeSessionFields({ | |
| agent: "codex", | |
| runtimeOptions: { | |
| timeoutSeconds: 86_400, | |
| }, | |
| }); | |
| const agentCall = findAgentGatewayCall(); | |
| expect(agentCall?.params?.timeout).toBe(172_800); | |
| }); | |
| it("rejects OpenClaw config agent ids when runtime=acp targets a native agent", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| acp: { | |
| enabled: true, | |
| backend: "acpx", | |
| allowedAgents: ["codex"], | |
| }, | |
| agents: { | |
| list: [{ id: "pleres" }], | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["*"], | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "pleres", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectRecordFields(result, { | |
| status: "error", | |
| errorCode: "runtime_agent_mismatch", | |
| }); | |
| expect(result).toHaveProperty( | |
| "error", | |
| 'agentId "pleres" is an OpenClaw config agent, not an ACP harness. Use runtime="subagent" or omit runtime for OpenClaw config agents. Use runtime="acp" only with external ACP harness ids such as codex, claude, droid, gemini, or opencode, or configure agents.entries.*.runtime.type="acp" with runtime.acp.agent.', | |
| ); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| expectGatewayMethodNotCalled("agent"); | |
| }); | |
| it("forwards prepared image attachments through the gateway agent call", async () => { | |
| const imageBase64 = Buffer.from("png-bytes").toString("base64"); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "describe the image", | |
| agentId: "codex", | |
| attachments: [{ mediaType: "image/png", data: imageBase64 }], | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| const agentCall = findAgentGatewayCall(); | |
| expect(agentCall?.params?.attachments).toEqual([ | |
| { | |
| type: "image", | |
| source: { type: "base64", media_type: "image/png", data: imageBase64 }, | |
| }, | |
| ]); | |
| }); | |
| it("omits attachments from gateway call when none are provided", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "hello", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| const agentCall = findAgentGatewayCall(); | |
| expect(agentCall?.params).not.toHaveProperty("attachments"); | |
| }); | |
| it("maps OpenClaw ACP runtime agent aliases to their configured harness id", async () => { | |
| replaceSpawnConfig({ | |
| ...createDefaultSpawnConfig(), | |
| agents: { | |
| list: [ | |
| { | |
| id: "reviewer", | |
| runtime: { | |
| type: "acp", | |
| acp: { | |
| agent: "codex", | |
| }, | |
| }, | |
| }, | |
| ], | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["codex"], | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "reviewer", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| const initInput = expectInitializeSessionFields({ agent: "codex" }); | |
| expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); | |
| }); | |
| it("inherits subagent envelope fields onto ACP children", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect(createSpawnRequest(), { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }); | |
| expectAcceptedSpawn(result); | |
| expectCreatedSessionFields({ | |
| spawnedBy: "agent:main:subagent:parent", | |
| spawnDepth: 2, | |
| subagentRole: "leaf", | |
| subagentControlScope: "none", | |
| }); | |
| }); | |
| it("rejects ACP spawns that exceed subagent max depth", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect(createSpawnRequest(), { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent:subagent:leaf", | |
| }); | |
| const failed = expectFailedSpawn(result, "forbidden"); | |
| expect(failed.errorCode).toBe("subagent_policy"); | |
| expect(failed.error).toContain("current depth: 2, max: 2"); | |
| }); | |
| it("rejects ACP spawns that exceed subagent child caps", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 1, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1); | |
| const result = await spawnAcpDirect(createSpawnRequest(), { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }); | |
| const failed = expectFailedSpawn(result, "forbidden"); | |
| expect(failed.errorCode).toBe("subagent_policy"); | |
| expect(failed.error).toContain("max active children"); | |
| }); | |
| it("enforces child caps while a sibling ACP dispatch has not registered", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 1, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.countActiveRunsForSessionMock.mockImplementation( | |
| () => hoisted.registerSubagentRunMock.mock.calls.length, | |
| ); | |
| let releaseFirstDispatch!: () => void; | |
| const pendingFirstDispatch = new Promise<void>((resolve) => { | |
| releaseFirstDispatch = resolve; | |
| }); | |
| let dispatchedRuns = 0; | |
| hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => { | |
| if (request.method !== "agent") { | |
| return {}; | |
| } | |
| const runNumber = ++dispatchedRuns; | |
| if (runNumber === 1) { | |
| await pendingFirstDispatch; | |
| } | |
| return { runId: `acp-run-${runNumber}` }; | |
| }); | |
| const context = { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| completionOwnerKey: "agent:main:main", | |
| }; | |
| const first = spawnAcpDirect(createSpawnRequest({ task: "first pending ACP child" }), context); | |
| await vi.waitFor(() => expect(dispatchedRuns).toBe(1)); | |
| const rejected = await spawnAcpDirect( | |
| createSpawnRequest({ task: "second over-cap ACP child" }), | |
| context, | |
| ); | |
| releaseFirstDispatch(); | |
| const accepted = await first; | |
| expect(expectFailedSpawn(rejected, "forbidden")).toMatchObject({ | |
| errorCode: "subagent_policy", | |
| error: expect.stringContaining("max active children for this session (1/1"), | |
| }); | |
| expectAcceptedSpawn(accepted); | |
| expect(dispatchedRuns).toBe(1); | |
| expect(hoisted.registerSubagentRunMock).toHaveBeenCalledTimes(1); | |
| }); | |
| it("counts a pending ACP task row and its admission reservation only once", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const activeTasks: Array<{ runtime: string; status: string; childSessionKey: string }> = []; | |
| hoisted.listTasksForOwnerKeyMock.mockImplementation(() => activeTasks); | |
| hoisted.countActiveRunsForSessionMock.mockImplementation( | |
| () => hoisted.registerSubagentRunMock.mock.calls.length, | |
| ); | |
| hoisted.getSubagentRunByChildSessionKeyMock.mockImplementation((childSessionKey: string) => | |
| hoisted.registerSubagentRunMock.mock.calls.some( | |
| ([run]) => (run as { childSessionKey?: string }).childSessionKey === childSessionKey, | |
| ) | |
| ? { childSessionKey, execution: { status: "running" } } | |
| : null, | |
| ); | |
| let releaseFirstDispatch!: () => void; | |
| const pendingFirstDispatch = new Promise<void>((resolve) => { | |
| releaseFirstDispatch = resolve; | |
| }); | |
| let dispatchedRuns = 0; | |
| hoisted.callGatewayMock.mockImplementation( | |
| async (request: { method?: string; params?: { sessionKey?: string } }) => { | |
| if (request.method !== "agent") { | |
| return {}; | |
| } | |
| const runNumber = ++dispatchedRuns; | |
| activeTasks.push({ | |
| runtime: "acp", | |
| status: "running", | |
| childSessionKey: request.params?.sessionKey ?? "", | |
| }); | |
| if (runNumber === 1) { | |
| await pendingFirstDispatch; | |
| } | |
| return { runId: `acp-run-${runNumber}` }; | |
| }, | |
| ); | |
| const context = { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }; | |
| const first = spawnAcpDirect(createSpawnRequest({ task: "first pending ACP child" }), context); | |
| await vi.waitFor(() => expect(dispatchedRuns).toBe(1)); | |
| const second = await spawnAcpDirect( | |
| createSpawnRequest({ task: "second admitted ACP child" }), | |
| context, | |
| ); | |
| releaseFirstDispatch(); | |
| const firstResult = await first; | |
| expectAcceptedSpawn(firstResult); | |
| expectAcceptedSpawn(second); | |
| expect(dispatchedRuns).toBe(2); | |
| expect(hoisted.registerSubagentRunMock).toHaveBeenCalledTimes(2); | |
| }); | |
| it("counts unrelated ACP task rows separately from anonymous child reservations", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.listTasksForOwnerKeyMock.mockReturnValue([ | |
| { | |
| runtime: "acp", | |
| status: "running", | |
| childSessionKey: "agent:codex:acp:independent-task", | |
| }, | |
| ]); | |
| const controllerSessionKey = "agent:main:subagent:parent"; | |
| const pendingNativeChild = reserveChildAdmissionSlot({ | |
| controllerSessionKey, | |
| resolveAdmission: () => ({ ok: true as const }), | |
| }); | |
| if (!pendingNativeChild.ok) { | |
| throw new Error("Expected native child reservation"); | |
| } | |
| try { | |
| const rejected = await spawnAcpDirect(createSpawnRequest(), { | |
| ...createRequesterContext(), | |
| agentSessionKey: controllerSessionKey, | |
| }); | |
| expect(expectFailedSpawn(rejected, "forbidden")).toMatchObject({ | |
| errorCode: "subagent_policy", | |
| error: expect.stringContaining("max active children for this session (2/2"), | |
| }); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| } finally { | |
| pendingNativeChild.release(); | |
| } | |
| }); | |
| it("returns ACP child capacity after run registration fails", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 1, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.registerSubagentRunMock.mockImplementationOnce(() => { | |
| throw new Error("registry unavailable"); | |
| }); | |
| const context = { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }; | |
| const failed = await spawnAcpDirect( | |
| createSpawnRequest({ task: "unregistered child" }), | |
| context, | |
| ); | |
| const replacement = await spawnAcpDirect( | |
| createSpawnRequest({ task: "replacement child" }), | |
| context, | |
| ); | |
| expect(expectFailedSpawn(failed, "error")).toMatchObject({ | |
| errorCode: "spawn_failed", | |
| error: expect.stringContaining("registry unavailable"), | |
| }); | |
| expectAcceptedSpawn(replacement); | |
| expect(hoisted.registerSubagentRunMock).toHaveBeenCalledTimes(2); | |
| }); | |
| it('counts streamTo="parent" ACP runs toward subagent child caps', async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 1, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([ | |
| { | |
| runtime: "acp", | |
| status: "running", | |
| childSessionKey: "agent:codex:acp:existing-parent-stream", | |
| }, | |
| ]); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| streamTo: "parent", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| const failed = expectFailedSpawn(result, "forbidden"); | |
| expect(failed.errorCode).toBe("subagent_policy"); | |
| expect(failed.error).toContain("max active children"); | |
| }); | |
| it("does not double-count duplicate ACP task rows for the same child session", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([ | |
| { | |
| runtime: "acp", | |
| status: "running", | |
| childSessionKey: "agent:codex:acp:existing-parent-stream", | |
| }, | |
| { | |
| runtime: "acp", | |
| status: "queued", | |
| childSessionKey: "agent:codex:acp:existing-parent-stream", | |
| }, | |
| ]); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| streamTo: "parent", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| }); | |
| it("does not double-count ACP task rows for active registry-tracked ACP children", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| subagents: { | |
| ...hoisted.state.cfg.agents?.defaults?.subagents, | |
| maxChildrenPerAgent: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.countActiveRunsForSessionMock.mockReturnValueOnce(1); | |
| hoisted.getSubagentRunByChildSessionKeyMock.mockImplementationOnce((childSessionKey: string) => | |
| childSessionKey === "agent:codex:acp:existing-parent-stream" | |
| ? { | |
| childSessionKey, | |
| createdAt: Date.now(), | |
| execution: { status: "running", startedAt: Date.now() }, | |
| } | |
| : null, | |
| ); | |
| hoisted.listTasksForOwnerKeyMock.mockReturnValueOnce([ | |
| { | |
| runtime: "acp", | |
| status: "running", | |
| childSessionKey: "agent:codex:acp:existing-parent-stream", | |
| }, | |
| ]); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| streamTo: "parent", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| }); | |
| it("allows configured ACP harness ids when subagent allowlist contains wildcard", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| ...hoisted.state.cfg.acp, | |
| allowedAgents: ["codex", "writer"], | |
| }, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| list: [ | |
| { | |
| id: "main", | |
| default: true, | |
| subagents: { | |
| allowAgents: ["*"], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| agentId: "writer", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| expectAcceptedSpawn(result); | |
| }); | |
| it("rejects unconfigured ACP harness ids when subagent allowlist contains wildcard", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| ...hoisted.state.cfg.acp, | |
| allowedAgents: [], | |
| }, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| list: [ | |
| { | |
| id: "main", | |
| default: true, | |
| subagents: { | |
| allowAgents: ["*"], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| agentId: "writer", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| const failed = expectFailedSpawn(result, "forbidden"); | |
| expect(failed.errorCode).toBe("subagent_policy"); | |
| expect(failed.error).toBe( | |
| 'agentId "writer" is not in the configured agent registry (allowed: main)', | |
| ); | |
| }); | |
| it("rejects ACP spawns to agents outside the subagent allowlist", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| ...hoisted.state.cfg.acp, | |
| allowedAgents: ["codex", "writer"], | |
| }, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| list: [ | |
| { | |
| id: "main", | |
| default: true, | |
| subagents: { | |
| allowAgents: ["codex"], | |
| }, | |
| }, | |
| { | |
| id: "writer", | |
| }, | |
| ], | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| agentId: "writer", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| const failed = expectFailedSpawn(result, "forbidden"); | |
| expect(failed.errorCode).toBe("subagent_policy"); | |
| expect(failed.error).toContain("agentId is not allowed"); | |
| }); | |
| it("rejects explicit ACP self-targets when the subagent allowlist excludes the requester", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| ...hoisted.state.cfg.acp, | |
| allowedAgents: ["codex", "writer"], | |
| }, | |
| agents: { | |
| defaults: { | |
| subagents: { | |
| allowAgents: ["writer"], | |
| maxSpawnDepth: 2, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| createSpawnRequest({ | |
| agentId: "codex", | |
| }), | |
| { | |
| ...createRequesterContext(), | |
| agentSessionKey: "agent:codex:subagent:parent", | |
| }, | |
| ); | |
| const failed = expectFailedSpawn(result, "forbidden"); | |
| expect(failed.errorCode).toBe("subagent_policy"); | |
| expect(failed.error).toContain("agentId is not allowed"); | |
| }); | |
| it("spawns Matrix thread-bound ACP sessions from top-level room targets", async () => { | |
| enableMatrixAcpThreadBindings(); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string; parentConversationId?: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "matrix", | |
| accountId: input.conversation.accountId, | |
| conversationId: "child-thread", | |
| parentConversationId: input.conversation.parentConversationId ?? "!room:example.org", | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| webhookId: "wh-1", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| cwd: os.tmpdir(), | |
| }, | |
| { | |
| agentSessionKey: "agent:main:matrix:channel:!room:example.org", | |
| agentChannel: "matrix", | |
| agentAccountId: "default", | |
| agentTo: "room:!room:example.org", | |
| agentGroupId: "!room:example.org", | |
| }, | |
| ); | |
| expect(result.status, JSON.stringify(result)).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "child", | |
| conversation: { | |
| channel: "matrix", | |
| accountId: "default", | |
| conversationId: "!room:example.org", | |
| }, | |
| }); | |
| expectAgentGatewayCall({ | |
| deliver: true, | |
| channel: "matrix", | |
| to: "room:!room:example.org", | |
| threadId: "child-thread", | |
| }); | |
| }); | |
| it("keeps canonical Matrix room casing for ACP thread bindings", async () => { | |
| enableMatrixAcpThreadBindings(); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string; parentConversationId?: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "matrix", | |
| accountId: input.conversation.accountId, | |
| conversationId: "child-thread", | |
| parentConversationId: input.conversation.parentConversationId ?? "!Room:Example.org", | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| webhookId: "wh-1", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:matrix:channel:!room:example.org", | |
| agentChannel: "matrix", | |
| agentAccountId: "default", | |
| agentTo: "room:!Room:Example.org", | |
| agentGroupId: "!room:example.org", | |
| }, | |
| ); | |
| expect(result.status, JSON.stringify(result)).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "child", | |
| conversation: { | |
| channel: "matrix", | |
| accountId: "default", | |
| conversationId: "!Room:Example.org", | |
| }, | |
| }); | |
| expectAgentGatewayCall({ | |
| deliver: true, | |
| channel: "matrix", | |
| to: "room:!Room:Example.org", | |
| threadId: "child-thread", | |
| }); | |
| }); | |
| it("preserves Matrix parent room casing when binding from an existing thread", async () => { | |
| enableMatrixAcpThreadBindings(); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string; parentConversationId?: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "matrix", | |
| accountId: input.conversation.accountId, | |
| conversationId: "child-thread", | |
| parentConversationId: input.conversation.parentConversationId ?? "!Room:Example.org", | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| webhookId: "wh-1", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:matrix:channel:!room:example.org:thread:$thread-root", | |
| agentChannel: "matrix", | |
| agentAccountId: "default", | |
| agentTo: "room:!Room:Example.org", | |
| agentThreadId: "$thread-root", | |
| agentGroupId: "!room:example.org", | |
| }, | |
| ); | |
| expect(result.status, JSON.stringify(result)).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "child", | |
| conversation: { | |
| channel: "matrix", | |
| accountId: "default", | |
| conversationId: "$thread-root", | |
| parentConversationId: "!Room:Example.org", | |
| }, | |
| }); | |
| expectAgentGatewayCall({ | |
| deliver: true, | |
| channel: "matrix", | |
| to: "room:!Room:Example.org", | |
| threadId: "child-thread", | |
| }); | |
| }); | |
| it("uses the target agent workspace for cross-agent ACP spawns when cwd is omitted", async () => { | |
| const fixture = await createCrossAgentWorkspaceFixture(); | |
| try { | |
| configureCrossAgentWorkspaceSpawn(fixture); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Inspect the queue owner state", | |
| agentId: "claude-code", | |
| mode: "run", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| const initInput = expectInitializeSessionFields({ | |
| agent: "claude-code", | |
| cwd: fixture.targetWorkspace, | |
| }); | |
| expect(initInput.sessionKey).toMatch(/^agent:claude-code:acp:/); | |
| } finally { | |
| await fs.rm(fixture.workspaceRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("falls back to backend default cwd when the inherited target workspace does not exist", async () => { | |
| const fixture = await createCrossAgentWorkspaceFixture({ | |
| targetDirName: "claude-code-missing", | |
| createTargetWorkspace: false, | |
| }); | |
| try { | |
| configureCrossAgentWorkspaceSpawn(fixture); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Inspect the queue owner state", | |
| agentId: "claude-code", | |
| mode: "run", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| const initInput = expectInitializeSessionFields({ | |
| agent: "claude-code", | |
| cwd: undefined, | |
| }); | |
| expect(initInput.sessionKey).toMatch(/^agent:claude-code:acp:/); | |
| } finally { | |
| await fs.rm(fixture.workspaceRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("surfaces non-missing target workspace access failures instead of silently dropping cwd", async () => { | |
| const fixture = await createCrossAgentWorkspaceFixture(); | |
| const accessSpy = vi.spyOn(fs, "access"); | |
| try { | |
| configureCrossAgentWorkspaceSpawn(fixture); | |
| accessSpy.mockRejectedValueOnce( | |
| Object.assign(new Error("permission denied"), { code: "EACCES" }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Inspect the queue owner state", | |
| agentId: "claude-code", | |
| mode: "run", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(result).toEqual({ | |
| status: "error", | |
| errorCode: "cwd_resolution_failed", | |
| error: "permission denied", | |
| }); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| } finally { | |
| accessSpy.mockRestore(); | |
| await fs.rm(fixture.workspaceRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it("binds LINE ACP sessions to the current conversation when the channel has no native threads", async () => { | |
| enableLineCurrentConversationBindings(); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "line", | |
| accountId: input.conversation.accountId, | |
| conversationId: input.conversation.conversationId, | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:line:direct:U1234567890abcdef1234567890abcdef", | |
| agentChannel: "line", | |
| agentAccountId: "default", | |
| agentTo: "U1234567890abcdef1234567890abcdef", | |
| }, | |
| ); | |
| expect(result.status, JSON.stringify(result)).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "line", | |
| accountId: "default", | |
| conversationId: "U1234567890abcdef1234567890abcdef", | |
| }, | |
| }); | |
| expectAgentGatewayCall({ | |
| deliver: true, | |
| channel: "line", | |
| to: "U1234567890abcdef1234567890abcdef", | |
| threadId: undefined, | |
| }); | |
| const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map( | |
| (call: unknown[]) => call[0] as { threadId?: string }, | |
| ); | |
| expect(transcriptCalls).toHaveLength(1); | |
| expect(transcriptCalls[0]?.threadId).toBeUndefined(); | |
| }); | |
| it("binds ACP sessions through the configured default account when accountId is omitted", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| channels: { | |
| ...hoisted.state.cfg.channels, | |
| custom: { | |
| defaultAccount: "work", | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| accounts: { | |
| work: { | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| registerSessionBindingAdapter({ | |
| channel: "custom", | |
| accountId: "work", | |
| capabilities: { | |
| bindSupported: true, | |
| unbindSupported: true, | |
| placements: ["current"] satisfies SessionBindingPlacement[], | |
| }, | |
| bind: async (input) => await hoisted.sessionBindingBindMock(input), | |
| listBySession: (targetSessionKey) => | |
| hoisted.sessionBindingListBySessionMock(targetSessionKey), | |
| resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), | |
| unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), | |
| }); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "custom", | |
| accountId: input.conversation.accountId, | |
| conversationId: input.conversation.conversationId, | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:custom:channel:123456", | |
| agentChannel: "custom", | |
| agentTo: "channel:123456", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "custom", | |
| accountId: "work", | |
| conversationId: "123456", | |
| }, | |
| }); | |
| expectAgentGatewayCall({ | |
| deliver: true, | |
| channel: "custom", | |
| to: "channel:123456", | |
| threadId: undefined, | |
| }); | |
| expect(findAgentGatewayCall()?.params?.accountId).toBe("work"); | |
| }); | |
| it("uses the target agent's bound account for cross-agent ACP thread spawns", async () => { | |
| const boundRoom = "!room:example.org"; | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| ...hoisted.state.cfg.acp, | |
| allowedAgents: ["codex", "bot-alpha"], | |
| }, | |
| channels: { | |
| ...hoisted.state.cfg.channels, | |
| matrix: { | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| accounts: { | |
| "bot-alpha": { | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: true, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| bindings: [ | |
| { | |
| type: "route", | |
| agentId: "bot-alpha", | |
| match: { | |
| channel: "matrix", | |
| peer: { | |
| kind: "channel", | |
| id: boundRoom, | |
| }, | |
| accountId: "bot-alpha", | |
| }, | |
| }, | |
| ], | |
| }); | |
| registerSessionBindingAdapter({ | |
| channel: "matrix", | |
| accountId: "bot-alpha", | |
| capabilities: createSessionBindingCapabilities(), | |
| bind: async (input) => await hoisted.sessionBindingBindMock(input), | |
| listBySession: (targetSessionKey) => | |
| hoisted.sessionBindingListBySessionMock(targetSessionKey), | |
| resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), | |
| unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), | |
| }); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { | |
| accountId: string; | |
| conversationId: string; | |
| parentConversationId?: string; | |
| }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "matrix", | |
| accountId: input.conversation.accountId, | |
| conversationId: input.conversation.conversationId, | |
| parentConversationId: input.conversation.parentConversationId, | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "bot-alpha", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "bot-alpha", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:matrix:room:requester", | |
| agentChannel: "matrix", | |
| agentAccountId: "bot-beta", | |
| agentTo: `room:${boundRoom}`, | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "child", | |
| conversation: { | |
| channel: "matrix", | |
| accountId: "bot-alpha", | |
| conversationId: boundRoom, | |
| }, | |
| }); | |
| expectRecordFields(gatewayRequest("agent").params, { | |
| deliver: true, | |
| channel: "matrix", | |
| accountId: "bot-alpha", | |
| to: `room:${boundRoom}`, | |
| }); | |
| expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| requesterOrigin: expect.objectContaining({ | |
| channel: "matrix", | |
| accountId: "bot-alpha", | |
| to: `room:${boundRoom}`, | |
| }), | |
| }), | |
| ); | |
| }); | |
| it.each([ | |
| { | |
| name: "canonical line target", | |
| agentTo: "line:U1234567890abcdef1234567890abcdef", | |
| expectedConversationId: "U1234567890abcdef1234567890abcdef", | |
| }, | |
| { | |
| name: "typed line user target", | |
| agentTo: "line:user:U1234567890abcdef1234567890abcdef", | |
| expectedConversationId: "U1234567890abcdef1234567890abcdef", | |
| }, | |
| { | |
| name: "typed line group target", | |
| agentTo: "line:group:C1234567890abcdef1234567890abcdef", | |
| expectedConversationId: "C1234567890abcdef1234567890abcdef", | |
| }, | |
| { | |
| name: "typed line room target", | |
| agentTo: "line:room:R1234567890abcdef1234567890abcdef", | |
| expectedConversationId: "R1234567890abcdef1234567890abcdef", | |
| }, | |
| ])( | |
| "resolves LINE ACP conversation ids from $name", | |
| async ({ agentTo, expectedConversationId }) => { | |
| enableLineCurrentConversationBindings(); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "line", | |
| accountId: input.conversation.accountId, | |
| conversationId: input.conversation.conversationId, | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: `agent:main:line:direct:${expectedConversationId}`, | |
| agentChannel: "line", | |
| agentAccountId: "default", | |
| agentTo, | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "line", | |
| accountId: "default", | |
| conversationId: expectedConversationId, | |
| }, | |
| }); | |
| }, | |
| ); | |
| it("preserves LINE fallback conversation precedence when groupId is present", async () => { | |
| enableLineCurrentConversationBindings(); | |
| hoisted.sessionBindingBindMock.mockImplementationOnce( | |
| async (input: { | |
| targetSessionKey: string; | |
| conversation: { accountId: string; conversationId: string }; | |
| metadata?: Record<string, unknown>; | |
| }) => | |
| createSessionBinding({ | |
| targetSessionKey: input.targetSessionKey, | |
| conversation: { | |
| channel: "line", | |
| accountId: input.conversation.accountId, | |
| conversationId: input.conversation.conversationId, | |
| }, | |
| metadata: { | |
| boundBy: | |
| typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", | |
| agentId: "codex", | |
| }, | |
| }), | |
| ); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:line:direct:R1234567890abcdef1234567890abcdef", | |
| agentChannel: "line", | |
| agentAccountId: "default", | |
| agentTo: "line:user:U1234567890abcdef1234567890abcdef", | |
| agentGroupId: "line:room:R1234567890abcdef1234567890abcdef", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "line", | |
| accountId: "default", | |
| conversationId: "R1234567890abcdef1234567890abcdef", | |
| }, | |
| }); | |
| }); | |
| it.each([ | |
| { | |
| name: "does not inline delivery for run-mode spawns from non-subagent requester sessions", | |
| ctx: createRequesterContext(), | |
| expectedAgentCall: { | |
| deliver: false, | |
| channel: undefined, | |
| to: undefined, | |
| threadId: undefined, | |
| } satisfies AgentCallParams, | |
| expectTranscriptPersistence: false, | |
| }, | |
| { | |
| name: "does not inline delivery for run-mode spawns from subagent requester sessions", | |
| ctx: createRequesterContext({ | |
| agentSessionKey: "agent:main:subagent:orchestrator", | |
| agentThreadId: undefined, | |
| }), | |
| expectedAgentCall: { | |
| deliver: false, | |
| channel: undefined, | |
| to: undefined, | |
| threadId: undefined, | |
| } satisfies AgentCallParams, | |
| expectTranscriptPersistence: false, | |
| }, | |
| ])("$name", async ({ ctx, expectedAgentCall, expectTranscriptPersistence }) => { | |
| const result = await spawnAcpDirect(createSpawnRequest(), ctx); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(accepted.expectsCompletionMessage).toBe(true); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| if (expectTranscriptPersistence) { | |
| expectRecordFields( | |
| firstMockCall(hoisted.resolveSessionTranscriptFileMock, "transcript file resolution")[0], | |
| { | |
| sessionId: "sess-123", | |
| storePath: "/tmp/codex-sessions.json", | |
| agentId: "codex", | |
| }, | |
| ); | |
| } | |
| expectAgentGatewayCall(expectedAgentCall); | |
| }); | |
| it("keeps ACP spawn running when session-file persistence fails", async () => { | |
| hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full")); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "run", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| agentChannel: "telegram", | |
| agentAccountId: "default", | |
| agentTo: "telegram:6098642967", | |
| agentThreadId: "1", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); | |
| const agentCall = hoisted.callGatewayMock.mock.calls | |
| .map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> }) | |
| .find((request) => request.method === "agent"); | |
| expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey); | |
| }); | |
| it("includes cwd in ACP thread intro banner when provided at spawn time", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Check workspace", | |
| agentId: "codex", | |
| cwd: "/home/bob/clawd", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| const bindInput = expectBindingCallFields({}); | |
| const metadata = expectRecordFields(bindInput.metadata, {}); | |
| expect(typeof metadata.introText).toBe("string"); | |
| expect(metadata.introText).toContain("cwd: /home/bob/clawd"); | |
| }); | |
| it("rejects disallowed ACP agents", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| acp: { | |
| enabled: true, | |
| backend: "acpx", | |
| allowedAgents: ["claudecode"], | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "hello", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expectRecordFields(result, { | |
| status: "forbidden", | |
| }); | |
| }); | |
| it("requires an explicit ACP agent when no config default exists", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "hello", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(expectFailedSpawn(result, "error").error).toContain("set `acp.defaultAgent`"); | |
| }); | |
| it("fails fast when Discord ACP thread spawn is disabled", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| session: { | |
| ...hoisted.state.cfg.session, | |
| threadBindings: { | |
| enabled: true, | |
| spawnSessions: false, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "hello", | |
| agentId: "codex", | |
| thread: true, | |
| mode: "session", | |
| }, | |
| { | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| expect(expectFailedSpawn(result, "error").error).toContain("spawnSessions=true"); | |
| }); | |
| it("forbids ACP spawn from sandboxed requester sessions", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| sandbox: { mode: "all" }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "hello", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:parent", | |
| }, | |
| ); | |
| expect(expectFailedSpawn(result, "forbidden").error).toContain( | |
| "Sandboxed sessions cannot spawn ACP sessions", | |
| ); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| }); | |
| it('forbids sandbox="require" for runtime=acp', async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "hello", | |
| agentId: "codex", | |
| sandbox: "require", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(expectFailedSpawn(result, "forbidden").error).toContain('sandbox="require"'); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| }); | |
| it('streams ACP progress to parent when streamTo="parent"', async () => { | |
| const firstHandle = createRelayHandle(); | |
| const secondHandle = createRelayHandle(); | |
| hoisted.startAcpSpawnParentStreamRelayMock | |
| .mockReset() | |
| .mockReturnValueOnce(firstHandle) | |
| .mockReturnValueOnce(secondHandle); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| streamTo: "parent", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| const agentCall = hoisted.callGatewayMock.mock.calls | |
| .map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> }) | |
| .find((request) => request.method === "agent"); | |
| const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex( | |
| (call: unknown[]) => (call[0] as { method?: string }).method === "agent", | |
| ); | |
| const relayCallOrder = expectDefined( | |
| hoisted.startAcpSpawnParentStreamRelayMock.mock.invocationCallOrder[0], | |
| "hoisted.startAcpSpawnParentStreamRelayMock.mock.invocationCallOrder[0] test invariant", | |
| ); | |
| const agentCallOrder = expectDefined( | |
| hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex], | |
| "hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant", | |
| ); | |
| expect(agentCall?.params?.deliver).toBe(false); | |
| expect(typeof relayCallOrder).toBe("number"); | |
| expect(typeof agentCallOrder).toBe("number"); | |
| expect(relayCallOrder < agentCallOrder).toBe(true); | |
| expectRelayCallFields({ | |
| parentSessionKey: "agent:main:main", | |
| agentId: "codex", | |
| childSessionId: "sess-123", | |
| emitStartNotice: false, | |
| }); | |
| const relayRuns = hoisted.startAcpSpawnParentStreamRelayMock.mock.calls.map( | |
| (call: unknown[]) => (call[0] as { runId?: string }).runId, | |
| ); | |
| expect(relayRuns).toContain(agentCall?.params?.idempotencyKey); | |
| expect(relayRuns).toContain(accepted.runId); | |
| expect(firstHandle.dispose).toHaveBeenCalledTimes(1); | |
| expect(firstHandle.notifyStarted).not.toHaveBeenCalled(); | |
| expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); | |
| }); | |
| it("implicitly streams mode=run ACP spawns for subagent requester sessions", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| heartbeat: { | |
| every: "30m", | |
| target: "last", | |
| }, | |
| }, | |
| }, | |
| }); | |
| const firstHandle = createRelayHandle(); | |
| const secondHandle = createRelayHandle(); | |
| hoisted.startAcpSpawnParentStreamRelayMock | |
| .mockReset() | |
| .mockReturnValueOnce(firstHandle) | |
| .mockReturnValueOnce(secondHandle); | |
| hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { | |
| const store: Record< | |
| string, | |
| { sessionId: string; updatedAt: number; delivery?: SessionEntry["delivery"] } | |
| > = { | |
| "agent:main:subagent:parent": { | |
| sessionId: "parent-sess-1", | |
| updatedAt: Date.now(), | |
| delivery: normalizeSessionDeliveryState({ | |
| context: { | |
| channel: "discord", | |
| to: "channel:parent-channel", | |
| accountId: "default", | |
| }, | |
| }), | |
| }, | |
| }; | |
| return new Proxy(store, { | |
| get(target, prop) { | |
| if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { | |
| return { sessionId: "sess-123", updatedAt: Date.now() }; | |
| } | |
| return target[prop as keyof typeof target]; | |
| }, | |
| }); | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:parent", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| const agentCall = hoisted.callGatewayMock.mock.calls | |
| .map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> }) | |
| .find((request) => request.method === "agent"); | |
| expect(agentCall?.params?.deliver).toBe(false); | |
| expect(agentCall?.params?.channel).toBeUndefined(); | |
| expect(agentCall?.params?.to).toBeUndefined(); | |
| expect(agentCall?.params?.threadId).toBeUndefined(); | |
| expectRelayCallFields({ | |
| parentSessionKey: "agent:main:subagent:parent", | |
| agentId: "codex", | |
| childSessionId: "sess-123", | |
| deliveryContext: { | |
| channel: "discord", | |
| to: "channel:parent-channel", | |
| accountId: "default", | |
| }, | |
| emitStartNotice: false, | |
| }); | |
| expect(firstHandle.dispose).toHaveBeenCalledTimes(1); | |
| expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); | |
| }); | |
| it("does not implicitly stream for ACP requester sessions inside a subagent envelope", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| heartbeat: { | |
| every: "30m", | |
| target: "last", | |
| }, | |
| }, | |
| }, | |
| }); | |
| hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { | |
| const store: Record< | |
| string, | |
| { | |
| sessionId: string; | |
| updatedAt: number; | |
| delivery?: SessionEntry["delivery"]; | |
| spawnedBy?: string; | |
| spawnDepth?: number; | |
| subagentRole?: string; | |
| subagentControlScope?: string; | |
| } | |
| > = { | |
| "agent:main:acp:child": { | |
| sessionId: "parent-sess-1", | |
| updatedAt: Date.now(), | |
| delivery: normalizeSessionDeliveryState({ | |
| context: { | |
| channel: "discord", | |
| to: "channel:parent-channel", | |
| accountId: "default", | |
| }, | |
| }), | |
| spawnedBy: "agent:main:subagent:parent", | |
| spawnDepth: 1, | |
| subagentRole: "orchestrator", | |
| subagentControlScope: "children", | |
| }, | |
| }; | |
| return new Proxy(store, { | |
| get(target, prop) { | |
| if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { | |
| return { sessionId: "sess-123", updatedAt: Date.now() }; | |
| } | |
| return target[prop as keyof typeof target]; | |
| }, | |
| }); | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:acp:child", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream when heartbeat target is not session-local", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| heartbeat: { | |
| every: "30m", | |
| target: "discord", | |
| to: "channel:ops-room", | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:fixed-target", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream when session scope is global", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| session: { | |
| ...hoisted.state.cfg.session, | |
| scope: "global", | |
| }, | |
| agents: { | |
| defaults: { | |
| ...hoisted.state.cfg.agents?.defaults, | |
| heartbeat: { | |
| every: "30m", | |
| target: "last", | |
| }, | |
| }, | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:global-scope", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it.each(["off", "all"] as const)( | |
| "preserves global requester ownership with sandbox mode %s", | |
| async (sandboxMode) => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| ownership: "explicit", | |
| entries: { | |
| research: { sandbox: { mode: sandboxMode } }, | |
| ops: {}, | |
| }, | |
| }, | |
| session: { | |
| ...hoisted.state.cfg.session, | |
| scope: "global", | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "global", | |
| requesterAgentIdOverride: "research", | |
| }, | |
| ); | |
| if (sandboxMode === "all") { | |
| expect(expectFailedSpawn(result, "forbidden").error).toContain( | |
| "Sandboxed sessions cannot spawn ACP sessions", | |
| ); | |
| expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| return; | |
| } | |
| expectAcceptedSpawn(result); | |
| expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| requesterSessionKey: "global", | |
| childSessionKey: expect.stringMatching(/^agent:codex:acp:/), | |
| agentId: "codex", | |
| requesterAgentId: "research", | |
| }), | |
| ); | |
| }, | |
| ); | |
| it("does not implicitly stream for subagent requester sessions when heartbeat is disabled", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }], | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:research:subagent:orchestrator", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream for subagent requester sessions when heartbeat cadence is invalid", async () => { | |
| replaceSpawnConfig({ | |
| ...hoisted.state.cfg, | |
| agents: { | |
| ...hoisted.state.cfg.agents, | |
| list: [ | |
| { | |
| id: "research", | |
| heartbeat: { every: "0m" }, | |
| }, | |
| ], | |
| }, | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:research:subagent:invalid-heartbeat", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream when heartbeats are runtime-disabled", async () => { | |
| hoisted.areHeartbeatsEnabledMock.mockReturnValue(false); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:runtime-disabled", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream for legacy subagent requester session keys", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "subagent:legacy-worker", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream for subagent requester sessions with thread context", async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:thread-context", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| agentThreadId: "requester-thread", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("does not implicitly stream for thread-bound subagent requester sessions", async () => { | |
| hoisted.sessionBindingListBySessionMock.mockImplementation((targetSessionKey: string) => { | |
| if (targetSessionKey === "agent:main:subagent:thread-bound") { | |
| return [ | |
| createSessionBinding({ | |
| targetSessionKey, | |
| targetKind: "subagent", | |
| status: "active", | |
| }), | |
| ]; | |
| } | |
| return []; | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:subagent:thread-bound", | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("run"); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| it("announces parent relay start only after successful child dispatch", async () => { | |
| const firstHandle = createRelayHandle(); | |
| const secondHandle = createRelayHandle(); | |
| hoisted.startAcpSpawnParentStreamRelayMock | |
| .mockReset() | |
| .mockReturnValueOnce(firstHandle) | |
| .mockReturnValueOnce(secondHandle); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| streamTo: "parent", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expect(firstHandle.notifyStarted).not.toHaveBeenCalled(); | |
| expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); | |
| const notifyOrder = secondHandle.notifyStarted.mock.invocationCallOrder; | |
| const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex( | |
| (call: unknown[]) => (call[0] as { method?: string }).method === "agent", | |
| ); | |
| const agentCallOrder = expectDefined( | |
| hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex], | |
| "hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant", | |
| ); | |
| expect(typeof agentCallOrder).toBe("number"); | |
| expect(typeof notifyOrder[0]).toBe("number"); | |
| expect(expectDefined(notifyOrder[0], "notifyOrder[0] test invariant") > agentCallOrder).toBe( | |
| true, | |
| ); | |
| }); | |
| it("binds Telegram forum-topic ACP sessions to the current topic", async () => { | |
| enableTelegramCurrentConversationBindings(); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:telegram:group:-1003342490704:topic:2", | |
| agentChannel: "telegram", | |
| agentAccountId: "default", | |
| agentTo: "telegram:-1003342490704", | |
| agentThreadId: "2", | |
| agentGroupId: "-1003342490704", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("session"); | |
| const binding = expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "telegram", | |
| accountId: "default", | |
| }, | |
| }); | |
| const conversation = expectRecordFields(binding.conversation, {}); | |
| const conversationId = | |
| typeof conversation.conversationId === "string" ? conversation.conversationId : ""; | |
| const parentConversationId = | |
| typeof conversation.parentConversationId === "string" | |
| ? conversation.parentConversationId | |
| : undefined; | |
| const canonicalTopicId = parentConversationId | |
| ? `${parentConversationId}:topic:${conversationId}` | |
| : conversationId; | |
| expect(canonicalTopicId).toBe("-1003342490704:topic:2"); | |
| const agentCall = hoisted.callGatewayMock.mock.calls | |
| .map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> }) | |
| .find((request) => request.method === "agent"); | |
| expect(agentCall?.params?.deliver).toBe(true); | |
| expect(agentCall?.params?.channel).toBe("telegram"); | |
| }); | |
| it("drops self-parent Telegram current-conversation refs before binding", async () => { | |
| enableTelegramCurrentConversationBindings(); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:telegram:direct:6098642967", | |
| agentChannel: "telegram", | |
| agentAccountId: "default", | |
| agentTo: "telegram:6098642967", | |
| }, | |
| ); | |
| const accepted = expectAcceptedSpawn(result); | |
| expect(accepted.mode).toBe("session"); | |
| expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "telegram", | |
| accountId: "default", | |
| conversationId: "6098642967", | |
| }, | |
| }); | |
| const bindCall = latestBindingInput(); | |
| const conversation = expectRecordFields(bindCall.conversation, {}); | |
| expect(conversation.parentConversationId).toBeUndefined(); | |
| }); | |
| it("preserves topic-qualified Telegram targets without a separate threadId", async () => { | |
| enableTelegramCurrentConversationBindings(); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| mode: "session", | |
| thread: true, | |
| }, | |
| { | |
| agentSessionKey: "agent:main:telegram:group:-1003342490704:topic:2", | |
| agentChannel: "telegram", | |
| agentAccountId: "default", | |
| agentTo: "telegram:group:-1003342490704:topic:2", | |
| }, | |
| ); | |
| expect(result.status).toBe("accepted"); | |
| expectBindingCallFields({ | |
| placement: "current", | |
| conversation: { | |
| channel: "telegram", | |
| accountId: "default", | |
| conversationId: "-1003342490704:topic:2", | |
| }, | |
| }); | |
| }); | |
| it("disposes pre-registered parent relay when initial ACP dispatch fails", async () => { | |
| const relayHandle = createRelayHandle(); | |
| hoisted.startAcpSpawnParentStreamRelayMock.mockReturnValueOnce(relayHandle); | |
| hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { | |
| const args = argsUnknown as { method?: string }; | |
| if (args.method === "sessions.patch") { | |
| return { ok: true }; | |
| } | |
| if (args.method === "agent") { | |
| throw new Error("agent dispatch failed"); | |
| } | |
| if (args.method === "sessions.delete") { | |
| return { ok: true }; | |
| } | |
| return {}; | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| streamTo: "parent", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| expect(expectFailedSpawn(result, "error").error).toContain("agent dispatch failed"); | |
| expect(relayHandle.dispose).toHaveBeenCalledTimes(1); | |
| expect(relayHandle.notifyStarted).not.toHaveBeenCalled(); | |
| expect(hoisted.cleanupFailedAcpSpawnMock).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| sessionEntry: expect.objectContaining({ sessionId: expect.any(String) }), | |
| closeRuntimeOnFailure: hoisted.closeRuntimeOnFailureMock, | |
| }), | |
| ); | |
| }); | |
| it("preserves the ACP failure code when run registration fails", async () => { | |
| hoisted.registerSubagentRunMock.mockImplementationOnce(() => { | |
| throw new Error("registry unavailable"); | |
| }); | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| }, | |
| { | |
| agentSessionKey: "agent:main:main", | |
| }, | |
| ); | |
| const failed = expectFailedSpawn(result, "error"); | |
| expect(failed.errorCode).toBe("spawn_failed"); | |
| expect(failed.error).toContain("registry unavailable"); | |
| expect(failed.runId).toBe("run-1"); | |
| expect(hoisted.cleanupFailedAcpSpawnMock).toHaveBeenCalledTimes(1); | |
| }); | |
| it('rejects streamTo="parent" without requester session context', async () => { | |
| const result = await spawnAcpDirect( | |
| { | |
| task: "Investigate flaky tests", | |
| agentId: "codex", | |
| streamTo: "parent", | |
| }, | |
| { | |
| agentChannel: "discord", | |
| agentAccountId: "default", | |
| agentTo: "channel:parent-channel", | |
| }, | |
| ); | |
| expect(expectFailedSpawn(result, "error").error).toContain('streamTo="parent"'); | |
| expect(hoisted.callGatewayMock).not.toHaveBeenCalled(); | |
| expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); | |
| }); | |
| }); | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |