From 04cf0f32d72aa0c712b8597949049897ded60f26 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 4 Sep 2026 16:56:00 +0000 Subject: [PATCH] Generated with Hive: Fix send_sphinx_message to default to one tribe and support named multi-destination sends --- .../lib/ai/resolveSphinxToolTarget.test.ts | 81 +++++- .../ai/runCanvasAgent-sphinx-merge.test.ts | 61 +++-- src/__tests__/unit/lib/ai/sphinxTools.test.ts | 242 ++++++++++++++---- src/lib/ai/sphinxTools.ts | 174 +++++++++++-- 4 files changed, 449 insertions(+), 109 deletions(-) diff --git a/src/__tests__/unit/lib/ai/resolveSphinxToolTarget.test.ts b/src/__tests__/unit/lib/ai/resolveSphinxToolTarget.test.ts index 9920895e16..693891b506 100644 --- a/src/__tests__/unit/lib/ai/resolveSphinxToolTarget.test.ts +++ b/src/__tests__/unit/lib/ai/resolveSphinxToolTarget.test.ts @@ -28,8 +28,41 @@ const USER_ID = "user-1"; const WS_A = { workspaceId: "cuid-a", slug: "alpha" }; const WS_B = { workspaceId: "cuid-b", slug: "beta" }; -const CONNECTED_ROW_A = { id: "cuid-a", slug: "alpha" }; -const CONNECTED_ROW_B = { id: "cuid-b", slug: "beta" }; +type ConnectedRow = { + id: string; + slug: string; + name: string; + sphinxChatPubkey: string; + swarm: { name: string } | null; +}; + +const CONNECTED_ROW_A: ConnectedRow = { + id: "cuid-a", + slug: "alpha", + name: "Alpha", + sphinxChatPubkey: "pubkey-a", + swarm: { name: "swarm38" }, +}; +const CONNECTED_ROW_B: ConnectedRow = { + id: "cuid-b", + slug: "beta", + name: "Beta", + sphinxChatPubkey: "pubkey-b", + swarm: { name: "swarm39" }, +}; + +function asTarget(row: ConnectedRow) { + return { + workspaceId: row.id, + workspaceSlug: row.slug, + sphinxChatPubkey: row.sphinxChatPubkey, + workspaceName: row.name, + ...(row.swarm?.name ? { swarmDomain: `${row.swarm.name}.sphinx.chat` } : {}), + }; +} + +const TARGET_A = asTarget(CONNECTED_ROW_A); +const TARGET_B = asTarget(CONNECTED_ROW_B); // Connection + write-access are folded into the single findMany so the // resolve step is one round trip regardless of workspace count. @@ -57,6 +90,13 @@ function scopedPredicate(ids: string[]) { }, ], }), + select: { + id: true, + slug: true, + name: true, + sphinxChatPubkey: true, + swarm: { select: { name: true } }, + }, }); } @@ -107,10 +147,7 @@ describe("resolveSphinxToolTarget", () => { currentCanvasRef: "", }); - expect(result).toEqual([ - { workspaceId: "cuid-a", workspaceSlug: "alpha" }, - { workspaceId: "cuid-b", workspaceSlug: "beta" }, - ]); + expect(result).toEqual([TARGET_A, TARGET_B]); }); it("returns [] when no in-scope workspace is Sphinx-connected", async () => { @@ -180,11 +217,29 @@ describe("resolveSphinxToolTarget", () => { currentCanvasRef: "", }); + expect(result).toEqual([TARGET_A, TARGET_B]); + expect(db.workspace.findMany).toHaveBeenCalledWith(scopedPredicate(["cuid-a", "cuid-b"])); + }); + + it("omits swarmDomain when the workspace has no swarm row", async () => { + (db.workspace.findMany as ReturnType).mockResolvedValue([ + { ...CONNECTED_ROW_A, swarm: null }, + ]); + + const result = await resolveSphinxToolTarget({ + userId: USER_ID, + workspaceConfigs: [WS_A], + }); + expect(result).toEqual([ - { workspaceId: "cuid-a", workspaceSlug: "alpha" }, - { workspaceId: "cuid-b", workspaceSlug: "beta" }, + { + workspaceId: "cuid-a", + workspaceSlug: "alpha", + sphinxChatPubkey: "pubkey-a", + workspaceName: "Alpha", + }, ]); - expect(db.workspace.findMany).toHaveBeenCalledWith(scopedPredicate(["cuid-a", "cuid-b"])); + expect(result[0]).not.toHaveProperty("swarmDomain"); }); it("drops VIEWER-only workspaces at resolve on org-root scope (DB filters them out)", async () => { @@ -198,7 +253,7 @@ describe("resolveSphinxToolTarget", () => { currentCanvasRef: "", }); - expect(result).toEqual([{ workspaceId: "cuid-a", workspaceSlug: "alpha" }]); + expect(result).toEqual([TARGET_A]); expect(db.workspace.findMany).toHaveBeenCalledWith(scopedPredicate(["cuid-a", "cuid-b"])); }); @@ -211,7 +266,7 @@ describe("resolveSphinxToolTarget", () => { currentCanvasRef: "", }); - expect(result).toEqual([{ workspaceId: "cuid-a", workspaceSlug: "alpha" }]); + expect(result).toEqual([TARGET_A]); const call = (db.workspace.findMany as ReturnType).mock.calls[0][0]; expect(call.where.OR).toContainEqual({ ownerId: USER_ID }); }); @@ -284,7 +339,7 @@ describe("resolveSphinxToolTarget", () => { workspaceConfigs: [WS_A], }); - expect(result).toEqual([{ workspaceId: "cuid-a", workspaceSlug: "alpha" }]); + expect(result).toEqual([TARGET_A]); expect(db.workspace.findMany).toHaveBeenCalledWith(scopedPredicate(["cuid-a"])); }); @@ -295,7 +350,7 @@ describe("resolveSphinxToolTarget", () => { currentCanvasRef: "ws:cuid-a", }); - expect(result).toEqual([{ workspaceId: "cuid-a", workspaceSlug: "alpha" }]); + expect(result).toEqual([TARGET_A]); expect(db.workspace.findMany).toHaveBeenCalledWith(scopedPredicate(["cuid-a"])); }); }); diff --git a/src/__tests__/unit/lib/ai/runCanvasAgent-sphinx-merge.test.ts b/src/__tests__/unit/lib/ai/runCanvasAgent-sphinx-merge.test.ts index b25442aa95..b84931a230 100644 --- a/src/__tests__/unit/lib/ai/runCanvasAgent-sphinx-merge.test.ts +++ b/src/__tests__/unit/lib/ai/runCanvasAgent-sphinx-merge.test.ts @@ -9,10 +9,39 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +type ConnectedRow = { + id: string; + slug: string; + name: string; + sphinxChatPubkey: string; + swarm: { name: string } | null; +}; + +function connectedRow( + slug: string, + swarmName: string | null = `swarm-${slug}`, +): ConnectedRow { + return { + id: `cuid-${slug}`, + slug, + name: slug, + sphinxChatPubkey: `pubkey-${slug}`, + swarm: swarmName ? { name: swarmName } : null, + }; +} + +function expectedTarget(slug: string, swarmName: string | null = `swarm-${slug}`) { + return { + workspaceId: `cuid-${slug}`, + workspaceSlug: slug, + sphinxChatPubkey: `pubkey-${slug}`, + workspaceName: slug, + ...(swarmName ? { swarmDomain: `${swarmName}.sphinx.chat` } : {}), + }; +} + const { mockFindMany } = vi.hoisted(() => ({ - mockFindMany: vi.fn<(...args: unknown[]) => Promise>>( - async () => [], - ), + mockFindMany: vi.fn<(...args: unknown[]) => Promise>(async () => []), })); const { mockValidateWorkspaceAccessById } = vi.hoisted(() => ({ @@ -177,7 +206,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - mockFindMany.mockResolvedValue([{ id: "cuid-alpha", slug: "alpha" }]); + mockFindMany.mockResolvedValue([connectedRow("alpha")]); mockValidateWorkspaceAccessById.mockResolvedValue({ canWrite: true }); }); @@ -190,7 +219,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { expect(buildSphinxTools).toHaveBeenCalledWith({ userId: "user-1", - targets: [{ workspaceId: "cuid-alpha", workspaceSlug: "alpha" }], + targets: [expectedTarget("alpha")], actorLabel: undefined, }); expect(toolNames()).toContain("send_sphinx_message"); @@ -250,10 +279,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { }); it("merges every writable connected conversation workspace on org-root scope (currentCanvasRef === '')", async () => { - mockFindMany.mockResolvedValue([ - { id: "cuid-alpha", slug: "alpha" }, - { id: "cuid-beta", slug: "beta" }, - ]); + mockFindMany.mockResolvedValue([connectedRow("alpha"), connectedRow("beta")]); await runCanvasAgent( opts({ @@ -264,10 +290,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { expect(buildSphinxTools).toHaveBeenCalledWith({ userId: "user-1", - targets: [ - { workspaceId: "cuid-alpha", workspaceSlug: "alpha" }, - { workspaceId: "cuid-beta", workspaceSlug: "beta" }, - ], + targets: [expectedTarget("alpha"), expectedTarget("beta")], actorLabel: undefined, }); expect(toolNames()).toContain("send_sphinx_message"); @@ -276,7 +299,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { it("drops VIEWER-only workspaces on org-root scope but still merges the writable one", async () => { // Write access is part of the findMany predicate, so the VIEWER-only // workspace (beta) never comes back from the query. - mockFindMany.mockResolvedValue([{ id: "cuid-alpha", slug: "alpha" }]); + mockFindMany.mockResolvedValue([connectedRow("alpha")]); await runCanvasAgent( opts({ @@ -287,7 +310,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { expect(buildSphinxTools).toHaveBeenCalledWith({ userId: "user-1", - targets: [{ workspaceId: "cuid-alpha", workspaceSlug: "alpha" }], + targets: [expectedTarget("alpha")], actorLabel: undefined, }); }); @@ -307,7 +330,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { }); it("scopes findMany to conversation workspace ids on org-root scope", async () => { - mockFindMany.mockResolvedValue([{ id: "cuid-alpha", slug: "alpha" }]); + mockFindMany.mockResolvedValue([connectedRow("alpha")]); await runCanvasAgent( opts({ @@ -326,7 +349,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { }); it("merges for multi-workspace only when currentCanvasRef is ws: of a connected conversation workspace", async () => { - mockFindMany.mockResolvedValue([{ id: "cuid-alpha", slug: "alpha" }]); + mockFindMany.mockResolvedValue([connectedRow("alpha")]); await runCanvasAgent( opts({ @@ -337,7 +360,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { expect(buildSphinxTools).toHaveBeenCalledWith({ userId: "user-1", - targets: [{ workspaceId: "cuid-alpha", workspaceSlug: "alpha" }], + targets: [expectedTarget("alpha")], actorLabel: undefined, }); expect(toolNames()).toContain("send_sphinx_message"); @@ -364,7 +387,7 @@ describe("runCanvasAgent — send_sphinx_message merge", () => { expect(buildSphinxTools).toHaveBeenCalledWith({ userId: "user-1", - targets: [{ workspaceId: "cuid-alpha", workspaceSlug: "alpha" }], + targets: [expectedTarget("alpha")], actorLabel: "tom", }); }); diff --git a/src/__tests__/unit/lib/ai/sphinxTools.test.ts b/src/__tests__/unit/lib/ai/sphinxTools.test.ts index 80d25095e2..63ce32ccd9 100644 --- a/src/__tests__/unit/lib/ai/sphinxTools.test.ts +++ b/src/__tests__/unit/lib/ai/sphinxTools.test.ts @@ -60,6 +60,9 @@ const WORKSPACE_ID = "ws-1"; const WORKSPACE_SLUG = "hive"; const OTHER_WORKSPACE_ID = "ws-other"; const OTHER_WORKSPACE_SLUG = "sphinx-voice"; +const PUBKEY_A = "pubkey-hive"; +const PUBKEY_B = "pubkey-sphinx-voice"; +const PUBKEY_SHARED = "pubkey-shared"; const CONNECTED_WORKSPACE = { sphinxEnabled: true, @@ -69,12 +72,34 @@ const CONNECTED_WORKSPACE = { }; const SINGLE_TARGET: SphinxToolTarget[] = [ - { workspaceId: WORKSPACE_ID, workspaceSlug: WORKSPACE_SLUG }, + { + workspaceId: WORKSPACE_ID, + workspaceSlug: WORKSPACE_SLUG, + sphinxChatPubkey: PUBKEY_A, + workspaceName: "Hive", + }, ]; const TWO_TARGETS: SphinxToolTarget[] = [ - { workspaceId: WORKSPACE_ID, workspaceSlug: WORKSPACE_SLUG }, - { workspaceId: OTHER_WORKSPACE_ID, workspaceSlug: OTHER_WORKSPACE_SLUG }, + { + workspaceId: WORKSPACE_ID, + workspaceSlug: WORKSPACE_SLUG, + sphinxChatPubkey: PUBKEY_A, + workspaceName: "Hive", + swarmDomain: "swarm38.sphinx.chat", + }, + { + workspaceId: OTHER_WORKSPACE_ID, + workspaceSlug: OTHER_WORKSPACE_SLUG, + sphinxChatPubkey: PUBKEY_B, + workspaceName: "Sphinx Voice", + swarmDomain: "swarm39.sphinx.chat", + }, +]; + +const SHARED_PUBKEY_TARGETS: SphinxToolTarget[] = [ + { ...TWO_TARGETS[0], sphinxChatPubkey: PUBKEY_SHARED }, + { ...TWO_TARGETS[1], sphinxChatPubkey: PUBKEY_SHARED }, ]; type SendTool = { @@ -83,7 +108,10 @@ type SendTool = { safeParse: (v: unknown) => { success: boolean; error?: unknown }; shape?: Record; }; - execute: (args: { message: string }) => Promise; + execute: (args: { + message: string; + destinations?: string[]; + }) => Promise; }; function getTool(opts?: { @@ -98,6 +126,21 @@ function getTool(opts?: { return tools[SEND_SPHINX_MESSAGE_TOOL] as unknown as SendTool; } +function sentWorkspaceIds(): string[] { + return (validateWorkspaceAccessById as ReturnType).mock.calls.map( + (call) => call[0] as string, + ); +} + +const SEND_OK = { success: true, messageId: "msg-1" }; + +function executeSend(targets: SphinxToolTarget[], destinations?: string[]) { + return getTool({ targets }).execute({ + message: "Hello tribe.", + ...(destinations !== undefined ? { destinations } : {}), + }); +} + describe("buildSphinxTools / send_sphinx_message", () => { beforeEach(() => { vi.clearAllMocks(); @@ -127,6 +170,23 @@ describe("buildSphinxTools / send_sphinx_message", () => { ); }); + it("accepts an optional destinations array on the schema", () => { + const schema = getTool().inputSchema; + expect(schema.shape).toHaveProperty("destinations"); + expect(schema.safeParse({ message: "Hello tribe." }).success).toBe(true); + expect(schema.safeParse({ message: "Hello tribe.", destinations: ["hive"] }).success).toBe( + true, + ); + expect(schema.safeParse({ message: "Hello tribe.", destinations: [] }).success).toBe(true); + expect(schema.safeParse({ message: "Hello tribe.", destinations: [""] }).success).toBe(false); + expect( + schema.safeParse({ message: "Hello tribe.", destinations: Array(32).fill("hive") }).success, + ).toBe(true); + expect( + schema.safeParse({ message: "Hello tribe.", destinations: Array(33).fill("hive") }).success, + ).toBe(false); + }); + it("returns no tools when there are no targets", () => { const tools = buildSphinxTools({ userId: USER_ID, targets: [] }); expect(Object.keys(tools)).toEqual([]); @@ -280,22 +340,27 @@ describe("buildSphinxTools / send_sphinx_message", () => { expect(sendToSphinx).not.toHaveBeenCalled(); }); - it("describes ASD-STE100, current-workspace-only, and immediate send for a single target", () => { - const { description } = getTool({ targets: SINGLE_TARGET }); + it("describes ask-to-post, one-tribe default, named destinations, and ASD-STE100", () => { + const { description } = getTool({ targets: TWO_TARGETS }); expect(description).toMatch(/ASD-STE100/i); - expect(description).toMatch(/current workspace/i); + expect(description).toMatch(/only when the user asks/i); + expect(description).toMatch(/one tribe/i); expect(description).toMatch(/immediately/i); expect(description).toMatch(/no draft/i); - }); - - it("describes fan-out to every writable Sphinx-connected workspace for multiple targets", () => { - const { description } = getTool({ targets: TWO_TARGETS }); - expect(description).toMatch(/every/i); - expect(description).toMatch(/cannot pick a workspace|you cannot pick a workspace/i); + expect(description).toMatch(/destinations/i); expect(description).toMatch(/org-wide tribe/i); + expect(description).not.toMatch(/fans out/i); + expect(description).not.toMatch(/every writable/i); + expect(description).not.toMatch(/cannot pick a workspace/i); expect(description).not.toMatch(/Post ONLY to the current workspace/i); }); + it("uses the same description for single-target and multi-target pools", () => { + expect(getTool({ targets: SINGLE_TARGET }).description).toBe( + getTool({ targets: TWO_TARGETS }).description, + ); + }); + describe("actor attribution", () => { it("prepends [actorLabel] when a non-empty username is provided", async () => { await getTool({ actorLabel: "tom" }).execute({ message: "The build is complete." }); @@ -337,65 +402,144 @@ describe("buildSphinxTools / send_sphinx_message", () => { }); }); - describe("fan-out (multiple targets)", () => { - it("sends to every target in parallel via Promise.all and returns success with the first messageId", async () => { - (sendToSphinx as ReturnType).mockImplementation(async (creds: unknown) => { - const c = creds as { chatPubkey: string }; - return { success: true, messageId: `msg-${c.chatPubkey}` }; - }); - (db.workspace.findFirst as ReturnType).mockImplementation( - async ({ where }: { where: { id: string } }) => ({ - ...CONNECTED_WORKSPACE, - sphinxChatPubkey: where.id, - }), - ); + describe("one-tribe default and named destinations", () => { + it("sends a default 2-target-pool call to exactly one tribe (conversation-order first)", async () => { + const result = await executeSend(TWO_TARGETS); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); + }); - const result = await getTool({ targets: TWO_TARGETS }).execute({ message: "Hello tribe." }); + it("treats an empty destinations array as the default one tribe", async () => { + const result = await executeSend(TWO_TARGETS, []); - expect(sendToSphinx).toHaveBeenCalledTimes(2); - expect(checkRateLimit).toHaveBeenCalledWith( - `send_sphinx_message:${USER_ID}:${WORKSPACE_ID}`, - 5, - 600, - ); - expect(checkRateLimit).toHaveBeenCalledWith( - `send_sphinx_message:${USER_ID}:${OTHER_WORKSPACE_ID}`, - 5, - 600, - ); - expect(result).toEqual({ success: true, messageId: `msg-${WORKSPACE_ID}` }); + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); }); - it("partial success (one target fails) still returns success", async () => { + it("collapses two targets that share a sphinxChatPubkey to exactly one send", async () => { + const result = await executeSend(SHARED_PUBKEY_TARGETS); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); + }); + + it("sends a single named destination that is not conversation-order [0] to that tribe only", async () => { + const result = await executeSend(TWO_TARGETS, [OTHER_WORKSPACE_SLUG]); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([OTHER_WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); + }); + + it("sends 2+ named destinations sequentially and attempts every selected target after an early success", async () => { + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; (validateWorkspaceAccessById as ReturnType).mockImplementation( - async (workspaceId: string) => ({ canWrite: workspaceId !== OTHER_WORKSPACE_ID }), + async (workspaceId: string) => { + order.push(workspaceId); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 15)); + inFlight -= 1; + return { canWrite: true }; + }, ); - const result = await getTool({ targets: TWO_TARGETS }).execute({ message: "Hello tribe." }); + const result = await executeSend(TWO_TARGETS, [WORKSPACE_SLUG, OTHER_WORKSPACE_SLUG]); + + expect(sendToSphinx).toHaveBeenCalledTimes(2); + expect(order).toEqual([WORKSPACE_ID, OTHER_WORKSPACE_ID]); + expect(maxInFlight).toBe(1); + expect(result).toEqual(SEND_OK); + }); + + it("skips unknown destination names, sends nothing, and returns a generic error", async () => { + const result = await executeSend(TWO_TARGETS, ["unknown-tribe"]); - expect(result).toEqual({ success: true, messageId: "msg-1" }); + expect(sendToSphinx).not.toHaveBeenCalled(); + expect(validateWorkspaceAccessById).not.toHaveBeenCalled(); + expect(result).toEqual({ error: "Failed to send Sphinx message" }); + }); + + it("matches a destination by workspace name, case-insensitively", async () => { + const result = await executeSend(TWO_TARGETS, ["sphinx voice"]); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([OTHER_WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); }); - it("returns a generic error when every target fails", async () => { + it("matches a duplicate display name to the first bound target only", async () => { + const duplicateNameTargets: SphinxToolTarget[] = [ + { ...TWO_TARGETS[0], workspaceName: "Hive" }, + { ...TWO_TARGETS[1], workspaceName: "Hive" }, + ]; + + const result = await executeSend(duplicateNameTargets, ["Hive"]); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); + }); + + it.each(["swarm38", "swarm38.sphinx.chat"])( + "matches swarm host %s to the bound target", + async (destination) => { + const result = await executeSend(TWO_TARGETS, [destination]); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); + }, + ); + + it("returns a generic SEND_FAILED_ERROR for a named unwritable match, not NOT_FOUND", async () => { + (validateWorkspaceAccessById as ReturnType).mockResolvedValue({ + canWrite: false, + }); + + const result = await executeSend(TWO_TARGETS, [OTHER_WORKSPACE_SLUG]); + + expect(sendToSphinx).not.toHaveBeenCalled(); + expect(result).toEqual({ error: "Failed to send Sphinx message" }); + expect(JSON.stringify(result)).not.toContain("not found"); + }); + + it("ignores destinations on a single-target bound pool and still sends to that workspace", async () => { + const result = await executeSend(SINGLE_TARGET, [OTHER_WORKSPACE_SLUG]); + + expect(sendToSphinx).toHaveBeenCalledTimes(1); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID]); + expect(result).toEqual(SEND_OK); + }); + + it("returns a generic error when every selected named destination fails", async () => { (sendToSphinx as ReturnType).mockResolvedValue({ success: false, error: "boom", }); - const result = await getTool({ targets: TWO_TARGETS }).execute({ message: "Hello tribe." }); + const result = await executeSend(TWO_TARGETS, [WORKSPACE_SLUG, OTHER_WORKSPACE_SLUG]); + expect(sendToSphinx).toHaveBeenCalledTimes(2); expect(result).toEqual({ error: "Failed to send Sphinx message" }); }); - it("a rate-limited target is skipped without blocking the other tribe", async () => { - (checkRateLimit as ReturnType).mockImplementation( - async (key: string) => ({ allowed: !key.includes(OTHER_WORKSPACE_ID) }), + it("returns success from the first successful named destination after attempting all matches", async () => { + (validateWorkspaceAccessById as ReturnType).mockImplementation( + async (workspaceId: string) => ({ canWrite: workspaceId !== WORKSPACE_ID }), ); - const result = await getTool({ targets: TWO_TARGETS }).execute({ message: "Hello tribe." }); + const result = await executeSend(TWO_TARGETS, [WORKSPACE_SLUG, OTHER_WORKSPACE_SLUG]); + expect(sentWorkspaceIds()).toEqual([WORKSPACE_ID, OTHER_WORKSPACE_ID]); expect(sendToSphinx).toHaveBeenCalledTimes(1); - expect(result).toEqual({ success: true, messageId: "msg-1" }); + expect(result).toEqual(SEND_OK); }); }); }); diff --git a/src/lib/ai/sphinxTools.ts b/src/lib/ai/sphinxTools.ts index 043365905b..49f9f9d156 100644 --- a/src/lib/ai/sphinxTools.ts +++ b/src/lib/ai/sphinxTools.ts @@ -1,16 +1,21 @@ /** * Canvas-agent Sphinx tribe tools. * - * `send_sphinx_message` posts immediately to the workspace(s) bound at - * merge time via Hive's existing `sendToSphinx` path. Destination is - * never an argument — the model cannot pick a workspace or invent an - * org-level tribe. + * `send_sphinx_message` posts immediately via Hive's existing + * `sendToSphinx` path. The candidate pool is bound at merge time from + * in-scope, writable, Sphinx-connected workspaces — the model cannot + * invent a workspace or an org-level tribe. + * + * Execute defaults to **one tribe** (conversation order after + * `sphinxChatPubkey` dedupe). Extra tribes are reached only when the + * caller passes `destinations` that match name, slug, or swarm host + * inside that already-bound pool. * * On a single-workspace turn (or an explicit `ws:` scope) exactly - * one target is bound. On the org-root canvas (`currentCanvasRef === - * ROOT_REF`), every Sphinx-connected, writable workspace in the - * conversation is bound and the tool fans a single call out to all of - * them in parallel — there is still no org-level tribe. + * one target is bound and `destinations` is ignored. On the org-root + * canvas (`currentCanvasRef === ROOT_REF`) the pool still includes + * every connected writable conversation workspace, but a default send + * still posts to one tribe. * * The tool is registered only when `resolveSphinxToolTarget` finds at * least one Sphinx-connected in-scope workspace; execute still @@ -27,7 +32,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { sendToSphinx } from "@/lib/sphinx/daily-pr-summary"; import { validateWorkspaceAccessById } from "@/services/workspace"; import { ROOT_REF } from "@/lib/canvas/scope"; -import { WORKSPACE_PERMISSION_LEVELS } from "@/lib/constants"; +import { getSwarmVanityAddress, WORKSPACE_PERMISSION_LEVELS } from "@/lib/constants"; import { WorkspaceRole } from "@prisma/client"; export const SEND_SPHINX_MESSAGE_TOOL = "send_sphinx_message"; @@ -45,6 +50,7 @@ const MESSAGE_MAX_LENGTH = 2000; const RATE_LIMIT_MAX = 5; const RATE_LIMIT_WINDOW_SECS = 600; const SEND_TIMEOUT_MS = 10_000; +const SPHINX_CHAT_HOST_SUFFIX = ".sphinx.chat"; const NOT_FOUND_ERROR = "Workspace not found or not accessible"; const SEND_FAILED_ERROR = "Failed to send Sphinx message"; @@ -56,11 +62,21 @@ const sendSphinxMessageSchema = z.object({ .min(1, SEND_FAILED_ERROR) .max(MESSAGE_MAX_LENGTH, SEND_FAILED_ERROR) .describe("The tribe message to send immediately."), + destinations: z + .array(z.string().trim().min(1)) + .max(32) + .optional() + .describe( + "Omit to post to the default one tribe. Pass only when the user named specific workspace(s) or tribe(s) by name, slug, or swarm host. Extra unnamed tribes are not sent.", + ), }); export interface SphinxToolTarget { workspaceId: string; workspaceSlug: string; + sphinxChatPubkey: string; + workspaceName: string; + swarmDomain?: string; } export interface ResolveSphinxToolTargetArgs { @@ -89,6 +105,10 @@ export interface ResolveSphinxToolTargetArgs { * connected + writable — never an org-wide tribe). * - Anything else (missing ref, `initiative:*`, `node:*`, * `feature:*`, opaque refs) → no candidates. + * + * The returned pool is the bound set execute may pick from. Execute + * defaults to one tribe; named `destinations` filter this pool and + * never query outside it. */ export async function resolveSphinxToolTarget( args: ResolveSphinxToolTargetArgs, @@ -133,7 +153,13 @@ export async function resolveSphinxToolTarget( }, ], }, - select: { id: true, slug: true }, + select: { + id: true, + slug: true, + name: true, + sphinxChatPubkey: true, + swarm: { select: { name: true } }, + }, }); const connectedById = new Map(connectedRows.map((row) => [row.id, row])); @@ -142,7 +168,16 @@ export async function resolveSphinxToolTarget( for (const candidate of candidates) { const row = connectedById.get(candidate.workspaceId); if (!row) continue; - targets.push({ workspaceId: row.id, workspaceSlug: row.slug }); + const target: SphinxToolTarget = { + workspaceId: row.id, + workspaceSlug: row.slug, + sphinxChatPubkey: row.sphinxChatPubkey ?? "", + workspaceName: row.name, + }; + if (row.swarm?.name) { + target.swarmDomain = getSwarmVanityAddress(row.swarm.name); + } + targets.push(target); } return targets; @@ -180,6 +215,72 @@ function resolveSphinxCandidate( return []; } +/** + * Keep the first occurrence of each non-empty `sphinxChatPubkey` in + * conversation order. Empty pubkeys are skipped. + */ +function dedupeByPubkey(targets: SphinxToolTarget[]): SphinxToolTarget[] { + const seen = new Set(); + const result: SphinxToolTarget[] = []; + for (const target of targets) { + if (!target.sphinxChatPubkey) continue; + if (seen.has(target.sphinxChatPubkey)) continue; + seen.add(target.sphinxChatPubkey); + result.push(target); + } + return result; +} + +function swarmHostname(swarmDomain: string): string { + if (swarmDomain.toLowerCase().endsWith(SPHINX_CHAT_HOST_SUFFIX)) { + return swarmDomain.slice(0, -SPHINX_CHAT_HOST_SUFFIX.length); + } + return swarmDomain; +} + +function targetMatchesDestination(target: SphinxToolTarget, name: string): boolean { + const needle = name.toLowerCase(); + if (target.workspaceSlug.toLowerCase() === needle) return true; + if (target.workspaceName.toLowerCase() === needle) return true; + if (target.swarmDomain && target.swarmDomain.toLowerCase() === needle) return true; + if (target.swarmDomain && swarmHostname(target.swarmDomain).toLowerCase() === needle) { + return true; + } + return false; +} + +/** + * Pick which bound targets a send should actually hit. + * + * - Bound pool of 1 → always that target (`destinations` cannot retarget). + * - Omitted / empty `destinations` → the first pubkey-deduped tribe. + * - Named destinations → first bound match per name (slug, workspace + * name, full swarm domain, or swarm hostname), then pubkey-deduped. + * Unmatched names are dropped and never fall back to the default. + */ +function selectSendTargets( + targets: SphinxToolTarget[], + destinations?: string[], +): SphinxToolTarget[] { + if (targets.length === 1) { + return targets; + } + + if (!destinations || destinations.length === 0) { + const first = dedupeByPubkey(targets)[0]; + return first ? [first] : []; + } + + const matched: SphinxToolTarget[] = []; + for (const raw of destinations) { + const name = raw.trim(); + if (!name) continue; + const found = targets.find((target) => targetMatchesDestination(target, name)); + if (found) matched.push(found); + } + return dedupeByPubkey(matched); +} + type TargetOutcome = | { success: true; messageId?: string } | { success: false; error: string }; @@ -272,6 +373,14 @@ async function sendToOneTarget(opts: { } } +const SEND_SPHINX_MESSAGE_DESCRIPTION = + "Post a message to a Sphinx tribe. Call this tool only when the user asks to post to Sphinx or the tribe. Do not call this tool for ordinary chat, summaries, or other tool flows. " + + "The default is one tribe. The message sends immediately. There is no draft or preview step. Never post to an org-wide tribe. " + + "Omit destinations to post to the default tribe. Pass destinations only when the user names specific workspace(s) or tribe(s) by name, slug, or swarm host. A multi-tribe send requires two or more named destinations that match distinct tribes. " + + "On a single bound workspace, do not pass destinations. " + + "Write the message in ASD-STE100 Simplified Technical English: one idea per sentence (20 words or fewer), active voice, one word per meaning, no contractions, short paragraphs, and lists for 3 or more items. " + + "Technical names (function names, paths, endpoints) are exempt from these style rules."; + export function buildSphinxTools(opts: { userId: string; targets: SphinxToolTarget[]; @@ -288,30 +397,32 @@ export function buildSphinxTools(opts: { return {}; } - const description = - targets.length === 1 - ? "Post a message to this workspace's Sphinx tribe. The message sends immediately when this tool is called. There is no draft or preview step. " + - "Post ONLY to the current workspace's Sphinx tribe. Never attempt to target another workspace or an org-wide tribe. " + - "Write the message in ASD-STE100 Simplified Technical English: one idea per sentence (20 words or fewer), active voice, one word per meaning, no contractions, short paragraphs, and lists for 3 or more items. " + - "Technical names (function names, paths, endpoints) are exempt from these style rules." - : "Post a message to every Sphinx-connected workspace tribe in this conversation that you can write to. One call fans out to all of them; the message sends immediately, with no draft or preview step. " + - "Destination is bound server-side to the writable, Sphinx-connected workspaces already in this conversation — you cannot pick a workspace and there is no org-wide tribe. " + - "Write the message in ASD-STE100 Simplified Technical English: one idea per sentence (20 words or fewer), active voice, one word per meaning, no contractions, short paragraphs, and lists for 3 or more items. " + - "Technical names (function names, paths, endpoints) are exempt from these style rules."; - return { [SEND_SPHINX_MESSAGE_TOOL]: tool({ - description, + description: SEND_SPHINX_MESSAGE_DESCRIPTION, inputSchema: sendSphinxMessageSchema, - execute: async ({ message }: { message: string }) => { + execute: async ({ + message, + destinations, + }: { + message: string; + destinations?: string[]; + }) => { const prefixed = actorLabel ? `[${actorLabel}] ${message}` : message; // Prefix + a 2000-char message can exceed the cap — always slice // the posted body so the wire never exceeds MESSAGE_MAX_LENGTH. const body = prefixed.slice(0, MESSAGE_MAX_LENGTH); - const outcomes = await Promise.all( - targets.map((target) => sendToOneTarget({ userId, target, body })), - ); + const selected = selectSendTargets(targets, destinations); + // A single bound workspace ignores destinations for both + // targeting and error shape (existing single-target contract). + const namedPath = + targets.length > 1 && Boolean(destinations && destinations.length > 0); + + const outcomes: TargetOutcome[] = []; + for (const target of selected) { + outcomes.push(await sendToOneTarget({ userId, target, body })); + } const succeeded = outcomes.find( (o): o is { success: true; messageId?: string } => o.success, @@ -320,9 +431,16 @@ export function buildSphinxTools(opts: { return { success: true, messageId: succeeded.messageId }; } + // Named destinations that match nothing / fail every send always + // get a generic error — never the single-target not-found shape, + // even when the match set collapsed to one unwritable target. + if (namedPath) { + return { error: SEND_FAILED_ERROR }; + } + // No tribe list, no per-target breakdown — the model gets a // generic result either way. Preserve the specific single-target - // error (e.g. write-access) when there was exactly one target. + // error (e.g. write-access) when there was exactly one selected. if (outcomes.length === 1) { const only = outcomes[0]; return { error: only.success ? SEND_FAILED_ERROR : only.error };