diff --git a/slack-bridge/follower-runtime.ts b/slack-bridge/follower-runtime.ts index 37a45b6..466928c 100644 --- a/slack-bridge/follower-runtime.ts +++ b/slack-bridge/follower-runtime.ts @@ -16,6 +16,7 @@ import { resolvePinetMeshAuth, resolveRuntimeAgentIdentity, syncFollowerInboxEntries, + syncTransferredSlackThreadContexts, } from "./helpers.js"; import { type FollowerDeliveryState, @@ -322,6 +323,18 @@ export function createFollowerRuntime(deps: FollowerRuntimeDeps): FollowerRuntim } if (agentMessages.length > 0) { + const transferredThreads = syncTransferredSlackThreadContexts( + agentMessages, + deps.getThreads(), + deps.getAgentOwnerToken(), + ); + if (transferredThreads.threadUpdates.length > 0) { + mergeFollowerThreadUpdates(deps.getThreads(), transferredThreads.threadUpdates); + if (transferredThreads.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..8257e30 100644 --- a/slack-bridge/helpers.test.ts +++ b/slack-bridge/helpers.test.ts @@ -60,6 +60,7 @@ import { isAgentToAgentEntry, partitionFollowerInboxEntries, syncBrokerInboxEntries, + syncTransferredSlackThreadContexts, buildBrokerProtocolGuardrailsPrompt, buildWorkerPromptGuidelines, buildIdentityReplyGuidelines, @@ -765,6 +766,36 @@ describe("formatPinetInboxMessages", () => { expect(result).not.toContain("ACK briefly after reading, do the work"); }); + it("surfaces transferred Slack thread context with slack_send guidance", () => { + const result = formatPinetInboxMessages([ + { + inboxId: 18, + message: { + threadId: "a2a:broker:worker", + sender: "broker-id", + body: "Take issue #756", + metadata: { + senderAgent: "Broker Bunny", + a2a: true, + threadOwnershipTransfer: { + mode: "transfer", + threadId: "1779139556.450249", + source: "slack", + channel: "C0APL58LB1R", + }, + }, + }, + }, + ]); + + expect(result).toContain( + "transferred_slack_thread thread_ts=1779139556.450249 channel=C0APL58LB1R reply=slack_send", + ); + expect(result).toContain( + "transferred Slack threads can be replied to with slack_send using the shown thread_ts.", + ); + }); + it("falls back to the sender id when no senderAgent metadata exists", () => { const result = formatPinetInboxMessages([ { @@ -3393,6 +3424,115 @@ describe("isDirectMessageChannel", () => { }); }); +// ─── syncTransferredSlackThreadContexts ─────────────────── + +describe("syncTransferredSlackThreadContexts", () => { + it("hydrates follower thread state from transferred Slack thread metadata", () => { + const threads = new Map(); + const result = syncTransferredSlackThreadContexts( + [ + { + inboxId: 18, + message: { + threadId: "a2a:broker:worker", + sender: "broker-id", + body: "Take issue #756", + metadata: { + a2a: true, + threadOwnershipTransfer: { + mode: "transfer", + threadId: "1779139556.450249", + source: "slack", + channel: "C0APL58LB1R", + }, + }, + }, + }, + ], + threads, + "AgentOwner", + ); + + expect(result.changed).toBe(true); + expect(result.threadUpdates).toEqual([ + { + channelId: "C0APL58LB1R", + threadTs: "1779139556.450249", + userId: "", + owner: "AgentOwner", + source: "slack", + }, + ]); + }); + + it("overwrites stale cached owner on explicit transfer", () => { + const threads = new Map([ + [ + "1779139556.450249", + { + channelId: "C_OLD", + threadTs: "1779139556.450249", + userId: "U_ORIGINAL", + owner: "PreviousOwner", + source: "slack", + }, + ], + ]); + + const result = syncTransferredSlackThreadContexts( + [ + { + message: { + threadId: "a2a:broker:worker", + sender: "broker-id", + metadata: { + threadOwnershipTransfer: { + mode: "transfer", + threadId: "1779139556.450249", + source: "slack", + channel: "C0APL58LB1R", + }, + }, + }, + }, + ], + threads, + "AgentOwner", + ); + + expect(result.changed).toBe(true); + expect(result.threadUpdates).toEqual([ + { + channelId: "C0APL58LB1R", + threadTs: "1779139556.450249", + userId: "U_ORIGINAL", + owner: "AgentOwner", + source: "slack", + }, + ]); + }); + + it("ignores transfer metadata without channel context", () => { + const result = syncTransferredSlackThreadContexts( + [ + { + message: { + threadId: "a2a:broker:worker", + sender: "broker-id", + metadata: { + threadOwnershipTransfer: { mode: "transfer", threadId: "1779139556.450249" }, + }, + }, + }, + ], + new Map(), + "AgentOwner", + ); + + expect(result).toEqual({ threadUpdates: [], changed: false }); + }); +}); + // ─── syncFollowerInboxEntries ───────────────────────────── describe("syncFollowerInboxEntries", () => { diff --git a/slack-bridge/helpers.ts b/slack-bridge/helpers.ts index 65e2945..af485ad 100644 --- a/slack-bridge/helpers.ts +++ b/slack-bridge/helpers.ts @@ -434,6 +434,38 @@ function formatPinetInboxPointer(entry: FollowerInboxEntry): string { return buildPinetReadPointer(entry.message.threadId ?? ""); } +function getTransferredSlackThreadContext( + metadata: Record | null, +): { threadId: string; channel?: string } | null { + const transfer = metadata?.threadOwnershipTransfer; + if (!transfer || typeof transfer !== "object" || Array.isArray(transfer)) { + return null; + } + + const raw = transfer as Record; + const threadId = typeof raw.threadId === "string" ? raw.threadId.trim() : ""; + if (!threadId) { + return null; + } + + const source = typeof raw.source === "string" ? raw.source.trim() : ""; + if (source && source !== "slack") { + return null; + } + + const channel = typeof raw.channel === "string" ? raw.channel.trim() : ""; + return { threadId, ...(channel ? { channel } : {}) }; +} + +function formatPinetThreadTransferSuffix(entry: FollowerInboxEntry): string { + const transfer = getTransferredSlackThreadContext(entry.message.metadata); + if (!transfer) { + return ""; + } + + return ` transferred_slack_thread thread_ts=${transfer.threadId}${transfer.channel ? ` channel=${transfer.channel}` : ""} reply=slack_send`; +} + export function formatPinetInboxMessages(entries: FollowerInboxEntry[]): string { const annotatedEntries = entries.map((entry) => { const classification = classifyPinetMail({ @@ -451,7 +483,8 @@ export function formatPinetInboxMessages(entries: FollowerInboxEntry[]): string const sender = getPinetSenderLabel(entry.message); const label = formatPinetMailClassLabel(classification.class); const inboxSuffix = entry.inboxId != null ? ` inbox_id=${entry.inboxId}` : ""; - return `[thread ${threadTs}] [${label}] ${sender}:${inboxSuffix} ${formatPinetInboxPointer(entry)}`; + const transferSuffix = formatPinetThreadTransferSuffix(entry); + return `[thread ${threadTs}] [${label}] ${sender}:${inboxSuffix}${transferSuffix} ${formatPinetInboxPointer(entry)}`; }); const hasMaintenanceOnly = annotatedEntries.some( @@ -461,14 +494,19 @@ export function formatPinetInboxMessages(entries: FollowerInboxEntry[]): string (entry) => entry.classification.class === "steering", ); const hasFollowUp = annotatedEntries.some((entry) => entry.classification.class === "fwup"); + const hasTransferredSlackThread = annotatedEntries.some(({ entry }) => + Boolean(getTransferredSlackThreadContext(entry.message.metadata)), + ); - const guidance = hasMaintenanceOnly - ? hasActionableWork || hasFollowUp - ? "Read pointer(s) before acting; reply via pinet action=send for steering/follow-up." - : "Context-only pointer(s); read only if needed." - : hasActionableWork - ? "Read pointer(s) before acting; reply via pinet action=send." - : "Read pointer(s) if follow-up is needed; reply via pinet action=send when needed."; + const guidance = hasTransferredSlackThread + ? "Read pointer(s) before acting; transferred Slack threads can be replied to with slack_send using the shown thread_ts." + : hasMaintenanceOnly + ? hasActionableWork || hasFollowUp + ? "Read pointer(s) before acting; reply via pinet action=send for steering/follow-up." + : "Context-only pointer(s); read only if needed." + : hasActionableWork + ? "Read pointer(s) before acting; reply via pinet action=send." + : "Read pointer(s) if follow-up is needed; reply via pinet action=send when needed."; return `New Pinet messages:\n${lines.join("\n")}\n\n${guidance}`; } @@ -2133,6 +2171,11 @@ export interface FollowerInboxSyncResult { changed: boolean; } +export interface TransferredSlackThreadSyncResult { + threadUpdates: FollowerThreadState[]; + changed: boolean; +} + export interface BrokerInboxControlEntry { inboxId: number; command: PinetControlCommand; @@ -2147,6 +2190,45 @@ export function isDirectMessageChannel(channel: string): boolean { return /^D[A-Z0-9]+$/.test(channel); } +export function syncTransferredSlackThreadContexts( + entries: FollowerInboxEntry[], + existingThreads: ReadonlyMap, + agentOwner: string, +): TransferredSlackThreadSyncResult { + let changed = false; + const threadUpdates: FollowerThreadState[] = []; + + for (const entry of entries) { + const transfer = getTransferredSlackThreadContext(entry.message.metadata); + if (!transfer?.channel) { + continue; + } + + const existing = existingThreads.get(transfer.threadId); + const nextThread: FollowerThreadState = { + channelId: transfer.channel, + threadTs: transfer.threadId, + userId: existing?.userId ?? "", + owner: agentOwner, + source: "slack", + }; + + threadUpdates.push(nextThread); + if ( + !existing || + existing.channelId !== nextThread.channelId || + existing.threadTs !== nextThread.threadTs || + existing.userId !== nextThread.userId || + existing.owner !== nextThread.owner || + existing.source !== nextThread.source + ) { + changed = true; + } + } + + return { threadUpdates, changed }; +} + export function syncFollowerInboxEntries( entries: FollowerInboxEntry[], existingThreads: ReadonlyMap, diff --git a/slack-bridge/pinet-mesh-ops.test.ts b/slack-bridge/pinet-mesh-ops.test.ts index a8102bd..1bc0142 100644 --- a/slack-bridge/pinet-mesh-ops.test.ts +++ b/slack-bridge/pinet-mesh-ops.test.ts @@ -333,10 +333,19 @@ describe("createPinetMeshOps", () => { messageId: 1, target: "Worker One", transferredThreadId: "1777798507.674009", + transferredThreadChannel: "C123", }); expect(transferThreadOwnership).toHaveBeenCalledWith("1777798507.674009", "worker-1"); + expect(insertedMessages[0]?.body).toContain("Transferred Slack thread context:"); + expect(insertedMessages[0]?.body).toContain("thread_ts: 1777798507.674009"); + expect(insertedMessages[0]?.body).toContain("channel: C123"); 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..2e77573 100644 --- a/slack-bridge/pinet-mesh-ops.ts +++ b/slack-bridge/pinet-mesh-ops.ts @@ -97,7 +97,12 @@ export interface PinetMeshOps { target: string, body: string, metadata?: Record, - ) => Promise<{ messageId: number; target: string; transferredThreadId?: string }>; + ) => Promise<{ + messageId: number; + target: string; + transferredThreadId?: string; + transferredThreadChannel?: string; + }>; sendPinetBroadcastMessage: ( channel: string, body: string, @@ -129,16 +134,32 @@ function prepareOutgoingPinetAgentMessage( return { body, metadata }; } -function getThreadOwnershipTransferId(metadata?: Record): string | null { +function getThreadOwnershipTransferMetadata( + metadata?: Record, +): Record | null { const transfer = metadata?.threadOwnershipTransfer; - if (!transfer || typeof transfer !== "object" || Array.isArray(transfer)) { - return null; - } + return transfer && typeof transfer === "object" && !Array.isArray(transfer) + ? (transfer as Record) + : null; +} - const threadId = (transfer as Record).threadId; +function getThreadOwnershipTransferId(metadata?: Record): string | null { + const threadId = getThreadOwnershipTransferMetadata(metadata)?.threadId; return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null; } +function appendSlackThreadTransferNotice(body: string, threadId: string, channel: string): string { + return [ + body, + "", + "Transferred Slack thread context:", + `- thread_ts: ${threadId}`, + `- channel: ${channel}`, + `- To report directly in the transferred Slack thread, use slack_send with thread_ts ${threadId}; the channel is already recorded in Pinet.`, + "- If slack_send says the thread is already owned by another agent, ask the broker to inspect ownership and transfer it again.", + ].join("\n"); +} + 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 +225,31 @@ export function createPinetMeshOps(deps: PinetMeshOpsDeps): PinetMeshOps { throw new Error(`Thread ${transferThreadId} is not a transferable Slack thread.`); } + const transferThreadChannel = transferThread?.channel; + const transferMetadata = getThreadOwnershipTransferMetadata(finalMetadata); + const dispatchMetadata = transferThreadId + ? { + ...(finalMetadata ?? {}), + threadOwnershipTransfer: { + ...(transferMetadata ?? {}), + mode: "transfer", + threadId: transferThreadId, + source: "slack", + channel: transferThreadChannel, + }, + } + : finalMetadata; + const dispatchBody = + transferThreadId && transferThreadChannel + ? appendSlackThreadTransferNotice(finalBody, transferThreadId, transferThreadChannel) + : finalBody; + const result = dispatchDirectAgentMessage(db, { senderAgentId: selfId, senderAgentName: deps.getAgentName(), target: targetRef, - body: finalBody, - metadata: finalMetadata, + body: dispatchBody, + metadata: dispatchMetadata, }); if (transferThreadId) { @@ -285,6 +325,7 @@ export function createPinetMeshOps(deps: PinetMeshOpsDeps): PinetMeshOps { messageId: result.messageId, target: result.target.name, ...(transferThreadId ? { transferredThreadId: transferThreadId } : {}), + ...(transferThreadChannel ? { transferredThreadChannel: transferThreadChannel } : {}), }; } diff --git a/slack-bridge/pinet-tools.test.ts b/slack-bridge/pinet-tools.test.ts index 358f0c4..8bcc88a 100644 --- a/slack-bridge/pinet-tools.test.ts +++ b/slack-bridge/pinet-tools.test.ts @@ -813,6 +813,7 @@ describe("registerPinetTools", () => { messageId: 41, target: "alpha", transferredThreadId: "1777798507.674009", + transferredThreadChannel: "C123", })); const deps = createDeps({ sendPinetAgentMessage }); const tools = registerWithDeps(deps); @@ -834,9 +835,10 @@ describe("registerPinetTools", () => { }); expect(result.details.status).toBe("succeeded"); expect(result.details.data.text).toBe( - "Pinet message sent to alpha; transferred thread 1777798507.674009.", + "Pinet message sent to alpha; transferred Slack thread 1777798507.674009.", ); expect(result.details.data.details.transferredThreadId).toBe("1777798507.674009"); + expect(result.details.data.details.transferredThreadChannel).toBe("C123"); }); it("rejects follower thread ownership transfers", async () => { diff --git a/slack-bridge/pinet-tools.ts b/slack-bridge/pinet-tools.ts index b3099be..6e25786 100644 --- a/slack-bridge/pinet-tools.ts +++ b/slack-bridge/pinet-tools.ts @@ -65,7 +65,12 @@ export interface RegisterPinetToolsDeps { target: string, body: string, metadata?: Record, - ) => Promise<{ messageId: number; target: string; transferredThreadId?: string }>; + ) => Promise<{ + messageId: number; + target: string; + transferredThreadId?: string; + transferredThreadChannel?: string; + }>; sendPinetBroadcastMessage: ( channel: string, body: string, @@ -568,14 +573,17 @@ function runPinetSendAction( { type: "text", text: output.full - ? `Message sent to ${result.target} (id: ${result.messageId})${result.transferredThreadId ? ` and transferred thread ${result.transferredThreadId}` : ""}.` - : `Pinet message sent to ${result.target}${result.transferredThreadId ? `; transferred thread ${result.transferredThreadId}` : ""}.`, + ? `Message sent to ${result.target} (id: ${result.messageId})${result.transferredThreadId ? ` and transferred Slack thread ${result.transferredThreadId}${result.transferredThreadChannel ? ` (${result.transferredThreadChannel})` : ""}` : ""}.` + : `Pinet message sent to ${result.target}${result.transferredThreadId ? `; transferred Slack thread ${result.transferredThreadId}` : ""}.`, }, ], details: { messageId: result.messageId, target: result.target, ...(result.transferredThreadId ? { transferredThreadId: result.transferredThreadId } : {}), + ...(result.transferredThreadChannel + ? { transferredThreadChannel: result.transferredThreadChannel } + : {}), }, }; })();