From dee2bde2f5f280c0b9a9cef20a03e1836d27e872 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 03:47:35 +0000 Subject: [PATCH] test(acp): cover generic conversation binds --- src/auto-reply/reply/commands-acp.test.ts | 33 +++- .../reply/commands-acp/context.test.ts | 1 - src/auto-reply/reply/session.test.ts | 92 ++++++++++- .../outbound/session-binding-service.test.ts | 144 ++++++++++++++++++ 4 files changed, 263 insertions(+), 7 deletions(-) diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 093d6ac26b1..f7fd2bf7dac 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -373,6 +373,21 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig ); } +async function runSlackDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand( + createConversationParams( + commandBody, + { + channel: "slack", + originatingTo: "user:U123", + senderId: "U123", + }, + cfg, + ), + true, + ); +} + function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = createConversationParams( commandBody, @@ -802,7 +817,7 @@ describe("/acp command", () => { conversation: expect.objectContaining({ channel: "discord", accountId: "default", - conversationId: "parent-1", + conversationId: "channel:parent-1", }), }), ); @@ -824,6 +839,22 @@ describe("/acp command", () => { ); }); + it("binds Slack DMs with --bind here through the generic conversation path", async () => { + const result = await runSlackDmAcpCommand("/acp spawn codex --bind here"); + + expect(result?.reply?.text).toContain("Bound this conversation to"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + conversation: expect.objectContaining({ + channel: "slack", + accountId: "default", + conversationId: "U123", + }), + }), + ); + }); + it("binds iMessage DMs with --bind here", async () => { const result = await runIMessageDmAcpCommand("/acp spawn codex --bind here"); diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 6efcf639c71..ad33db8e610 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -163,7 +163,6 @@ describe("commands-acp context", () => { accountId: "default", threadId: undefined, conversationId: "123456789", - parentConversationId: "123456789", }); expect(resolveAcpCommandConversationId(params)).toBe("123456789"); }); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index da635497125..188abd3f9db 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -8,6 +8,7 @@ import type { SessionEntry } from "../../config/sessions.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; import { __testing as sessionBindingTesting, + getSessionBindingService, registerSessionBindingAdapter, } from "../../infra/outbound/session-binding-service.js"; import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; @@ -61,6 +62,10 @@ async function writeSessionStoreFast( await fs.writeFile(storePath, JSON.stringify(store), "utf-8"); } +beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); +}); + describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -515,7 +520,7 @@ describe("initSessionState RawBody", () => { expect(result.isNewSession).toBe(false); }); - it("does not rotate local session state for ACP /new when conversation IDs are unavailable", async () => { + it("rotates local session state for ACP /new when no matching conversation binding exists", async () => { const root = await makeCaseDir("openclaw-rawbody-acp-reset-no-conversation-"); const storePath = path.join(root, "sessions.json"); const sessionKey = "agent:codex:acp:binding:discord:default:feedface"; @@ -555,9 +560,9 @@ describe("initSessionState RawBody", () => { commandAuthorized: true, }); - expect(result.resetTriggered).toBe(false); - expect(result.sessionId).toBe(existingSessionId); - expect(result.isNewSession).toBe(false); + expect(result.resetTriggered).toBe(true); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.isNewSession).toBe(true); }); it("keeps custom reset triggers working on bound ACP sessions", async () => { @@ -846,6 +851,83 @@ describe("initSessionState RawBody", () => { vi.unstubAllEnvs(); } }); + + it.each([ + { + name: "Slack DM", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "user:U123", + }, + ctx: { + Provider: "slack", + Surface: "slack", + From: "slack:user:U123", + To: "user:U123", + OriginatingTo: "user:U123", + SenderId: "U123", + ChatType: "direct", + }, + }, + { + name: "Signal DM", + conversation: { + channel: "signal", + accountId: "default", + conversationId: "+15550001111", + }, + ctx: { + Provider: "signal", + Surface: "signal", + From: "signal:+15550001111", + To: "+15550001111", + OriginatingTo: "signal:+15550001111", + SenderId: "+15550001111", + ChatType: "direct", + }, + }, + { + name: "Google Chat room", + conversation: { + channel: "googlechat", + accountId: "default", + conversationId: "spaces/AAAAAAA", + }, + ctx: { + Provider: "googlechat", + Surface: "googlechat", + From: "googlechat:users/123", + To: "spaces/AAAAAAA", + OriginatingTo: "googlechat:spaces/AAAAAAA", + SenderId: "users/123", + ChatType: "group", + }, + }, + ])("routes generic current-conversation bindings for $name", async ({ conversation, ctx }) => { + const storePath = await createStorePath("openclaw-generic-current-binding-"); + const boundSessionKey = `agent:codex:acp:binding:${conversation.channel}:default:test`; + + await getSessionBindingService().bind({ + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation, + }); + + const result = await initSessionState({ + ctx: { + RawBody: "hello", + SessionKey: `agent:main:${conversation.channel}:seed`, + ...ctx, + }, + cfg: { + session: { store: storePath }, + } as OpenClawConfig, + commandAuthorized: true, + }); + + expect(result.sessionKey).toBe(boundSessionKey); + }); }); describe("initSessionState reset policy", () => { @@ -1160,7 +1242,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { senderName: "Other", senderE164: "+1555123456", senderId: "123@lid", - expectedIsNewSession: false, + expectedIsNewSession: true, }, ] as const; diff --git a/src/infra/outbound/session-binding-service.test.ts b/src/infra/outbound/session-binding-service.test.ts index 4f31b4b11f0..b0988d198c0 100644 --- a/src/infra/outbound/session-binding-service.test.ts +++ b/src/infra/outbound/session-binding-service.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import { __testing, getSessionBindingService, @@ -46,6 +47,7 @@ function createRecord(input: SessionBindingBindInput): SessionBindingRecord { describe("session binding service", () => { beforeEach(() => { __testing.resetSessionBindingAdaptersForTests(); + setDefaultChannelPluginRegistryForTests(); }); it("normalizes conversation refs and infers current placement", async () => { @@ -214,6 +216,148 @@ describe("session binding service", () => { }); }); + it("falls back to generic current-conversation bindings for built-in channels", async () => { + const service = getSessionBindingService(); + + expect( + service.getCapabilities({ + channel: "Slack", + accountId: " DEFAULT ", + }), + ).toEqual({ + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }); + + const bound = await service.bind({ + targetSessionKey: "agent:codex:acp:slack-dm", + targetKind: "session", + conversation: { + channel: " Slack ", + accountId: " DEFAULT ", + conversationId: " user:U123 ", + }, + metadata: { + label: "slack-dm", + }, + ttlMs: 60_000, + }); + + expect(bound).toMatchObject({ + bindingId: "generic:slack\u241fdefault\u241f\u241fuser:U123", + targetSessionKey: "agent:codex:acp:slack-dm", + targetKind: "session", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "user:U123", + }, + status: "active", + metadata: expect.objectContaining({ + label: "slack-dm", + }), + }); + + const resolved = service.resolveByConversation({ + channel: "slack", + accountId: "default", + conversationId: "user:U123", + }); + expect(resolved).toMatchObject({ + bindingId: bound.bindingId, + targetSessionKey: "agent:codex:acp:slack-dm", + }); + expect(service.listBySession("agent:codex:acp:slack-dm")).toEqual([resolved]); + + service.touch(bound.bindingId, 1234); + expect( + service.resolveByConversation({ + channel: "slack", + accountId: "default", + conversationId: "user:U123", + })?.metadata, + ).toEqual( + expect.objectContaining({ + label: "slack-dm", + lastActivityAt: 1234, + }), + ); + + await expect( + service.unbind({ + targetSessionKey: "agent:codex:acp:slack-dm", + reason: "test cleanup", + }), + ).resolves.toEqual([ + expect.objectContaining({ + bindingId: bound.bindingId, + }), + ]); + expect( + service.resolveByConversation({ + channel: "slack", + accountId: "default", + conversationId: "user:U123", + }), + ).toBeNull(); + }); + + it("supports registered plugin channels through the generic current-conversation path", async () => { + const service = getSessionBindingService(); + + expect( + service.getCapabilities({ + channel: "msteams", + accountId: "default", + }), + ).toEqual({ + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current"], + }); + + await expect( + service.bind({ + targetSessionKey: "agent:codex:acp:msteams-room", + targetKind: "session", + conversation: { + channel: "msteams", + accountId: "default", + conversationId: "19:chatid@thread.v2", + }, + placement: "child", + }), + ).rejects.toMatchObject({ + code: "BINDING_CAPABILITY_UNSUPPORTED", + details: { + channel: "msteams", + accountId: "default", + placement: "child", + }, + }); + + await expect( + service.bind({ + targetSessionKey: "agent:codex:acp:msteams-room", + targetKind: "session", + conversation: { + channel: "msteams", + accountId: "default", + conversationId: "19:chatid@thread.v2", + }, + }), + ).resolves.toMatchObject({ + conversation: { + channel: "msteams", + accountId: "default", + conversationId: "19:chatid@thread.v2", + }, + }); + }); + it("keeps the first live adapter authoritative until it unregisters", () => { const firstBinding = { bindingId: "first-binding",