diff --git a/slack-bridge/follower-runtime.ts b/slack-bridge/follower-runtime.ts index 37a45b6..96fab77 100644 --- a/slack-bridge/follower-runtime.ts +++ b/slack-bridge/follower-runtime.ts @@ -16,6 +16,7 @@ import { resolvePinetMeshAuth, resolveRuntimeAgentIdentity, syncFollowerInboxEntries, + syncFollowerTransferredThreadContext, } from "./helpers.js"; import { type FollowerDeliveryState, @@ -322,6 +323,18 @@ export function createFollowerRuntime(deps: FollowerRuntimeDeps): FollowerRuntim } if (agentMessages.length > 0) { + const transferSync = syncFollowerTransferredThreadContext( + agentMessages, + deps.getThreads(), + deps.getAgentOwnerToken(), + ); + if (transferSync.threadUpdates.length > 0) { + mergeFollowerThreadUpdates(deps.getThreads(), transferSync.threadUpdates); + if (transferSync.changed) { + deps.persistState(); + } + } + const pinetPrompt = formatPinetInboxMessages(agentMessages); if (deps.deliverFollowUpMessage(pinetPrompt)) { markFollowerInboxIdsDelivered(deps.deliveryState, getInboxIds(agentMessages)); diff --git a/slack-bridge/helpers.test.ts b/slack-bridge/helpers.test.ts index 0ca2b21..69faab5 100644 --- a/slack-bridge/helpers.test.ts +++ b/slack-bridge/helpers.test.ts @@ -83,6 +83,7 @@ import { alignAgentIdentityToRole, trackBrokerInboundThread, syncFollowerInboxEntries, + syncFollowerTransferredThreadContext, resolveFollowerThreadChannel, isDirectMessageChannel, buildFollowerRuntimeDiagnostic, @@ -3509,6 +3510,115 @@ describe("syncFollowerInboxEntries", () => { }); }); +// ─── syncFollowerTransferredThreadContext ───────────────── + +describe("syncFollowerTransferredThreadContext", () => { + it("extracts Slack channel context from explicit broker transfer metadata", () => { + const threads = new Map(); + const result = syncFollowerTransferredThreadContext( + [ + { + inboxId: 41, + message: { + threadId: "a2a:broker-1:worker-1", + source: "agent", + sender: "broker-1", + body: "please take over", + createdAt: "2026-05-18T22:00:00.000Z", + metadata: { + a2a: true, + threadOwnershipTransfer: { + mode: "transfer", + threadId: "1777798507.674009", + source: "slack", + channel: "C123", + }, + }, + }, + }, + ], + threads, + "worker-owner-token", + ); + + expect(result).toEqual({ + changed: true, + threadUpdates: [ + { + channelId: "C123", + threadTs: "1777798507.674009", + userId: "broker-1", + owner: "worker-owner-token", + source: "slack", + }, + ], + }); + }); + + it("ignores transfers without channel context so callers fall back to diagnostics", () => { + const result = syncFollowerTransferredThreadContext( + [ + { + message: { + threadId: "a2a:broker-1:worker-1", + sender: "broker-1", + body: "please take over", + metadata: { + a2a: true, + threadOwnershipTransfer: { mode: "transfer", threadId: "1777798507.674009" }, + }, + }, + }, + ], + new Map(), + "worker-owner-token", + ); + + expect(result).toEqual({ changed: false, threadUpdates: [] }); + }); + + it("returns changed=false when the transferred thread context is already cached", () => { + const threads = new Map([ + [ + "1777798507.674009", + { + channelId: "C123", + threadTs: "1777798507.674009", + userId: "broker-1", + owner: "worker-owner-token", + source: "slack", + }, + ], + ]); + + const result = syncFollowerTransferredThreadContext( + [ + { + message: { + threadId: "a2a:broker-1:worker-1", + sender: "broker-1", + body: "please take over", + metadata: { + a2a: true, + threadOwnershipTransfer: { + mode: "transfer", + threadId: "1777798507.674009", + source: "slack", + channel: "C123", + }, + }, + }, + }, + ], + threads, + "worker-owner-token", + ); + + expect(result.changed).toBe(false); + expect(result.threadUpdates).toHaveLength(1); + }); +}); + // ─── resolveFollowerThreadChannel ───────────────────────── describe("resolveFollowerThreadChannel", () => { diff --git a/slack-bridge/helpers.ts b/slack-bridge/helpers.ts index 65e2945..2ebb38e 100644 --- a/slack-bridge/helpers.ts +++ b/slack-bridge/helpers.ts @@ -2133,6 +2133,68 @@ export interface FollowerInboxSyncResult { changed: boolean; } +export interface FollowerTransferredThreadContextSyncResult { + threadUpdates: FollowerThreadState[]; + changed: boolean; +} + +function getFollowerTransferredThreadContext(entry: FollowerInboxEntry): { + threadId: string; + channel: string; + source: string; +} | null { + const metadata = entry.message.metadata ?? {}; + const transfer = asRecord(metadata.threadOwnershipTransfer); + if (!transfer) return null; + + const mode = asString(transfer.mode); + if (mode && mode !== "transfer") return null; + + const threadId = asString(transfer.threadId); + const channel = asString(transfer.channel); + const source = asString(transfer.source) ?? "slack"; + if (!threadId || !channel || source !== "slack") return null; + + return { threadId, channel, source }; +} + +export function syncFollowerTransferredThreadContext( + entries: FollowerInboxEntry[], + existingThreads: ReadonlyMap, + agentOwner: string, +): FollowerTransferredThreadContextSyncResult { + let changed = false; + const threadUpdates: FollowerThreadState[] = []; + + for (const entry of entries) { + const transferred = getFollowerTransferredThreadContext(entry); + if (!transferred) continue; + + const existing = existingThreads.get(transferred.threadId); + const nextThread: FollowerThreadState = { + channelId: transferred.channel, + threadTs: transferred.threadId, + userId: existing?.userId || entry.message.sender || "", + owner: agentOwner, + source: transferred.source, + }; + + if ( + !existing || + existing.channelId !== nextThread.channelId || + existing.userId !== nextThread.userId || + existing.owner !== nextThread.owner || + existing.source !== nextThread.source + ) { + changed = true; + } + + threadUpdates.push(nextThread); + } + + return { threadUpdates, changed }; +} + export interface BrokerInboxControlEntry { inboxId: number; command: PinetControlCommand; diff --git a/slack-bridge/pinet-mesh-ops.test.ts b/slack-bridge/pinet-mesh-ops.test.ts index a8102bd..17994da 100644 --- a/slack-bridge/pinet-mesh-ops.test.ts +++ b/slack-bridge/pinet-mesh-ops.test.ts @@ -336,7 +336,12 @@ describe("createPinetMeshOps", () => { }); expect(transferThreadOwnership).toHaveBeenCalledWith("1777798507.674009", "worker-1"); expect(insertedMessages[0]?.metadata).toMatchObject({ - threadOwnershipTransfer: { mode: "transfer", threadId: "1777798507.674009" }, + threadOwnershipTransfer: { + mode: "transfer", + threadId: "1777798507.674009", + source: "slack", + channel: "C123", + }, senderAgent: "Broker Crane", a2a: true, }); diff --git a/slack-bridge/pinet-mesh-ops.ts b/slack-bridge/pinet-mesh-ops.ts index bfcdd17..24434bf 100644 --- a/slack-bridge/pinet-mesh-ops.ts +++ b/slack-bridge/pinet-mesh-ops.ts @@ -139,6 +139,28 @@ function getThreadOwnershipTransferId(metadata?: Record): strin return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null; } +function enrichThreadOwnershipTransferMetadata( + metadata: Record | undefined, + thread: PinetMeshOpsTransferableThread, +): Record { + const transfer = metadata?.threadOwnershipTransfer; + const transferRecord = + transfer && typeof transfer === "object" && !Array.isArray(transfer) + ? (transfer as Record) + : {}; + + return { + ...(metadata ?? {}), + threadOwnershipTransfer: { + ...transferRecord, + mode: "transfer", + threadId: thread.threadId, + ...(thread.source ? { source: thread.source } : {}), + ...(thread.channel ? { channel: thread.channel } : {}), + }, + }; +} + function parseGitHubRemoteRepo(remoteUrl: string): { repoOwner: string; repoName: string } | null { const match = remoteUrl.match(/github\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/i); if (!match?.[1] || !match[2]) { @@ -204,12 +226,16 @@ export function createPinetMeshOps(deps: PinetMeshOpsDeps): PinetMeshOps { throw new Error(`Thread ${transferThreadId} is not a transferable Slack thread.`); } + const dispatchMetadata = transferThread + ? enrichThreadOwnershipTransferMetadata(finalMetadata, transferThread) + : finalMetadata; + const result = dispatchDirectAgentMessage(db, { senderAgentId: selfId, senderAgentName: deps.getAgentName(), target: targetRef, body: finalBody, - metadata: finalMetadata, + metadata: dispatchMetadata, }); if (transferThreadId) {