/** 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; 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) : 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[0]; type SpawnContext = Parameters[1]; type SpawnResult = Awaited>; 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; 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 { 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; notifyStarted?: ReturnType; }) { 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 { return { task: "Investigate flaky tests", agentId: "codex", mode: "run", ...overrides, }; } function createRequesterContext(overrides?: Partial): 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 { 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 } | undefined { return hoisted.callGatewayMock.mock.calls .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) .find((request) => request.method === "agent"); } function expectFailedSpawn( result: SpawnResult, status?: "error" | "forbidden", ): Extract { 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 { 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, ): Record { if (!record || typeof record !== "object") { throw new Error("Expected record"); } const actual = record as Record; 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 { return expectRecordFields(latestMockCall(hoisted.sessionBindingBindMock, "session bind")[0], {}); } function gatewayRequests(): Array<{ method?: string; params?: Record }> { return hoisted.callGatewayMock.mock.calls.map( (call: unknown[]) => call[0] as { method?: string; params?: Record }, ); } function gatewayRequest(method: string): { method?: string; params?: Record } { 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): void { expectRecordFields(firstMockCall(hoisted.upsertSessionEntryMock, "session create")[1], expected); } function expectInitializeSessionFields(expected: Record): Record { return expectRecordFields( firstMockCall(hoisted.initializeSessionMock, "session initialization")[0], expected, ); } function expectBindingCallFields(expected: { conversation?: Record; metadata?: Record; placement?: string; targetKind?: string; }): Record { 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, 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) => ({ ...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; }) => 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 = {}; 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 ( _method: string, _params: Record, options?: NonNullable[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((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((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; }) => 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; }) => 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; }) => 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; }) => 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; }) => 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; }) => 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; }) => 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; }) => 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 }) .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 }) .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 }) .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 }) .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. */