diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 0c5c6d7bcb9..68be3ecac5b 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -327,8 +327,70 @@ function expectAcceptedSpawn(result: SpawnResult): Extract, +): Record { + expect(record).toBeDefined(); + const actual = record as Record; + for (const [key, value] of Object.entries(expected)) { + expect(actual[key]).toEqual(value); + } + return actual; +} + +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); + expect(request).toBeDefined(); + return request as { method?: string; params?: Record }; +} + +function expectGatewayMethodNotCalled(method: string): void { + expect(gatewayRequests().some((request) => request.method === method)).toBe(false); +} + +function expectSessionPatchFields(expected: Record): void { + expectRecordFields(gatewayRequest("sessions.patch").params, expected); +} + +function expectInitializeSessionFields(expected: Record): Record { + return expectRecordFields(hoisted.initializeSessionMock.mock.calls[0]?.[0], expected); +} + +function expectBindingCallFields(expected: { + conversation?: Record; + metadata?: Record; + placement?: string; + targetKind?: string; +}): Record { + const input = expectRecordFields(hoisted.sessionBindingBindMock.mock.calls.at(-1)?.[0], { + ...(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 = findAgentGatewayCall(); + 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); @@ -689,37 +751,28 @@ describe("spawnAcpDirect", () => { expect(accepted.runId).toBe("run-1"); expect(accepted.mode).toBe("session"); expect(accepted.inlineDelivery).toBe(true); - const patchCall = hoisted.callGatewayMock.mock.calls - .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) - .find((request) => request.method === "sessions.patch"); - expect(patchCall?.params).toMatchObject({ + expectSessionPatchFields({ key: accepted.childSessionKey, spawnedBy: "agent:main:main", }); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - targetKind: "session", - placement: "child", - }), - ); + expectBindingCallFields({ + targetKind: "session", + placement: "child", + }); expectResolvedIntroTextInBindMetadata(); - const agentCall = hoisted.callGatewayMock.mock.calls - .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) - .find((request) => request.method === "agent"); + 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"); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: expect.stringMatching(/^agent:codex:acp:/), - agent: "codex", - mode: "persistent", - }), - ); + 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 }, ); @@ -765,11 +818,7 @@ describe("spawnAcpDirect", () => { ); expectAcceptedSpawn(result); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - resumeSessionId, - }), - ); + expectInitializeSessionFields({ resumeSessionId }); }); it("rejects ACP resume IDs not recorded for the requester session", async () => { @@ -807,7 +856,7 @@ describe("spawnAcpDirect", () => { }, ); - expect(result).toMatchObject({ + expectRecordFields(result, { status: "forbidden", errorCode: "resume_forbidden", }); @@ -829,16 +878,14 @@ describe("spawnAcpDirect", () => { ); expectAcceptedSpawn(result); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: expect.stringMatching(/^agent:codex:acp:/), - agent: "codex", - runtimeOptions: { - model: "openai-codex/gpt-5.4", - thinking: "high", - }, - }), - ); + const initInput = expectInitializeSessionFields({ + agent: "codex", + runtimeOptions: { + model: "openai-codex/gpt-5.4", + thinking: "high", + }, + }); + expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); }); it("applies ACP spawn run timeout to runtime options and dispatch", async () => { @@ -854,15 +901,13 @@ describe("spawnAcpDirect", () => { ); expectAcceptedSpawn(result); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: expect.stringMatching(/^agent:codex:acp:/), - agent: "codex", - runtimeOptions: { - timeoutSeconds: 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); @@ -897,15 +942,13 @@ describe("spawnAcpDirect", () => { }, ); - expect(result).toMatchObject({ + expectRecordFields(result, { status: "error", errorCode: "runtime_agent_mismatch", }); expect(result).toHaveProperty("error", expect.stringContaining("OpenClaw config agent")); expect(hoisted.initializeSessionMock).not.toHaveBeenCalled(); - expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith( - expect.objectContaining({ method: "agent" }), - ); + expectGatewayMethodNotCalled("agent"); }); it("maps OpenClaw ACP runtime agent aliases to their configured harness id", async () => { @@ -943,12 +986,8 @@ describe("spawnAcpDirect", () => { ); expectAcceptedSpawn(result); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - agent: "codex", - sessionKey: expect.stringMatching(/^agent:codex:acp:/), - }), - ); + const initInput = expectInitializeSessionFields({ agent: "codex" }); + expect(initInput.sessionKey).toMatch(/^agent:codex:acp:/); }); it("inherits subagent envelope fields onto ACP children", async () => { @@ -971,10 +1010,7 @@ describe("spawnAcpDirect", () => { }); const accepted = expectAcceptedSpawn(result); - const patchCall = hoisted.callGatewayMock.mock.calls - .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) - .find((request) => request.method === "sessions.patch"); - expect(patchCall?.params).toMatchObject({ + expectSessionPatchFields({ key: accepted.childSessionKey, spawnedBy: "agent:main:subagent:parent", spawnDepth: 2, @@ -1261,16 +1297,14 @@ describe("spawnAcpDirect", () => { }, ); expect(result.status, JSON.stringify(result)).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "child", - conversation: expect.objectContaining({ - channel: "matrix", - accountId: "default", - conversationId: "!room:example", - }), - }), - ); + expectBindingCallFields({ + placement: "child", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "!room:example", + }, + }); expectAgentGatewayCall({ deliver: true, channel: "matrix", @@ -1321,16 +1355,14 @@ describe("spawnAcpDirect", () => { ); expect(result.status, JSON.stringify(result)).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "child", - conversation: expect.objectContaining({ - channel: "matrix", - accountId: "default", - conversationId: "!Room:Example.org", - }), - }), - ); + expectBindingCallFields({ + placement: "child", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "!Room:Example.org", + }, + }); expectAgentGatewayCall({ deliver: true, channel: "matrix", @@ -1382,17 +1414,15 @@ describe("spawnAcpDirect", () => { ); expect(result.status, JSON.stringify(result)).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "child", - conversation: expect.objectContaining({ - channel: "matrix", - accountId: "default", - conversationId: "$thread-root", - parentConversationId: "!Room:Example.org", - }), - }), - ); + expectBindingCallFields({ + placement: "child", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "$thread-root", + parentConversationId: "!Room:Example.org", + }, + }); expectAgentGatewayCall({ deliver: true, channel: "matrix", @@ -1418,13 +1448,11 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: expect.stringMatching(/^agent:claude-code:acp:/), - agent: "claude-code", - cwd: fixture.targetWorkspace, - }), - ); + 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 }); } @@ -1450,13 +1478,11 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: expect.stringMatching(/^agent:claude-code:acp:/), - agent: "claude-code", - cwd: undefined, - }), - ); + 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 }); } @@ -1534,16 +1560,14 @@ describe("spawnAcpDirect", () => { ); expect(result.status, JSON.stringify(result)).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "line", - accountId: "default", - conversationId: "U1234567890abcdef1234567890abcdef", - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "line", + accountId: "default", + conversationId: "U1234567890abcdef1234567890abcdef", + }, + }); expectAgentGatewayCall({ deliver: true, channel: "line", @@ -1629,16 +1653,14 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "custom", - accountId: "work", - conversationId: "123456", - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "custom", + accountId: "work", + conversationId: "123456", + }, + }); expectAgentGatewayCall({ deliver: true, channel: "custom", @@ -1740,17 +1762,15 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "child", - conversation: expect.objectContaining({ - channel: "matrix", - accountId: "bot-alpha", - conversationId: boundRoom, - }), - }), - ); - expect(findAgentGatewayCall()?.params).toMatchObject({ + expectBindingCallFields({ + placement: "child", + conversation: { + channel: "matrix", + accountId: "bot-alpha", + conversationId: boundRoom, + }, + }); + expectRecordFields(gatewayRequest("agent").params, { deliver: true, channel: "matrix", accountId: "bot-alpha", @@ -1820,16 +1840,14 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "line", - accountId: "default", - conversationId: expectedConversationId, - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "line", + accountId: "default", + conversationId: expectedConversationId, + }, + }); }, ); @@ -1873,16 +1891,14 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "line", - accountId: "default", - conversationId: "R1234567890abcdef1234567890abcdef", - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "line", + accountId: "default", + conversationId: "R1234567890abcdef1234567890abcdef", + }, + }); }); it.each([ @@ -1919,13 +1935,11 @@ describe("spawnAcpDirect", () => { expect(accepted.streamLogPath).toBeUndefined(); expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); if (expectTranscriptPersistence) { - expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionId: "sess-123", - storePath: "/tmp/codex-sessions.json", - agentId: "codex", - }), - ); + expectRecordFields(hoisted.resolveSessionTranscriptFileMock.mock.calls[0]?.[0], { + sessionId: "sess-123", + storePath: "/tmp/codex-sessions.json", + agentId: "codex", + }); } expectAgentGatewayCall(expectedAgentCall); }); @@ -1974,13 +1988,10 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - introText: expect.stringContaining("cwd: /home/bob/clawd"), - }), - }), - ); + 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 () => { @@ -2003,7 +2014,7 @@ describe("spawnAcpDirect", () => { }, ); - expect(result).toMatchObject({ + expectRecordFields(result, { status: "forbidden", }); }); @@ -2132,22 +2143,22 @@ describe("spawnAcpDirect", () => { expect(typeof relayCallOrder).toBe("number"); expect(typeof agentCallOrder).toBe("number"); expect(relayCallOrder < agentCallOrder).toBe(true); - expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith( - expect.objectContaining({ - parentSessionKey: "agent:main:main", - agentId: "codex", - logPath: "/tmp/sess-main.acp-stream.jsonl", - emitStartNotice: false, - }), - ); + expectRelayCallFields({ + parentSessionKey: "agent:main:main", + agentId: "codex", + logPath: "/tmp/sess-main.acp-stream.jsonl", + 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(hoisted.resolveAcpSpawnStreamLogPathMock).toHaveBeenCalledWith({ - childSessionKey: expect.stringMatching(/^agent:codex:acp:/), - }); + const streamPathInput = expectRecordFields( + hoisted.resolveAcpSpawnStreamLogPathMock.mock.calls[0]?.[0], + {}, + ); + expect(streamPathInput.childSessionKey).toMatch(/^agent:codex:acp:/); expect(firstHandle.dispose).toHaveBeenCalledTimes(1); expect(firstHandle.notifyStarted).not.toHaveBeenCalled(); expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); @@ -2220,19 +2231,17 @@ describe("spawnAcpDirect", () => { expect(agentCall?.params?.channel).toBeUndefined(); expect(agentCall?.params?.to).toBeUndefined(); expect(agentCall?.params?.threadId).toBeUndefined(); - expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith( - expect.objectContaining({ - parentSessionKey: "agent:main:subagent:parent", - agentId: "codex", - logPath: "/tmp/sess-main.acp-stream.jsonl", - deliveryContext: { - channel: "discord", - to: "channel:parent-channel", - accountId: "default", - }, - emitStartNotice: false, - }), - ); + expectRelayCallFields({ + parentSessionKey: "agent:main:subagent:parent", + agentId: "codex", + logPath: "/tmp/sess-main.acp-stream.jsonl", + deliveryContext: { + channel: "discord", + to: "channel:parent-channel", + accountId: "default", + }, + emitStartNotice: false, + }); expect(firstHandle.dispose).toHaveBeenCalledTimes(1); expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); }); @@ -2570,17 +2579,15 @@ describe("spawnAcpDirect", () => { const accepted = expectAcceptedSpawn(result); expect(accepted.mode).toBe("session"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "telegram", - accountId: "default", - conversationId: "2", - parentConversationId: "-1003342490704", - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "2", + parentConversationId: "-1003342490704", + }, + }); const agentCall = hoisted.callGatewayMock.mock.calls .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) .find((request) => request.method === "agent"); @@ -2608,16 +2615,14 @@ describe("spawnAcpDirect", () => { const accepted = expectAcceptedSpawn(result); expect(accepted.mode).toBe("session"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "telegram", - accountId: "default", - conversationId: "6098642967", - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "6098642967", + }, + }); const bindCall = hoisted.sessionBindingBindMock.mock.calls.at(-1)?.[0] as | { conversation?: { parentConversationId?: string } } | undefined; @@ -2643,16 +2648,14 @@ describe("spawnAcpDirect", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( - expect.objectContaining({ - placement: "current", - conversation: expect.objectContaining({ - channel: "telegram", - accountId: "default", - conversationId: "-1003342490704:topic:2", - }), - }), - ); + expectBindingCallFields({ + placement: "current", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1003342490704:topic:2", + }, + }); }); it("disposes pre-registered parent relay when initial ACP dispatch fails", async () => {