diff --git a/extensions/commonly/src/client.test.ts b/extensions/commonly/src/client.test.ts index e5ebc09dcf95..257ed12cbdd9 100644 --- a/extensions/commonly/src/client.test.ts +++ b/extensions/commonly/src/client.test.ts @@ -158,4 +158,46 @@ describe("CommonlyClient", () => { ).rejects.toThrow(/400/); }); }); + + describe("agent-dm", () => { + it("openAgentDm POSTs target shape with default instanceId omitted", async () => { + fetchMock.mockResolvedValue(createResponse({ room: { _id: "pod-dm-1", name: "Pixel ↔ Aria" }, autoJoined: false })); + const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" }); + + const r = await client.openAgentDm({ agentName: "openclaw", instanceId: "aria" }, "pod-team-1"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:5000/api/agents/runtime/agent-dm", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer rt" }), + }), + ); + const body = JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body); + expect(body.target).toEqual({ agentName: "openclaw", instanceId: "aria" }); + expect(body.originPodId).toBe("pod-team-1"); + expect(r.room._id).toBe("pod-dm-1"); + }); + + it("openAgentDm omits instanceId when not provided (defaults to 'default' server-side)", async () => { + fetchMock.mockResolvedValue(createResponse({ room: { _id: "pod-dm-2" }, autoJoined: false })); + const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" }); + + await client.openAgentDm({ agentName: "codex" }); + + const body = JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body); + expect(body.target).toEqual({ agentName: "codex" }); + expect(body.originPodId).toBeUndefined(); + }); + + it("openAgentDm surfaces 403 from co-pod-member rule", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + text: async () => '{"message":"No shared pod with target"}', + }); + const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" }); + await expect(client.openAgentDm({ agentName: "stranger" })).rejects.toThrow(/403/); + }); + }); }); diff --git a/extensions/commonly/src/client.ts b/extensions/commonly/src/client.ts index 52608e071016..fb922889c67a 100644 --- a/extensions/commonly/src/client.ts +++ b/extensions/commonly/src/client.ts @@ -505,6 +505,46 @@ export class CommonlyClient { return res.json(); } + /** + * Open or fetch an `agent-dm` pod between this agent and a target agent. + * Idempotent: a second call for the same (caller, target) pair returns + * the existing pod. The §3.7 co-pod-member rule on the server side gates + * creation — this agent must already share at least one pod with the + * target before a private DM is allowed (otherwise 403). + * + * Target identity is `agentName` (the registry/preset id) plus an + * optional `instanceId` (defaults to 'default'). For OpenClaw-driven + * agents the agentName is 'openclaw' and the instanceId carries the + * actual identity ('pixel', 'aria', etc.). + * + * `originPodId` is optional context — when set, the server drops a + * "DM started" system message in that pod so humans can find the + * thread without it polluting the team chat. + */ + async openAgentDm( + target: { agentName: string; instanceId?: string }, + originPodId?: string, + ): Promise<{ room: { _id: string; name?: string; type?: string; members?: unknown[] }; autoJoined: boolean }> { + const body: Record = { + target: { + agentName: target.agentName, + ...(target.instanceId ? { instanceId: target.instanceId } : {}), + }, + }; + if (originPodId) body.originPodId = originPodId; + + const res = await fetch(`${this.config.baseUrl}/api/agents/runtime/agent-dm`, { + method: 'POST', + headers: this.runtimeHeaders, + body: JSON.stringify(body), + }); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + throw new Error(`Failed to open agent-dm: ${res.status} ${msg}`); + } + return res.json(); + } + /** * List tasks for a pod */ diff --git a/extensions/commonly/src/tools.ts b/extensions/commonly/src/tools.ts index a1a64af6e282..e2d74b8030c4 100644 --- a/extensions/commonly/src/tools.ts +++ b/extensions/commonly/src/tools.ts @@ -710,6 +710,36 @@ export class CommonlyTools { return jsonResult({ ok: true, ...result }); }, }, + { + name: "commonly_open_dm", + label: "Commonly Open Agent DM", + description: + "Open or fetch a private 1:1 DM pod with another agent. Idempotent — repeat calls for the same target return the same pod. Returns the pod's id; use commonly_post_message with that podId to actually send a message. " + + "Identity: pass `agentName` (the other agent's registry name, e.g. 'pixel', 'codex') and optionally `instanceId` (defaults to 'default'). " + + "For OpenClaw-driven agents, agentName is the runtime ('openclaw') and instanceId carries the identity ('aria', 'pixel', etc.). " + + "Authorization: server enforces the co-pod-member rule — you must already share at least one pod with the target before a DM is allowed. If the target isn't reachable you'll get a 403. " + + "Use this when you want a side-thread with a peer you've already worked with — discussing details before surfacing the result, asking a specialist a one-off question, or coordinating handoffs that don't belong in a team pod yet.", + parameters: Type.Object({ + agentName: Type.String({ description: "Target agent's registry name (e.g. 'codex', 'pixel'). For an OpenClaw-driven peer, pass 'openclaw' and supply their identity in instanceId." }), + instanceId: Type.Optional(Type.String({ description: "Target's instanceId (defaults to 'default'). For an OpenClaw peer, this is their actual identity ('aria', 'pixel', ...)." })), + originPodId: Type.Optional(Type.String({ description: "Pod where the conversation context originated. When set, the server drops a 'DM started' system message in that pod so humans can find the link without it polluting team chat." })), + }), + async execute(_id: string, params: Record) { + const agentName = readStringParam(params, "agentName", { required: true }); + const instanceId = readStringParam(params, "instanceId"); + const originPodId = readStringParam(params, "originPodId"); + const result = await client.openAgentDm( + { agentName, ...(instanceId ? { instanceId } : {}) }, + originPodId || undefined, + ); + return jsonResult({ + ok: true, + podId: result.room?._id, + podName: result.room?.name, + autoJoined: result.autoJoined, + }); + }, + }, { name: "commonly_get_tasks", label: "Commonly Get Tasks",