From 43c6a4926adb63cbeba3bf251b869b5a580027ab Mon Sep 17 00:00:00 2001 From: David Witwer Date: Tue, 20 Oct 2026 16:12:35 -0700 Subject: [PATCH 1/2] feat(tools): add Slack history() and whoAmI() connector methods [SAP-4015] Agents that read a channel's top-level messages had no connector path, so they kept their own Slack bot token in the vault. history() calls the gateway's conversations.history route; whoAmI() calls auth.test and returns the bot's user_id and bot_id, which an agent needs to pick out its own posts from history. whoAmI is named for what it returns, following the naming guide, rather than Slack's wire name. The offline stub answers both, and a no-arg stub override is called with no arguments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011DacyUn1ab7Tw2AZtWXasj --- .changeset/slack-history-whoami.md | 5 ++ packages/tools/src/client.ts | 9 ++++ packages/tools/src/connectors/slack/README.md | 28 ++++++---- .../tools/src/connectors/slack/index.spec.ts | 23 ++++++++ packages/tools/src/connectors/slack/index.ts | 52 +++++++++++++++++++ packages/tools/src/index.ts | 3 ++ packages/tools/src/stub/index.ts | 25 ++++++++- packages/tools/src/stub/slack-mcp.spec.ts | 32 ++++++++++++ 8 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 .changeset/slack-history-whoami.md diff --git a/.changeset/slack-history-whoami.md b/.changeset/slack-history-whoami.md new file mode 100644 index 000000000..9a36aa6ec --- /dev/null +++ b/.changeset/slack-history-whoami.md @@ -0,0 +1,5 @@ +--- +"@sapiom/tools": minor +--- + +`connectors.slack` gains `history()` (Slack `conversations.history`: a channel's top-level messages, paginated, with `oldest` / `latest` bounds) and `whoAmI()` (Slack `auth.test`: the bot's own `user_id` and `bot_id`, to recognise its own posts). Agents that read a channel no longer need their own Slack bot token. diff --git a/packages/tools/src/client.ts b/packages/tools/src/client.ts index 571b06db9..89ee3ad5f 100644 --- a/packages/tools/src/client.ts +++ b/packages/tools/src/client.ts @@ -202,6 +202,8 @@ import * as github from "./connectors/github/index.js"; import type { ListReposArgs, GitHubRepo } from "./connectors/github/index.js"; import * as slack from "./connectors/slack/index.js"; import type { + SlackHistoryArgs, + SlackHistoryResult, SlackPostEphemeralArgs, SlackPostEphemeralResult, SlackPostMessageArgs, @@ -214,6 +216,7 @@ import type { SlackUpdateResult, SlackUserInfoArgs, SlackUserInfoResult, + SlackWhoAmIResult, } from "./connectors/slack/index.js"; import { mcp } from "./connectors/mcp/index.js"; import type { McpConnector } from "./connectors/mcp/index.js"; @@ -632,6 +635,10 @@ export interface Sapiom { removeReaction(args: SlackReactionArgs): Promise; /** `conversations.replies`: a thread's parent message and replies, paginated. */ replies(args: SlackRepliesArgs): Promise; + /** `conversations.history`: a channel's top-level messages, paginated. */ + history(args: SlackHistoryArgs): Promise; + /** `auth.test`: the bot's own `user_id` and `bot_id`. */ + whoAmI(): Promise; /** `users.info`: look up a user by id. */ userInfo(args: SlackUserInfoArgs): Promise; }; @@ -921,6 +928,8 @@ function bind(transport: Transport): Sapiom { addReaction: (args) => slack.addReaction(args, transport), removeReaction: (args) => slack.removeReaction(args, transport), replies: (args) => slack.replies(args, transport), + history: (args) => slack.history(args, transport), + whoAmI: () => slack.whoAmI(transport), userInfo: (args) => slack.userInfo(args, transport), }, linear: mcp("linear", transport), diff --git a/packages/tools/src/connectors/slack/README.md b/packages/tools/src/connectors/slack/README.md index 94c8e6614..32e108b0e 100644 --- a/packages/tools/src/connectors/slack/README.md +++ b/packages/tools/src/connectors/slack/README.md @@ -1,6 +1,6 @@ # slack -Post, edit, and react to Slack messages and read threads and users over a tenant's +Post, edit, and react to Slack messages and read channels, threads and users over a tenant's connected Slack workspace. The gateway resolves the bot token server-side; it never reaches your run. An unconnected tenant gets a `404 connector_not_found`. @@ -23,17 +23,25 @@ Ambient import works too: `import { connectors } from "@sapiom/tools"` (then `co ## Operations -| SDK method | Slack method | Args | -| ---------------------- | ----------------------- | -------------------------------------------------- | -| `postMessage(args)` | `chat.postMessage` | `channel`, `text?`, `blocks?`, `threadTs?` | -| `update(args)` | `chat.update` | `channel`, `ts`, `text?`, `blocks?` | -| `postEphemeral(args)` | `chat.postEphemeral` | `channel`, `user`, `text?`, `blocks?`, `threadTs?` | -| `addReaction(args)` | `reactions.add` | `channel`, `timestamp`, `name` | -| `removeReaction(args)` | `reactions.remove` | `channel`, `timestamp`, `name` | -| `replies(args)` | `conversations.replies` | `channel`, `ts`, `cursor?`, `limit?` | -| `userInfo(args)` | `users.info` | `user` | +| SDK method | Slack method | Args | +| ---------------------- | ----------------------- | ------------------------------------------------------------------ | +| `postMessage(args)` | `chat.postMessage` | `channel`, `text?`, `blocks?`, `threadTs?` | +| `update(args)` | `chat.update` | `channel`, `ts`, `text?`, `blocks?` | +| `postEphemeral(args)` | `chat.postEphemeral` | `channel`, `user`, `text?`, `blocks?`, `threadTs?` | +| `addReaction(args)` | `reactions.add` | `channel`, `timestamp`, `name` | +| `removeReaction(args)` | `reactions.remove` | `channel`, `timestamp`, `name` | +| `replies(args)` | `conversations.replies` | `channel`, `ts`, `cursor?`, `limit?` | +| `history(args)` | `conversations.history` | `channel`, `cursor?`, `limit?`, `oldest?`, `latest?`, `inclusive?` | +| `whoAmI()` | `auth.test` | none | +| `userInfo(args)` | `users.info` | `user` | Message methods need `text`, `blocks`, or both. `postMessage` resolves to `{ ok, channel, ts }`; the others resolve to Slack's own response body. Slack answering `ok: false` surfaces as a `502` whose message names Slack's error code (e.g. `channel_not_found`, `not_in_channel`). + +`history` returns a channel's top-level messages, newest first; read a thread's replies +with `replies`. It needs the bot to be in the channel: Slack answers `not_in_channel` +otherwise, so invite the Sapiom app first. `whoAmI` returns the bot's `user_id` and +`bot_id`, the `user` / `bot_id` on messages it posted, so an agent can pick out its own +posts from `history`. diff --git a/packages/tools/src/connectors/slack/index.spec.ts b/packages/tools/src/connectors/slack/index.spec.ts index 5c69eed1d..40b2bff7c 100644 --- a/packages/tools/src/connectors/slack/index.spec.ts +++ b/packages/tools/src/connectors/slack/index.spec.ts @@ -120,6 +120,18 @@ describe("slack methods", () => { "conversations.replies", { channel: "C1", ts: "1.1", cursor: "c2", limit: 50 }, ], + [ + "history", + "conversations.history", + { + channel: "C1", + cursor: "c2", + limit: 200, + oldest: "1.0", + latest: "2.0", + inclusive: true, + }, + ], ["userInfo", "users.info", { user: "U1" }], ] as const)( "%s POSTs methods/%s with the args unchanged and returns Slack's body", @@ -141,6 +153,17 @@ describe("slack methods", () => { }, ); + it("whoAmI POSTs methods/auth.test with an empty body and returns the bot identity", async () => { + const result = { ok: true, user_id: "U1", bot_id: "B1", team_id: "T1" }; + const { transport, calls } = makeTransport([() => jsonResponse(result)]); + + await expect(slack.whoAmI(transport)).resolves.toEqual(result); + + expect(calls[0]!.url).toBe(`${BASE}/connectors/v1/slack/methods/auth.test`); + expect(calls[0]!.init.method).toBe("POST"); + expect(bodyOf(calls[0]!)).toEqual({}); + }); + it("surfaces a 404 (no Slack connector for this tenant) with the connector_not_found body", async () => { const { transport } = makeTransport([ () => diff --git a/packages/tools/src/connectors/slack/index.ts b/packages/tools/src/connectors/slack/index.ts index 19615e787..6475d8584 100644 --- a/packages/tools/src/connectors/slack/index.ts +++ b/packages/tools/src/connectors/slack/index.ts @@ -121,6 +121,43 @@ export interface SlackRepliesResult { [key: string]: unknown; } +export interface SlackHistoryArgs { + channel: string; + /** `response_metadata.next_cursor` from a previous page. */ + cursor?: string; + /** Messages per page, 1-1000. */ + limit?: number; + /** Only messages after this `ts`. */ + oldest?: string; + /** Only messages before this `ts`. */ + latest?: string; + /** Include messages whose `ts` equals `oldest` or `latest`. */ + inclusive?: boolean; +} + +export interface SlackHistoryResult { + ok: true; + /** Top-level messages, newest first. Thread replies are not included; read them with `replies`. */ + messages: SlackMessage[]; + has_more?: boolean; + response_metadata?: { next_cursor?: string }; + [key: string]: unknown; +} + +/** The bot's own identity, as `auth.test` returns it; fields beyond these pass through. */ +export interface SlackWhoAmIResult { + ok: true; + /** The bot user's id (`U…`): the `user` on messages it posts. */ + user_id: string; + /** The bot's id (`B…`): the `bot_id` on messages it posts. */ + bot_id?: string; + team_id: string; + team?: string; + user?: string; + url?: string; + [key: string]: unknown; +} + export interface SlackUserInfoArgs { user: string; } @@ -211,6 +248,21 @@ export async function replies( return callMethod("conversations.replies", { ...args }, transport); } +/** Read a channel's top-level messages, newest first, one page at a time. */ +export async function history( + args: SlackHistoryArgs, + transport: Transport = defaultTransport(), +): Promise { + return callMethod("conversations.history", { ...args }, transport); +} + +/** The bot's own user id and bot id, to tell its own posts apart (`auth.test`). */ +export async function whoAmI( + transport: Transport = defaultTransport(), +): Promise { + return callMethod("auth.test", {}, transport); +} + /** Look up a user by id (name, display name, timezone, bot flag). */ export async function userInfo( args: SlackUserInfoArgs, diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 272fe40ab..19d8281a0 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -260,6 +260,8 @@ export type { export type { ListReposArgs, GitHubRepo } from "./connectors/github/index.js"; export type { SlackBlock, + SlackHistoryArgs, + SlackHistoryResult, SlackMessage, SlackMessageContent, SlackPostEphemeralArgs, @@ -275,6 +277,7 @@ export type { SlackUser, SlackUserInfoArgs, SlackUserInfoResult, + SlackWhoAmIResult, } from "./connectors/slack/index.js"; export { McpRelayError } from "./connectors/mcp/index.js"; export type { diff --git a/packages/tools/src/stub/index.ts b/packages/tools/src/stub/index.ts index 0c992e850..d1e56b5e3 100644 --- a/packages/tools/src/stub/index.ts +++ b/packages/tools/src/stub/index.ts @@ -156,12 +156,14 @@ import type { } from "../connectors/google/index.js"; import type { GitHubRepo } from "../connectors/github/index.js"; import type { + SlackHistoryResult, SlackPostEphemeralResult, SlackPostMessageResult, SlackReactionResult, SlackRepliesResult, SlackUpdateResult, SlackUserInfoResult, + SlackWhoAmIResult, } from "../connectors/slack/index.js"; import type { McpCallToolResult, @@ -1052,7 +1054,8 @@ export function createStubClient(opts: StubClientOptions = {}): Sapiom { fallback: () => T, ): T => { const key = `connectors.slack.${method}`; - const result = r(key, [args], fallback); + // `undefined` is a no-arg method (`whoAmI`): its override is called with no arguments. + const result = r(key, args === undefined ? [] : [args], fallback); if ( matchedOverride([key]) && (result === null || typeof result !== "object" || Array.isArray(result)) @@ -2459,6 +2462,26 @@ export function createStubClient(opts: StubClientOptions = {}): Sapiom { ], has_more: false, })) as SlackRepliesResult, + history: async (args) => + stubSlack("history", args, () => ({ + ok: true, + messages: [ + { + type: "message", + ts: "1700000000.000100", + user: "U0STUBUSER", + text: "stub channel message", + }, + ], + has_more: false, + })) as SlackHistoryResult, + whoAmI: async () => + stubSlack("whoAmI", undefined, () => ({ + ok: true, + user_id: "U0STUBBOT", + bot_id: "B0STUBBOT", + team_id: "T0STUBTEAM", + })) as SlackWhoAmIResult, userInfo: async (args) => stubSlack("userInfo", args, () => ({ ok: true, diff --git a/packages/tools/src/stub/slack-mcp.spec.ts b/packages/tools/src/stub/slack-mcp.spec.ts index 2315bddbc..968043790 100644 --- a/packages/tools/src/stub/slack-mcp.spec.ts +++ b/packages/tools/src/stub/slack-mcp.spec.ts @@ -24,6 +24,12 @@ describe("stub slack + mcp connectors", () => { expect(thread.messages[0]).toMatchObject({ ts: "1.1" }); const info = await slack.userInfo({ user: "U9" }); expect(info.user).toMatchObject({ id: "U9", is_bot: false }); + const channel = await slack.history({ channel: "C1" }); + expect(channel.messages).toHaveLength(1); + await expect(slack.whoAmI()).resolves.toMatchObject({ + user_id: "U0STUBBOT", + bot_id: "B0STUBBOT", + }); }); it("returns and records a Slack override", async () => { @@ -48,6 +54,32 @@ describe("stub slack + mcp connectors", () => { ); }); + it("calls a whoAmI override with no arguments", async () => { + const calls: StubCallRecord[] = []; + const client = createStubClient({ + calls, + overrides: { + "connectors.slack.whoAmI": (...args: unknown[]) => ({ + ok: true, + user_id: "U7", + team_id: "T7", + argCount: args.length, + }), + }, + }); + + await expect(client.connectors.slack.whoAmI()).resolves.toMatchObject({ + user_id: "U7", + argCount: 0, + }); + expect(calls).toContainEqual( + expect.objectContaining({ + capability: "connectors.slack.whoAmI", + args: [], + }), + ); + }); + it("rejects when a Slack override throws", async () => { const client = createStubClient({ overrides: { From 373d14b1a0d266a110d00b37f284462ebb699175 Mon Sep 17 00:00:00 2001 From: David Witwer Date: Tue, 20 Oct 2026 16:14:52 -0700 Subject: [PATCH 2/2] fix(tools): keep the Slack history stub off the bot's post ts; update support-desk note [SAP-4015] The offline history() message reused postMessage's stub ts but named a different author, so local code matching history against whoAmI saw the bot's own post credited to someone else. The support-desk README said the connector has no conversations.history; it now has one, and the remaining gap is that the desk does not search it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011DacyUn1ab7Tw2AZtWXasj --- examples/support-desk/README.md | 22 +++++++++++----------- packages/tools/src/stub/index.ts | 3 ++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/examples/support-desk/README.md b/examples/support-desk/README.md index 72b493fe3..839a3f9d5 100644 --- a/examples/support-desk/README.md +++ b/examples/support-desk/README.md @@ -690,14 +690,14 @@ The deployed agents always share the fleet database. ## Known limitations -| Limitation | Why | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A channel without the bot is invisible.** A customer posting there produces no event, no run and no alert. | Slack sends nothing for a channel the bot is not in. The setup agent probes the channels listed in `channels.customer`; it cannot find unlisted ones. | -| **Post, then record (top-level posts).** A threaded post that is recorded after it is sent is stamped with a block id derived from its key, so a retry finds it in the thread and records it instead of posting again (intake's team-reply mirror records first, so it cannot duplicate). A top-level post (the intake triage card, the urgent-pager DM, the controller's on-call escalation DM, the daily digest, an On Hold notice on an issue with no triage thread) still posts again if Slack accepted it and the next database write failed. | Slack's Web API has no idempotency key, and the Slack connector exposes neither `conversations.history` nor message `metadata`, so a top-level post cannot be found again. | -| **Linear adoption window.** A retry more than 7 days after a crash between creating the Linear issue and recording it creates a second one. | `save_issue` has no idempotency key; escalation adopts by a `:` marker over the last 7 days. | -| **Thread replies under a card left in an old triage channel are not notes.** After a desk's `triageChannel` moves, a teammate's reply under one of its existing cards is not stored on the issue. | Intake routes a message by its channel, and the old channel is no longer a desk's triage channel. | -| **Intake links by content.** A new top-level message joins any open issue Jev judges to be the same problem (p ≥ 0.8). Leftover open issues capture new messages. | Run `pnpm run reset-demo` before a demo. | -| **Latency.** A customer message reaches its draft card in about 35 s. A click shows a working state in about 2 s. | The time goes to the steps: intake makes a Jev call and several Slack calls, and each step starts a fresh sandbox call. | -| **Deploy detection is local.** `pnpm run setup` skips a deploy when the bundle hash in `.sapiom/fleet-state.json` matches the live build; a fresh clone redeploys once. | The server does not expose a content hash for a build. | -| **Customers are recognised by Slack workspace.** Anyone outside `team.slack_team_ids` posting in a channel the bot is in is a customer. Shared channels cannot be detected. A team member's message is kept only in a channel that already has an account. A customer who posts from your workspace needs `customers.test_user_ids`. | The connector has no `conversations.info`. | -| **The docs source is fleet-wide.** Every desk drafts from the same `knowledge.docs_url`. | Desks share it until a per-desk override is needed. | +| Limitation | Why | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A channel without the bot is invisible.** A customer posting there produces no event, no run and no alert. | Slack sends nothing for a channel the bot is not in. The setup agent probes the channels listed in `channels.customer`; it cannot find unlisted ones. | +| **Post, then record (top-level posts).** A threaded post that is recorded after it is sent is stamped with a block id derived from its key, so a retry finds it in the thread and records it instead of posting again (intake's team-reply mirror records first, so it cannot duplicate). A top-level post (the intake triage card, the urgent-pager DM, the controller's on-call escalation DM, the daily digest, an On Hold notice on an issue with no triage thread) still posts again if Slack accepted it and the next database write failed. | Slack's Web API has no idempotency key, and the desk does not yet search channel history (`connectors.slack.history`) for a top-level post's block id, so it cannot find one again. | +| **Linear adoption window.** A retry more than 7 days after a crash between creating the Linear issue and recording it creates a second one. | `save_issue` has no idempotency key; escalation adopts by a `:` marker over the last 7 days. | +| **Thread replies under a card left in an old triage channel are not notes.** After a desk's `triageChannel` moves, a teammate's reply under one of its existing cards is not stored on the issue. | Intake routes a message by its channel, and the old channel is no longer a desk's triage channel. | +| **Intake links by content.** A new top-level message joins any open issue Jev judges to be the same problem (p ≥ 0.8). Leftover open issues capture new messages. | Run `pnpm run reset-demo` before a demo. | +| **Latency.** A customer message reaches its draft card in about 35 s. A click shows a working state in about 2 s. | The time goes to the steps: intake makes a Jev call and several Slack calls, and each step starts a fresh sandbox call. | +| **Deploy detection is local.** `pnpm run setup` skips a deploy when the bundle hash in `.sapiom/fleet-state.json` matches the live build; a fresh clone redeploys once. | The server does not expose a content hash for a build. | +| **Customers are recognised by Slack workspace.** Anyone outside `team.slack_team_ids` posting in a channel the bot is in is a customer. Shared channels cannot be detected. A team member's message is kept only in a channel that already has an account. A customer who posts from your workspace needs `customers.test_user_ids`. | The connector has no `conversations.info`. | +| **The docs source is fleet-wide.** Every desk drafts from the same `knowledge.docs_url`. | Desks share it until a per-desk override is needed. | diff --git a/packages/tools/src/stub/index.ts b/packages/tools/src/stub/index.ts index d1e56b5e3..0f3e55eaf 100644 --- a/packages/tools/src/stub/index.ts +++ b/packages/tools/src/stub/index.ts @@ -2468,7 +2468,8 @@ export function createStubClient(opts: StubClientOptions = {}): Sapiom { messages: [ { type: "message", - ts: "1700000000.000100", + // Not postMessage's stub ts: this is another user's message, not the bot's. + ts: "1700000000.000050", user: "U0STUBUSER", text: "stub channel message", },