test(acp): cover generic conversation binds

This commit is contained in:
Peter Steinberger 2026-03-28 03:47:35 +00:00
parent ec9f96cb2a
commit dee2bde2f5
No known key found for this signature in database
4 changed files with 263 additions and 7 deletions

View File

@ -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");

View File

@ -163,7 +163,6 @@ describe("commands-acp context", () => {
accountId: "default",
threadId: undefined,
conversationId: "123456789",
parentConversationId: "123456789",
});
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
});

View File

@ -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;

View File

@ -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",