diff --git a/.changeset/bright-agents-stop.md b/.changeset/bright-agents-stop.md new file mode 100644 index 000000000..16dbac15a --- /dev/null +++ b/.changeset/bright-agents-stop.md @@ -0,0 +1,6 @@ +--- +"@chat-adapter/slack": minor +"chat": minor +--- + +Add Slack Agent Sessions lifecycle support, native stop cancellation through `thread.signal`, automatic session titles, and session stop/title-change events while preserving the legacy `assistant_view` compatibility path. diff --git a/apps/docs/content/adapters/official/slack.mdx b/apps/docs/content/adapters/official/slack.mdx index f732a791f..70b33dbe0 100644 --- a/apps/docs/content/adapters/official/slack.mdx +++ b/apps/docs/content/adapters/official/slack.mdx @@ -5,7 +5,7 @@ packageName: "@chat-adapter/slack" slug: slack type: platform logo: slack -tagline: Build bots for Slack workspaces with full support for threads, reactions, native streaming, scheduled messages, modals, slash commands, and the Assistants API. +tagline: Build bots for Slack workspaces with full support for threads, reactions, Agent Sessions, native streaming, scheduled messages, modals, and slash commands. beta: true features: postMessage: yes @@ -142,6 +142,12 @@ bot.onNewMention(async (thread, message) => { description: "Enable the Agent messaging experience (agent_view manifest mode).", }, + sessionTitle: { + type: "boolean | ((context) => string | Promise | null)", + default: "true when agentView is enabled", + description: + "Automatically title new agent sessions from the root user message, or customize titles with a resolver.", + }, suggestedPrompts: { type: "SlackSuggestedPrompts", description: @@ -150,7 +156,7 @@ bot.onNewMention(async (thread, message) => { loadingMessages: { type: "string[]", description: - "Default rotating status strings for the assistant thinking indicator, used by startTyping and setAssistantStatus when no explicit status is passed.", + "Legacy assistant_view rotating status strings. Agent Sessions use Slack's standard Working state.", }, nativeStreaming: { type: "boolean", @@ -614,7 +620,7 @@ The package still installs the full Slack adapter dependencies. The subpaths kee ### Agents -Everything for building an AI agent on Slack: the Agent messaging experience (`agent_view`), the Assistants API (suggested prompts, status, titles), native streaming, and feedback buttons. +Everything for building an AI agent on Slack: the Agent messaging experience (`agent_view`), Agent Sessions, suggested prompts, native streaming, and feedback buttons. #### Agent messaging experience @@ -624,6 +630,10 @@ Slack's Agent messaging experience (`agent_view` manifest mode) supersedes the o const slack = createSlackAdapter({ agentView: true }); ``` +Slack deprecated `assistant_view` on August 20, 2026 and will retire it in +February 2027. Chat SDK keeps the legacy path available when `agentView` is +false, but new and migrated apps should use Agent messaging now. + With `agentView: true`: - `onAppHomeOpened` is the DM-open signal (Slack no longer signals DM-open via `assistant_thread_started` under `agent_view`), and it fires regardless of the opened tab — branch on `event.tab` (`"home"` vs `"messages"`) if you also publish a Home view. @@ -631,6 +641,7 @@ With `agentView: true`: - `getAppContext(message)` returns the folded active-view context on a DM message. - `setSuggestedPrompts(channelId, undefined, prompts)` may omit the thread reference — prompts sit at the top of the agent conversation. A `suggestedPrompts` config entry is applied automatically on every Messages-tab open. - DM messages are threaded per Slack's model (each user message is a thread root). Threads returned by `openDM()` keep working: when the conversation-scoped thread is subscribed, incoming top-level DM messages route to it, so `onSubscribedMessage` and per-thread state behave as before. +- New sessions are titled from the first line of the root message by default. Set `sessionTitle: false` to disable this, or pass a resolver to customize it. Because bot replies are threaded under each user message, channel-level history (`channel.messages`, `conversations.history`) only returns the user's side of a DM conversation. If you build AI conversation history for DMs, use [transcripts](/docs/conversation-history) (which record both roles across thread IDs) instead of channel history — otherwise the model never sees its own previous replies. @@ -643,15 +654,74 @@ oauth_config: scopes: bot: - assistant:write + - chat:write settings: event_subscriptions: bot_events: - app_home_opened - app_context_changed + - agent_session_stopped + - agent_session_title_changed +``` + +#### Agent Sessions API + +With `agentView: true`, `startTyping()` transitions the session to +`processing`. Slack shows its standard Working indicator and a native stop +button. Chat SDK returns the session to `active` after posts and streams; use +`setSessionStatus()` directly for `suspended` or `closed` states. + +```typescript +await thread.startTyping(); + +const result = await agent.stream({ + prompt: message.text, + abortSignal: thread.signal, +}); +await thread.post(result.fullStream); +``` + +Always pass `thread.signal` to model APIs. When the user clicks Slack's stop +button, Chat SDK aborts that signal locally and through the configured shared +state adapter, stops rendering the stream, and transitions the session out of +`processing`. + +```typescript +bot.onAgentSessionStopped(async (event) => { + await releaseExternalResources(event.threadId); +}); + +bot.onAgentSessionTitleChanged(async (event) => { + await syncTitle(event.threadId, event.title); +}); ``` -#### Slack Assistants API +The `SlackAdapter` exposes: + +| Method | Description | +|--------|-------------| +| `setSessionStatus(channelId, threadTs, status)` | Set `processing`, `active`, `suspended`, or `closed` | +| `setAssistantTitle(channelId, threadTs, title)` | Rename the agent session | +| `setSuggestedPrompts(channelId, threadTs, prompts, title?)` | Show prompt suggestions | +| `publishHomeView(userId, view)` | Publish a Home tab view | +| `startTyping(threadId)` | Set the agent session to `processing` | + +`setAssistantStatus` and `setAssistantTitle` remain compatibility methods: +under `agentView`, they map to `agents.sessions.*`; under legacy +`assistant_view`, they call `assistant.threads.*`. Custom status text and +`loadingMessages` only apply to the legacy experience. + +Customize automatic titles with `sessionTitle`: + +```typescript +const slack = createSlackAdapter({ + agentView: true, + sessionTitle: ({ text }) => text.split("\n", 1)[0]?.slice(0, 80) ?? null, +}); +``` + +#### Legacy Slack Assistants API The adapter supports Slack's [Assistants API](https://api.slack.com/docs/apps/ai). Register handlers on the `Chat` instance: @@ -698,7 +768,7 @@ const slack = createSlackAdapter({ }); ``` -`loadingMessages` becomes the default for `startTyping(threadId)` and `setAssistantStatus(...)` when no explicit status/messages are passed. +`loadingMessages` becomes the default for `startTyping(threadId)` and `setAssistantStatus(...)` in legacy `assistant_view` when no explicit status/messages are passed. The `SlackAdapter` exposes: @@ -785,6 +855,8 @@ display_information: description: A bot built with chat-sdk features: + agent_view: + agent_description: A bot built with Chat SDK bot_user: display_name: My Bot always_online: true @@ -793,6 +865,7 @@ oauth_config: scopes: bot: - app_mentions:read + - assistant:write - channels:history - channels:read - chat:write @@ -816,8 +889,10 @@ settings: - message.im - message.mpim - member_joined_channel - - assistant_thread_started - - assistant_thread_context_changed + - app_home_opened + - app_context_changed + - agent_session_stopped + - agent_session_title_changed interactivity: is_enabled: true request_url: https://your-domain.com/api/webhooks/slack diff --git a/apps/docs/content/docs/api/chat.mdx b/apps/docs/content/docs/api/chat.mdx index e0cb466fb..b39911651 100644 --- a/apps/docs/content/docs/api/chat.mdx +++ b/apps/docs/content/docs/api/chat.mdx @@ -452,6 +452,36 @@ bot.onAssistantContextChanged(async (event) => { The event shape is identical to `onAssistantThreadStarted`. +### onAgentSessionStopped + +Fires after a user clicks Slack's native agent-session stop button. Chat SDK +aborts the active turn and moves the session out of `processing` before this +handler runs. + +```typescript +bot.onAgentSessionStopped(async (event) => { + await releaseExternalResources(event.threadId); +}); +``` + +The event contains `threadId`, `channelId`, `threadTs`, +`streamingMessageTs`, `userId`, and `adapter`. `streamingMessageTs` lists the +streaming messages Slack stopped and can be empty when no stream was active. + +### onAgentSessionTitleChanged + +Fires when a user renames a Slack agent session. + +```typescript +bot.onAgentSessionTitleChanged(async (event) => { + await syncTitle(event.threadId, event.title); +}); +``` + +The event contains `title`, `previousTitle`, `threadId`, `channelId`, +`threadTs`, `userId`, and `adapter`. `previousTitle` is omitted when the +session did not have a title before the change. + ### onAppHomeOpened Fires when a user opens the bot's Home tab in Slack. Use this to publish a dynamic Home tab view. @@ -485,6 +515,19 @@ bot.onAppHomeOpened(async (event) => { ## Utility methods +### abortTurn + +Abort the active handler turn for a thread. Chat SDK signals work in this +process immediately and publishes the cancellation through the configured +state adapter for another serverless instance to observe. + +```typescript +await bot.abortTurn(thread.id); +``` + +Platform adapters normally invoke this for native cancellation events. Model +and tool calls must receive `thread.signal` to stop their own upstream work. + ### webhooks Type-safe webhook handlers keyed by adapter name. Pass these to your HTTP route handler. diff --git a/apps/docs/content/docs/api/thread.mdx b/apps/docs/content/docs/api/thread.mdx index d2b99474f..b67811b6a 100644 --- a/apps/docs/content/docs/api/thread.mdx +++ b/apps/docs/content/docs/api/thread.mdx @@ -194,7 +194,10 @@ await thread.setState({ aiMode: false }, { replace: true }); ## startTyping -Show a typing indicator in the thread. No-op on platforms that don't support it. On Slack, you can pass an optional `status` string to show a custom loading message (requires `assistant:write` scope). +Show a typing indicator in the thread. No-op on platforms that don't support +it. With Slack Agent messaging, this sets the session to `processing` and +shows Slack's standard Working state plus a native stop button. Custom status +text only applies to legacy `assistant_view`. ```typescript await thread.startTyping(); @@ -203,6 +206,25 @@ await thread.startTyping(); await thread.startTyping("Searching documents..."); ``` +## signal + +An `AbortSignal` for the active turn. Slack aborts it when the user clicks the +native agent-session stop button, including when the stop webhook reaches a +different serverless instance sharing the same state adapter. + +Pass it to model and tool APIs so cancellation stops upstream work: + +```typescript +const result = await agent.stream({ + prompt: message.text, + abortSignal: thread.signal, +}); +await thread.post(result.fullStream); +``` + +Threads created outside an incoming handler expose a signal that remains +unaborted. + ## markAsRead Mark an inbound message as read. WhatsApp, Messenger, and XChat support this capability. Other adapters throw `NotImplementedError`. diff --git a/apps/docs/content/docs/handling-events.mdx b/apps/docs/content/docs/handling-events.mdx index 8d22e193e..f15061f0e 100644 --- a/apps/docs/content/docs/handling-events.mdx +++ b/apps/docs/content/docs/handling-events.mdx @@ -355,6 +355,30 @@ For button clicks, slash commands, and modal forms, see the dedicated guides: These handlers are specific to the Slack platform and require the Slack adapter. +### Handling agent session stop and title changes + +`onAgentSessionStopped` fires when a user clicks Slack's native stop button. +Chat SDK aborts the active `thread.signal` and transitions the session out of +`processing` before invoking your handler. + +```typescript title="lib/bot.ts" lineNumbers +bot.onAgentSessionStopped(async (event) => { + await releaseExternalResources(event.threadId); +}); +``` + +`onAgentSessionTitleChanged` fires when a user renames a session in Slack: + +```typescript title="lib/bot.ts" lineNumbers +bot.onAgentSessionTitleChanged(async (event) => { + await syncTitle(event.threadId, event.title); +}); +``` + +Subscribe to `agent_session_stopped` and `agent_session_title_changed` in the +Slack app manifest. Model calls should receive `thread.signal` so stop also +cancels upstream generation. + ### Handling assistant threads `onAssistantThreadStarted` fires when a user opens a new assistant thread in Slack. Use it with the [Slack Assistants API](/adapters/official/slack#slack-assistants-api) to set suggested prompts and status indicators. @@ -387,7 +411,7 @@ The `event` object includes: ```typescript title="lib/bot.ts" lineNumbers bot.onAssistantContextChanged(async (event) => { const slack = bot.getAdapter("slack") as SlackAdapter; - await slack.setStatus(event.channelId, event.threadTs, "Updating context..."); + await slack.setAssistantStatus(event.channelId, event.threadTs, "Updating context..."); // Update prompts based on new context const channelName = event.context.channelId ?? "general"; diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx index 9e02ad06d..9c321aeb8 100644 --- a/apps/docs/content/docs/streaming.mdx +++ b/apps/docs/content/docs/streaming.mdx @@ -25,11 +25,19 @@ const agent = new ToolLoopAgent({ }); bot.onNewMention(async (thread, message) => { - const result = await agent.stream({ prompt: message.text }); + const result = await agent.stream({ + prompt: message.text, + abortSignal: thread.signal, + }); await thread.post(result.fullStream); }); ``` +Passing `thread.signal` lets platform cancellation stop model generation. In +Slack Agent messaging, clicking the native stop button aborts this signal even +when the stop webhook is handled by another serverless instance using the same +state adapter. + ### Why `fullStream` over `textStream`? When AI SDK agents make tool calls between text steps, `textStream` concatenates all text without separators — `"hello.how are you?"` instead of `"hello.\n\nhow are you?"`. The `fullStream` contains explicit `finish-step` events that Chat SDK uses to inject paragraph breaks between steps automatically. @@ -44,6 +52,17 @@ await thread.post(result.fullStream); await thread.post(result.textStream); ``` +For a human-in-the-loop turn, wrap the stream in `StreamingPlan` and leave the +Slack Agent Session suspended after the final chunk: + +```typescript +await thread.post( + new StreamingPlan(result.fullStream, { + sessionStatus: "suspended", + }) +); +``` + ## Custom streams Any async iterable works: diff --git a/examples/nextjs-chat/.gitignore b/examples/nextjs-chat/.gitignore index 179118acb..b20b3451f 100644 --- a/examples/nextjs-chat/.gitignore +++ b/examples/nextjs-chat/.gitignore @@ -1,2 +1,3 @@ .env*.local .env* +/.swc diff --git a/examples/nextjs-chat/slack-manifest.yml b/examples/nextjs-chat/slack-manifest.yml index 668857376..cf338e920 100644 --- a/examples/nextjs-chat/slack-manifest.yml +++ b/examples/nextjs-chat/slack-manifest.yml @@ -34,7 +34,7 @@ oauth_config: - reactions:write # User info for display names - users:read - # Agent experience (suggested prompts, thinking status) — uncomment + # Agent experience (suggested prompts and Agent Sessions) — uncomment # together with the agent_view / event blocks below. # - assistant:write @@ -52,6 +52,8 @@ settings: # Agent experience events — uncomment together with agent_view above. # - app_home_opened # - app_context_changed + # - agent_session_stopped + # - agent_session_title_changed interactivity: is_enabled: false org_deploy_enabled: false diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index d5c047848..6c4506fec 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -74,7 +74,8 @@ const DISCORD_METHODS = [ // Suggested prompts are pinned automatically when an assistant/agent thread // opens; the resolver tailors them to the user's active view (agent_view // folds what the user is looking at into the event as `entities`). -// `loadingMessages` rotate in the thinking indicator while the bot works. +// `loadingMessages` rotate only in legacy assistant_view; agent_view uses +// Slack's standard Agent Sessions working state and native stop button. // Set SLACK_AGENT_VIEW=true when your manifest uses `agent_view`. // Set SLACK_NATIVE_STREAMING=false to compare Slack's native streaming API // (chat.startStream/appendStream/stopStream, the default) against the diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx index bca56024c..d73f4418a 100644 --- a/examples/nextjs-chat/src/lib/bot.tsx +++ b/examples/nextjs-chat/src/lib/bot.tsx @@ -171,12 +171,15 @@ bot.onNewMention(async (thread, message) => { if (AI_MENTION_REGEX.test(message.text)) { await thread.setState({ aiMode: true }); // Also respond to the initial message with AI (including any image attachments). - // No explicit status: on Slack this falls back to the adapter-level - // `loadingMessages` rotation (see SLACK_AGENT_OPTIONS in adapters.ts). + // Slack agent_view shows its standard Agent Sessions working state; + // legacy assistant_view uses the configured loadingMessages rotation. await thread.startTyping(); try { const history = await toAiMessages([message]); - const result = await agent.stream({ prompt: history }); + const result = await agent.stream({ + prompt: history, + abortSignal: thread.signal, + }); await thread.post(result.fullStream); } catch (err) { console.error("Error in AI response:", err); @@ -309,7 +312,10 @@ bot.onDirectMessage(async (thread, message, channel) => { } try { - const result = await agent.stream({ prompt: history }); + const result = await agent.stream({ + prompt: history, + abortSignal: thread.signal, + }); await thread.post(result.fullStream); // Persist the assistant reply so the next turn sees both sides. if (message.userKey) { @@ -1368,11 +1374,14 @@ bot.onSubscribedMessage(async (thread, message) => { ); } - // No explicit status: on Slack this falls back to the adapter-level - // `loadingMessages` rotation (see SLACK_AGENT_OPTIONS in adapters.ts). + // Slack agent_view shows its standard Agent Sessions working state; + // legacy assistant_view uses the configured loadingMessages rotation. await thread.startTyping(); try { - const result = await agent.stream({ prompt: history }); + const result = await agent.stream({ + prompt: history, + abortSignal: thread.signal, + }); await thread.post(result.fullStream); const responseText = await result.text; // Persist the assistant reply alongside the user message, so the next diff --git a/packages/adapter-slack/AGENTS.md b/packages/adapter-slack/AGENTS.md index d0a1d2f19..1069e38e2 100644 --- a/packages/adapter-slack/AGENTS.md +++ b/packages/adapter-slack/AGENTS.md @@ -95,7 +95,8 @@ The package's main exports (see `src/index.ts`): `removeReaction`, `startTyping`, `openModal`, `getInstallation`, `setInstallation`, `deleteInstallation`, `handleOAuthCallback`, `withBotToken`, `setSuggestedPrompts`, `setAssistantStatus`, - `setAssistantTitle`, `publishHomeView`, `startSocketModeListener`. + `setSessionStatus`, `setAssistantTitle`, `publishHomeView`, + `startSocketModeListener`. - `SlackAdapterConfig`, `SlackBotToken`, `SlackInstallation`, `SlackThreadId`, `SlackEvent`, `SlackReactionEvent`, `SlackAdapterMode`, `SlackOAuthCallbackOptions` — configuration and @@ -252,6 +253,12 @@ markdown stays valid (no half-formed tags); structured `task_update` / (the `StreamingPlan` `endWith` option) attach Block Kit to the final message on `chat.stopStream`. +With `agentView` enabled, `startTyping` and stream completion manage the +Agent Sessions lifecycle through `agents.sessions.setStatus`, and +`agent_session_stopped` aborts the active `Thread.signal`. Keep the legacy +`assistant.threads.*` path intact for `agentView: false` until Slack retires +`assistant_view` in February 2027. + Fallback to post-and-edit (`chat.update` deltas, throttled by `updateIntervalMs`) happens at three levels: diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index f2ee5d468..8b8752287 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -502,9 +502,23 @@ Internal API calls (`postMessage`, `editMessage`, `fetchMessages`, etc.) are unaffected — they continue to resolve tokens through the same async path they always have. -## Slack Assistants API +## Slack Agent messaging -The adapter supports Slack's [Assistants API](https://api.slack.com/docs/apps/ai) for building AI-powered assistant experiences. This enables suggested prompts, status indicators, and thread titles in assistant DM threads. +Enable `agentView: true` for Slack's Agent messaging experience. Slack +deprecated `assistant_view` and will retire it in February 2027; the adapter +keeps it available as a compatibility path when `agentView` is false. + +Agent Sessions provide lifecycle status, native stop, and session titles. +Pass `thread.signal` to model APIs so clicking Slack's stop button cancels +upstream generation: + +```typescript +const result = await agent.stream({ + prompt: message.text, + abortSignal: thread.signal, +}); +await thread.post(result.fullStream); +``` ### Event handlers @@ -522,19 +536,28 @@ bot.onAssistantThreadStarted(async (event) => { bot.onAssistantContextChanged(async (event) => { // User navigated to a different channel with the assistant panel open }); + +bot.onAgentSessionStopped(async (event) => { + await releaseExternalResources(event.threadId); +}); + +bot.onAgentSessionTitleChanged(async (event) => { + await syncTitle(event.threadId, event.title); +}); ``` ### Adapter methods -The `SlackAdapter` exposes these methods for the Assistants API: +The `SlackAdapter` exposes these methods: | Method | Description | |--------|-------------| | `setSuggestedPrompts(channelId, threadTs, prompts, title?)` | Show prompt suggestions in the thread | -| `setAssistantStatus(channelId, threadTs, status)` | Show a thinking/status indicator | -| `setAssistantTitle(channelId, threadTs, title)` | Set the thread title (shown in History) | +| `setSessionStatus(channelId, threadTs, status)` | Set `processing`, `active`, `suspended`, or `closed` | +| `setAssistantStatus(channelId, threadTs, status)` | Compatibility wrapper for session/assistant status | +| `setAssistantTitle(channelId, threadTs, title)` | Rename the session/thread | | `publishHomeView(userId, view)` | Publish a Home tab view for a user | -| `startTyping(threadId, status)` | Show a custom loading status (requires `assistant:write` scope) | +| `startTyping(threadId, status)` | Set `processing`; custom text is legacy-only | ### Required scopes and events @@ -545,12 +568,15 @@ oauth_config: scopes: bot: - assistant:write + - chat:write settings: event_subscriptions: bot_events: - - assistant_thread_started - - assistant_thread_context_changed + - app_home_opened + - app_context_changed + - agent_session_stopped + - agent_session_title_changed ``` ### Stream with stop blocks diff --git a/packages/adapter-slack/sample-messages.md b/packages/adapter-slack/sample-messages.md index cf12ba10f..44e986de5 100644 --- a/packages/adapter-slack/sample-messages.md +++ b/packages/adapter-slack/sample-messages.md @@ -71,3 +71,15 @@ body: '{"token":"xAbCdEfGhIjKlMnOpQrStUvW","team_id":"T00FAKE00AA","context_team_id":"T00FAKE00AA","context_enterprise_id":null,"api_app_id":"A00FAKEAPP01","event":{"type":"message","user":"U00FAKEUSER1","ts":"1786120899.208429","client_msg_id":"8f1c2d3e-45a6-47b8-9c0d-1e2f3a4b5c6d","text":"Which devices support remote firmware upgrades?","team":"T00FAKE00AA","blocks":[{"type":"rich_text","block_id":"tblQ1","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"Which devices support remote firmware upgrades?"}]}]}],"attachments":[{"id":1,"fallback":"[no preview available]","blocks":[{"type":"table","block_id":"pasted1","rows":[[{"type":"rich_text","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"Manufacturer","style":{"bold":true}}]}]},{"type":"raw_text","text":"Identifier Listed"},{"type":"raw_text","text":"Units"}],[{"type":"raw_text","text":"Samsung"},{"type":"raw_text","text":"QB55C"},{"type":"raw_number","value":3}]]}]}],"channel":"C00FAKECHAN1","event_ts":"1786120899.208429","channel_type":"channel"},"type":"event_callback","event_id":"Ev0ATABLE001","event_time":1786120899,"authorizations":[{"enterprise_id":null,"team_id":"T00FAKE00AA","user_id":"U00FAKEBOT01","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false}' } ``` + +```log +[chat-sdk:slack] Slack webhook raw body { + body: '{"token":"xAbCdEfGhIjKlMnOpQrStUvW","team_id":"T00FAKE00AA","api_app_id":"A00FAKEAPP01","event":{"type":"agent_session_stopped","user":"U00FAKEUSER1","channel":"D00FAKEDM001","thread_ts":"1782234671.392669","streaming_message_ts":["1782234987.693923"],"event_ts":"1783536983.783769"},"type":"event_callback","event_id":"Ev0AGENTSTOP1","event_time":1783536983}' +} +``` + +```log +[chat-sdk:slack] Slack webhook raw body { + body: '{"token":"xAbCdEfGhIjKlMnOpQrStUvW","team_id":"T00FAKE00AA","api_app_id":"A00FAKEAPP01","event":{"type":"agent_session_title_changed","user":"U00FAKEUSER1","channel":"D00FAKEDM001","thread_ts":"1782234671.392669","previous_title":"Scuba diving research","title":"Bora Bora trip prep","event_ts":"1783536983.783769","team_id":"T00FAKE00AA"},"type":"event_callback","event_id":"Ev0AGENTTITLE1","event_time":1783536983}' +} +``` diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 1d3510bf5..b7ac1af41 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -3865,7 +3865,11 @@ describe("message subtype handling", () => { it.each([ ["a flat DM", { agentView: false }, "slack:D_DM:"], - ["a threaded agent_view DM", { agentView: true }, "slack:D_DM:1111.0001"], + [ + "a threaded agent_view DM", + { agentView: true, sessionTitle: false }, + "slack:D_DM:1111.0001", + ], ])("routes the message, its edit, and its delete to one thread id in %s", async (_, config, expected) => { const adapter = createSlackAdapter({ botToken: "xoxb-test-token", @@ -4531,6 +4535,7 @@ describe("handleWebhook - slash commands", () => { // ============================================================================ interface MockableClient { + apiCall: ReturnType; assistant: { threads: { setStatus: ReturnType; @@ -5690,6 +5695,89 @@ describe("agent_view DM threading", () => { expect(threadId).toBe("slack:D1:1771.99"); }); + it("automatically titles a top-level agent_view DM session", async () => { + const adapter = createSlackAdapter({ + agentView: true, + botToken: "xoxb-test-token", + signingSecret: secret, + botUserId: "U_BOT", + logger: mockLogger, + }); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); + await adapter.initialize( + createMockChatInstance({ state: createMockState() }) + ); + const tasks: Promise[] = []; + + await adapter.handleWebhook(createWebhookRequest(dmMessageBody(), secret), { + waitUntil: (task) => tasks.push(task), + }); + await Promise.all(tasks); + + expect(apiCall).toHaveBeenCalledWith( + "agents.sessions.rename", + expect.objectContaining({ + channel_id: "D1", + thread_ts: "1771.99", + title: "hi", + }) + ); + }); + + it("skips automatic titles when sessionTitle is disabled", async () => { + const adapter = createSlackAdapter({ + agentView: true, + sessionTitle: false, + botToken: "xoxb-test-token", + signingSecret: secret, + botUserId: "U_BOT", + logger: mockLogger, + }); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); + await adapter.initialize( + createMockChatInstance({ state: createMockState() }) + ); + const tasks: Promise[] = []; + + await adapter.handleWebhook(createWebhookRequest(dmMessageBody(), secret), { + waitUntil: (task) => tasks.push(task), + }); + await Promise.all(tasks); + + expect(apiCall).not.toHaveBeenCalledWith( + "agents.sessions.rename", + expect.anything() + ); + }); + + it("does not retitle an agent_view DM follow-up", async () => { + const adapter = createSlackAdapter({ + agentView: true, + botToken: "xoxb-test-token", + signingSecret: secret, + botUserId: "U_BOT", + logger: mockLogger, + }); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); + await adapter.initialize( + createMockChatInstance({ state: createMockState() }) + ); + const body = JSON.parse(dmMessageBody()); + body.event.thread_ts = "1771.00"; + + await adapter.handleWebhook( + createWebhookRequest(JSON.stringify(body), secret) + ); + + expect(apiCall).not.toHaveBeenCalledWith( + "agents.sessions.rename", + expect.anything() + ); + }); + it("routes a top-level agent_view DM message to the conversation-scoped thread when it is subscribed (openDM flow)", async () => { const adapter = createSlackAdapter({ agentView: true, @@ -5736,20 +5824,21 @@ describe("agent_view DM threading", () => { signingSecret: secret, logger: mockLogger, }); - mockClientMethod( - adapter, - "assistant.threads.setStatus", - vi.fn().mockResolvedValue({ ok: true }) - ); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); - await adapter.startTyping("slack:D1:1771.99", "Thinking..."); + await adapter.startTyping("slack:D1:1771.99", "Thinking...", { + initiatorUserId: "U1", + }); const client = getClient(adapter); - expect(client.assistant.threads.setStatus).toHaveBeenCalledWith( + expect(client.apiCall).toHaveBeenCalledWith( + "agents.sessions.setStatus", expect.objectContaining({ channel_id: "D1", + initiator_user_id: "U1", thread_ts: "1771.99", - status: "Thinking...", + status: "processing", }) ); }); @@ -7592,6 +7681,31 @@ describe("setAssistantStatus", () => { }) ); }); + + it("maps agent status text to session lifecycle states", async () => { + const adapter = createSlackAdapter({ + agentView: true, + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); + + await adapter.setAssistantStatus("D123", "1.2", "Working..."); + await adapter.setAssistantStatus("D123", "1.2", ""); + + expect(apiCall).toHaveBeenNthCalledWith( + 1, + "agents.sessions.setStatus", + expect.objectContaining({ status: "processing" }) + ); + expect(apiCall).toHaveBeenNthCalledWith( + 2, + "agents.sessions.setStatus", + expect.objectContaining({ status: "active" }) + ); + }); }); // ============================================================================ @@ -7630,6 +7744,28 @@ describe("setAssistantTitle", () => { }) ); }); + + it("renames an agent session under agentView", async () => { + const adapter = createSlackAdapter({ + agentView: true, + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); + + await adapter.setAssistantTitle("D123", "1.2", "Agent title"); + + expect(apiCall).toHaveBeenCalledWith( + "agents.sessions.rename", + expect.objectContaining({ + channel_id: "D123", + thread_ts: "1.2", + title: "Agent title", + }) + ); + }); }); // ============================================================================ @@ -7732,6 +7868,102 @@ describe("handleWebhook - assistant events", () => { ); }); + it("aborts and activates an agent session when the user stops it", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance({ state }); + const adapter = createSlackAdapter({ + agentView: true, + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + botUserId: "U_BOT", + }); + const apiCall = vi.fn().mockResolvedValue({ ok: true }); + mockClientMethod(adapter, "apiCall", apiCall); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "event_callback", + team_id: "T123", + event: { + type: "agent_session_stopped", + user: "U_USER", + channel: "D_AGENT", + thread_ts: "1234567890.111111", + streaming_message_ts: ["1234567891.222222", "1234567891.333333"], + event_ts: "1234567892.333333", + }, + }); + const tasks: Promise[] = []; + const response = await adapter.handleWebhook( + createWebhookRequest(body, secret), + { waitUntil: (task) => tasks.push(task) } + ); + await Promise.all(tasks); + + const threadId = "slack:D_AGENT:1234567890.111111"; + expect(response.status).toBe(200); + expect(chatInstance.abortTurn).toHaveBeenCalledWith(threadId); + expect(apiCall).toHaveBeenCalledWith( + "agents.sessions.setStatus", + expect.objectContaining({ + channel_id: "D_AGENT", + status: "active", + thread_ts: "1234567890.111111", + }) + ); + expect(chatInstance.processAgentSessionStopped).toHaveBeenCalledWith( + expect.objectContaining({ + streamingMessageTs: ["1234567891.222222", "1234567891.333333"], + threadId, + userId: "U_USER", + }), + expect.any(Object) + ); + expect(state.acquireLock).not.toHaveBeenCalled(); + }); + + it("dispatches agent session title changes", async () => { + const chatInstance = createMockChatInstance({ + state: createMockState(), + }); + const adapter = createSlackAdapter({ + agentView: true, + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + botUserId: "U_BOT", + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "event_callback", + team_id: "T123", + event: { + type: "agent_session_title_changed", + user: "U_USER", + channel: "D_AGENT", + thread_ts: "1234567890.111111", + title: "New title", + event_ts: "1234567892.333333", + team_id: "T123", + }, + }); + const response = await adapter.handleWebhook( + createWebhookRequest(body, secret) + ); + + expect(response.status).toBe(200); + expect(chatInstance.processAgentSessionTitleChanged).toHaveBeenCalledWith( + expect.objectContaining({ + previousTitle: undefined, + threadId: "slack:D_AGENT:1234567890.111111", + title: "New title", + }), + undefined + ); + }); + it("handles app_home_opened event", async () => { const state = createMockState(); const chatInstance = { @@ -9840,6 +10072,23 @@ describe("native streaming fallback", () => { } } + it("finishes agent streams with an active session status", async () => { + const { adapter } = createAdapter({ agentView: true }); + const append = vi.fn().mockResolvedValue({ ok: true }); + const stop = vi.fn().mockResolvedValue({ ts: "stream-ts" }); + mockClientMethod( + adapter, + "chatStream", + vi.fn().mockReturnValue({ append, stop, ts: undefined }) + ); + + await adapter.stream("slack:D123:1234567890.000000", textStream("hello")); + + expect(stop).toHaveBeenCalledWith( + expect.objectContaining({ session_status: "active" }) + ); + }); + it("returns null before consuming the stream when nativeStreaming is false", async () => { const { adapter } = createAdapter({ nativeStreaming: false }); const chatStream = vi.fn(); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 4dade3122..4e577d27d 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -20,6 +20,7 @@ import type { ActionEvent, Adapter, AdapterPostableMessage, + AgentSessionStatus, Attachment, ChannelInfo, ChannelVisibility, @@ -48,6 +49,7 @@ import type { StreamOptions, ThreadInfo, ThreadSummary, + TypingOptions, UserInfo, WebhookOptions, } from "chat"; @@ -192,6 +194,7 @@ import type { SlackBotToken, SlackFeedbackButtonsOptions, SlackInstallation, + SlackSessionTitle, SlackSuggestedPrompts, SlackSuggestedPromptsContext, } from "./types"; @@ -202,6 +205,8 @@ export type { SlackBotToken, SlackFeedbackButtonsOptions, SlackInstallation, + SlackSessionTitle, + SlackSessionTitleContext, SlackSuggestedPrompt, SlackSuggestedPrompts, SlackSuggestedPromptsContext, @@ -614,6 +619,26 @@ interface SlackAssistantContextChangedEvent { type: "assistant_thread_context_changed"; } +interface SlackAgentSessionStoppedEvent { + channel: string; + event_ts: string; + streaming_message_ts: string[]; + thread_ts: string; + type: "agent_session_stopped"; + user: string; +} + +interface SlackAgentSessionTitleChangedEvent { + channel: string; + event_ts: string; + previous_title?: string; + team_id: string; + thread_ts: string; + title: string; + type: "agent_session_title_changed"; + user: string; +} + /** Slack app_home_opened event payload */ interface SlackAppHomeOpenedEvent { channel: string; @@ -802,6 +827,7 @@ interface CachedChannel { export class SlackAdapter implements Adapter { readonly name = "slack"; readonly userName: string; + readonly supportsTurnCancellation: boolean; protected readonly _client: WebClient; protected readonly tokenClientCache = new Map(); @@ -834,6 +860,7 @@ export class SlackAdapter implements Adapter { // Socket mode support protected readonly appToken: string | undefined; protected readonly agentView: boolean; + protected readonly sessionTitle: SlackSessionTitle; protected readonly suggestedPrompts?: SlackSuggestedPrompts; protected readonly loadingMessages?: string[]; /** Normalized feedbackButtons config (`true` becomes `{}`). */ @@ -1014,6 +1041,8 @@ export class SlackAdapter implements Adapter { this.appToken = config.appToken; this.agentView = config.agentView ?? false; + this.supportsTurnCancellation = this.agentView; + this.sessionTitle = config.sessionTitle ?? this.agentView; this.suggestedPrompts = config.suggestedPrompts; this.loadingMessages = config.loadingMessages; this.nativeStreaming = config.nativeStreaming ?? true; @@ -1933,6 +1962,16 @@ export class SlackAdapter implements Adapter { event as SlackAssistantContextChangedEvent, options ); + } else if (event.type === "agent_session_stopped") { + this.handleAgentSessionStopped( + event as SlackAgentSessionStoppedEvent, + options + ); + } else if (event.type === "agent_session_title_changed") { + this.handleAgentSessionTitleChanged( + event as SlackAgentSessionTitleChangedEvent, + options + ); } else if (event.type === "app_context_changed") { this.handleAppContextChanged( event as SlackAppContextChangedEvent, @@ -2864,18 +2903,49 @@ export class SlackAdapter implements Adapter { { error: String(error), threadId } ); } - chat.processMessage( - this, - routedThreadId, - makeFactory(routedThreadId), - options - ); + try { + await chat.processMessage( + this, + routedThreadId, + makeFactory(routedThreadId), + options + ); + } catch (error) { + this.logger.warn("Agent view DM processing failed", { + error, + threadId: routedThreadId, + }); + } + await this.applyConfiguredSessionTitle(event); })(); options?.waitUntil?.(task); return; } - this.chat.processMessage(this, threadId, makeFactory(threadId), options); + const processTask = this.chat.processMessage( + this, + threadId, + makeFactory(threadId), + options + ); + if ( + this.agentView && + isDM && + !event.thread_ts && + !event.subtype && + event.user && + !event.bot_id + ) { + const titleTask = Promise.resolve(processTask) + .then(() => this.applyConfiguredSessionTitle(event)) + .catch((error) => { + this.logger.warn( + "Skipping Slack agent session title after message processing failed", + { error, threadId } + ); + }); + options?.waitUntil?.(titleTask); + } } protected handleMessageChanged( @@ -3244,6 +3314,85 @@ export class SlackAdapter implements Adapter { ); } + protected handleAgentSessionStopped( + event: SlackAgentSessionStoppedEvent, + options?: WebhookOptions + ): void { + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring agent_session_stopped" + ); + return; + } + + const threadId = this.encodeThreadId({ + channel: event.channel, + threadTs: event.thread_ts, + }); + const chat = this.chat; + const task = (async () => { + try { + await chat.abortTurn(threadId); + } catch (error) { + this.logger.warn("Failed to abort stopped Slack agent session", { + error, + threadId, + }); + } + + try { + await this.setSessionStatus(event.channel, event.thread_ts, "active"); + } catch (error) { + this.logger.warn("Failed to activate stopped Slack agent session", { + error, + threadId, + }); + } + + chat.processAgentSessionStopped( + { + adapter: this, + channelId: event.channel, + streamingMessageTs: event.streaming_message_ts, + threadId, + threadTs: event.thread_ts, + userId: event.user, + }, + options + ); + })(); + options?.waitUntil?.(task); + } + + protected handleAgentSessionTitleChanged( + event: SlackAgentSessionTitleChangedEvent, + options?: WebhookOptions + ): void { + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring agent_session_title_changed" + ); + return; + } + + const threadId = this.encodeThreadId({ + channel: event.channel, + threadTs: event.thread_ts, + }); + this.chat.processAgentSessionTitleChanged( + { + adapter: this, + channelId: event.channel, + previousTitle: event.previous_title, + threadId, + threadTs: event.thread_ts, + title: event.title, + userId: event.user, + }, + options + ); + } + /** * Handle app_home_opened events from Slack. * Fires when a user opens the bot's Home tab. @@ -3438,9 +3587,54 @@ export class SlackAdapter implements Adapter { } } + protected async applyConfiguredSessionTitle( + event: SlackEvent + ): Promise { + if ( + !( + this.agentView && + this.sessionTitle && + event.channel && + event.ts && + event.user && + event.text && + !event.bot_id && + !event.subtype && + !event.thread_ts + ) + ) { + return; + } + + try { + const context = { + channelId: event.channel, + text: event.text, + threadTs: event.ts, + userId: event.user, + }; + const resolved = + typeof this.sessionTitle === "function" + ? await this.sessionTitle(context) + : event.text.split("\n", 1)[0]?.trim(); + const title = resolved?.trim().slice(0, 80); + if (!title) { + return; + } + await this.renameAgentSession(event.channel, event.ts, title); + } catch (error) { + this.logger.warn("Failed to set Slack agent session title", { + channelId: event.channel, + error, + threadTs: event.ts, + }); + } + } + /** * Set status/thinking indicator for an assistant thread. - * Slack Assistants API: assistant.threads.setStatus + * Uses Agent Sessions when `agentView` is enabled and the legacy Assistants + * API otherwise. * * When `loadingMessages` is omitted, falls back to the adapter-level * `loadingMessages` config. @@ -3451,6 +3645,20 @@ export class SlackAdapter implements Adapter { status: string, loadingMessages?: string[] ): Promise { + if (this.agentView) { + if (loadingMessages?.length || status.trim()) { + this.logger.debug( + "Slack Agent Sessions use the standard Working status; custom loading text is ignored" + ); + } + await this.setSessionStatus( + channelId, + threadTs, + status.trim() ? "processing" : "active" + ); + return; + } + const effectiveLoadingMessages = loadingMessages ?? this.loadingMessages; await this._client.assistant.threads.setStatus( await this.withToken({ @@ -3465,14 +3673,56 @@ export class SlackAdapter implements Adapter { } /** - * Set title for an assistant thread (shown in History tab). - * Slack Assistants API: assistant.threads.setTitle + * Set a Slack Agent Session lifecycle state, creating the session if needed. + */ + async setSessionStatus( + channelId: string, + threadTs: string, + status: AgentSessionStatus, + options?: { initiatorUserId?: string; title?: string } + ): Promise { + await this._client.apiCall( + "agents.sessions.setStatus", + await this.withToken({ + channel_id: channelId, + thread_ts: threadTs, + status, + ...(options?.initiatorUserId + ? { initiator_user_id: options.initiatorUserId } + : {}), + ...(options?.title ? { title: options.title } : {}), + }) + ); + } + + protected async renameAgentSession( + channelId: string, + threadTs: string, + title: string + ): Promise { + await this._client.apiCall( + "agents.sessions.rename", + await this.withToken({ + channel_id: channelId, + thread_ts: threadTs, + title, + }) + ); + } + + /** + * Set title for an assistant thread or agent session. */ async setAssistantTitle( channelId: string, threadTs: string, title: string ): Promise { + if (this.agentView) { + await this.renameAgentSession(channelId, threadTs, title); + return; + } + await this._client.assistant.threads.setTitle( await this.withToken({ channel_id: channelId, @@ -4886,12 +5136,36 @@ export class SlackAdapter implements Adapter { * @param threadId - The thread to show the indicator in * @param status - Optional custom status message (e.g., "Searching documents...") */ - async startTyping(threadId: string, status?: string): Promise { + async startTyping( + threadId: string, + status?: string, + options?: TypingOptions + ): Promise { const { channel, threadTs } = this.decodeThreadId(threadId); if (!threadTs) { this.logger.debug("Slack: startTyping skipped - no thread context"); return; } + if (this.agentView) { + this.logger.debug("Slack API: agents.sessions.setStatus", { + channel, + threadTs, + status: "processing", + }); + try { + await this.setSessionStatus(channel, threadTs, "processing", { + initiatorUserId: options?.initiatorUserId, + }); + } catch (error) { + this.logger.warn("Slack API: agents.sessions.setStatus failed", { + channel, + threadTs, + error, + }); + } + return; + } + this.logger.debug("Slack API: assistant.threads.setStatus", { channel, threadTs, @@ -4918,6 +5192,28 @@ export class SlackAdapter implements Adapter { } } + async endTyping( + threadId: string, + status: AgentSessionStatus = "active" + ): Promise { + if (!this.agentView) { + return; + } + const { channel, threadTs } = this.decodeThreadId(threadId); + if (!threadTs) { + return; + } + try { + await this.setSessionStatus(channel, threadTs, status); + } catch (error) { + this.logger.warn("Slack API: agents.sessions.setStatus failed", { + channel, + threadTs, + error, + }); + } + } + /** * Stream a message using Slack's native streaming API. * @@ -5171,6 +5467,9 @@ export class SlackAdapter implements Adapter { }; for await (const chunk of textStream) { + if (options?.signal?.aborted) { + break; + } if (typeof chunk === "string") { renderer.push(chunk); await flushCommitted(); @@ -5197,6 +5496,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack: fallback stream complete", { messageId: fallback.message?.id, }); + await this.endTyping(threadId, options?.sessionStatus ?? "active"); return fallback.message; } @@ -5212,9 +5512,14 @@ export class SlackAdapter implements Adapter { try { result = await streamer.stop({ token, + ...(this.agentView + ? { session_status: options?.sessionStatus ?? "active" } + : {}), ...(stopBlocks.length > 0 ? { blocks: stopBlocks as ChatStopStreamArguments["blocks"] } : {}), + } as ChatStopStreamArguments & { + session_status?: AgentSessionStatus; }); } catch (error) { if (fallback.nativeRendered) { @@ -5229,6 +5534,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack: fallback stream complete", { messageId: fallback.message?.id, }); + await this.endTyping(threadId, options?.sessionStatus ?? "active"); return fallback.message; } const messageTs = (result.message?.ts ?? result.ts) as string; @@ -6318,6 +6624,7 @@ export function createSlackAdapter(config?: SlackAdapterConfig): SlackAdapter { loadingMessages: config?.loadingMessages, logger: config?.logger ?? new ConsoleLogger("info").child("slack"), nativeStreaming: config?.nativeStreaming, + sessionTitle: config?.sessionTitle, suggestedPrompts: config?.suggestedPrompts, socketForwardingSecret: config?.socketForwardingSecret ?? diff --git a/packages/adapter-slack/src/types.ts b/packages/adapter-slack/src/types.ts index ea69f60df..2c0cfa773 100644 --- a/packages/adapter-slack/src/types.ts +++ b/packages/adapter-slack/src/types.ts @@ -90,6 +90,25 @@ export type SlackSuggestedPrompts = | undefined | Promise); +/** Context passed to a dynamic agent-session title resolver. */ +export interface SlackSessionTitleContext { + channelId: string; + text: string; + threadTs: string; + userId: string; +} + +/** + * Automatic agent-session title configuration. `true` uses the first line of + * the root message, `false` disables automatic titles, and a resolver can + * provide a custom title or return null to skip it. + */ +export type SlackSessionTitle = + | boolean + | (( + context: SlackSessionTitleContext + ) => string | null | Promise); + export interface SlackAdapterConfig { /** * Enable Slack's Agent messaging experience (`agent_view` manifest mode). @@ -165,6 +184,12 @@ export interface SlackAdapterConfig { * the workspace rejects the first native call. */ nativeStreaming?: boolean; + /** + * Automatically title new agent sessions from their root user message. + * Defaults to true when `agentView` is enabled. Pass false to disable or a + * resolver to customize the title. + */ + sessionTitle?: SlackSessionTitle; /** Signing secret for webhook verification. Defaults to SLACK_SIGNING_SECRET env var. */ signingSecret?: string; /** Shared secret for authenticating forwarded socket mode events. Auto-detected from SLACK_SOCKET_FORWARDING_SECRET. Falls back to appToken if not set. */ diff --git a/packages/chat/src/agent-session.test.ts b/packages/chat/src/agent-session.test.ts new file mode 100644 index 000000000..4d7d27bdf --- /dev/null +++ b/packages/chat/src/agent-session.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { Chat } from "./chat"; +import { createMockAdapter, createMockState } from "./mock-adapter"; +import type { + AgentSessionStoppedEvent, + AgentSessionTitleChangedEvent, +} from "./types"; + +describe("agent session events", () => { + it("dispatches stop events to registered handlers", async () => { + const adapter = createMockAdapter("slack"); + const chat = new Chat({ + userName: "bot", + adapters: { slack: adapter }, + state: createMockState(), + logger: "error", + }); + const handler = vi.fn(); + chat.onAgentSessionStopped(handler); + const event: AgentSessionStoppedEvent = { + adapter, + channelId: "D1", + streamingMessageTs: ["2.3"], + threadId: "slack:D1:1.2", + threadTs: "1.2", + userId: "U1", + }; + let task: Promise | undefined; + + chat.processAgentSessionStopped(event, { + waitUntil: (promise) => { + task = promise; + }, + }); + await task; + + expect(handler).toHaveBeenCalledWith(event); + }); + + it("dispatches title changes to registered handlers", async () => { + const adapter = createMockAdapter("slack"); + const chat = new Chat({ + userName: "bot", + adapters: { slack: adapter }, + state: createMockState(), + logger: "error", + }); + const handler = vi.fn(); + chat.onAgentSessionTitleChanged(handler); + const event: AgentSessionTitleChangedEvent = { + adapter, + channelId: "D1", + previousTitle: "Old title", + threadId: "slack:D1:1.2", + threadTs: "1.2", + title: "New title", + userId: "U1", + }; + let task: Promise | undefined; + + chat.processAgentSessionTitleChanged(event, { + waitUntil: (promise) => { + task = promise; + }, + }); + await task; + + expect(handler).toHaveBeenCalledWith(event); + }); +}); diff --git a/packages/chat/src/chat.test.ts b/packages/chat/src/chat.test.ts index e04a26bb8..934721b8e 100644 --- a/packages/chat/src/chat.test.ts +++ b/packages/chat/src/chat.test.ts @@ -372,6 +372,67 @@ describe("Chat", () => { expect(resolved).toBe(true); }); + it("aborts an active thread signal from another Chat instance", async () => { + const sharedState = createMockState(); + const cancellableAdapter = { + ...mockAdapter, + supportsTurnCancellation: true, + }; + const processingChat = new Chat({ + userName: "testbot", + adapters: { slack: cancellableAdapter }, + state: sharedState, + logger: mockLogger, + }); + const stoppingChat = new Chat({ + userName: "testbot", + adapters: { + slack: { + ...createMockAdapter("slack"), + supportsTurnCancellation: true, + }, + }, + state: sharedState, + logger: mockLogger, + }); + const threadId = "slack:C123:agent-stop.1"; + let signal: AbortSignal | undefined; + let notifyStarted: () => void = () => { + throw new Error("notifyStarted not assigned"); + }; + const started = new Promise((resolve) => { + notifyStarted = resolve; + }); + + processingChat.onNewMention(async (thread) => { + signal = thread.signal; + notifyStarted(); + await new Promise((resolve) => { + if (thread.signal.aborted) { + resolve(); + return; + } + thread.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + }); + + const message = createTestMessage("agent-stop-message", "@testbot stop"); + message.isMention = true; + const processing = processingChat.processMessage( + cancellableAdapter, + threadId, + message + ); + await started; + + await stoppingChat.abortTurn(threadId); + await processing; + + expect(signal?.aborted).toBe(true); + }); + it("should dispatch message updates without normal message routing", async () => { const updateHandler = vi.fn().mockResolvedValue(undefined); const mentionHandler = vi.fn().mockResolvedValue(undefined); diff --git a/packages/chat/src/chat.ts b/packages/chat/src/chat.ts index 09437a518..0bd5caea6 100644 --- a/packages/chat/src/chat.ts +++ b/packages/chat/src/chat.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { decodeCallbackValue, postToCallbackUrl, @@ -21,6 +22,10 @@ import type { ActionEvent, ActionHandler, Adapter, + AgentSessionStoppedEvent, + AgentSessionStoppedHandler, + AgentSessionTitleChangedEvent, + AgentSessionTitleChangedHandler, AppContextChangedEvent, AppContextChangedHandler, AppHomeOpenedEvent, @@ -76,11 +81,28 @@ import type { import { ChatError, ConsoleLogger, LockError } from "./types"; const DEFAULT_LOCK_TTL_MS = 30_000; // 30 seconds +const ACTIVE_TURN_TTL_MS = 60 * 60 * 1000; // 1 hour +const ABORT_POLL_INTERVAL_MS = 250; /** Promise-based sleep for debounce timing. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +function sleepUntilAbort(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + }); +} const SLACK_USER_ID_REGEX = /^[UW][A-Z0-9]+$/; const DISCORD_SNOWFLAKE_REGEX = /^\d{17,19}$/; const LINEAR_UUID_REGEX = @@ -280,10 +302,18 @@ export class Chat< []; private readonly assistantContextChangedHandlers: AssistantContextChangedHandler[] = []; + private readonly agentSessionStoppedHandlers: AgentSessionStoppedHandler[] = + []; + private readonly agentSessionTitleChangedHandlers: AgentSessionTitleChangedHandler[] = + []; private readonly appHomeOpenedHandlers: AppHomeOpenedHandler[] = []; private readonly appContextChangedHandlers: AppContextChangedHandler[] = []; private readonly memberJoinedChannelHandlers: MemberJoinedChannelHandler[] = []; + private readonly activeTurnControllers = new Map< + string, + Map + >(); /** Initialization state */ private initPromise: Promise | null = null; @@ -899,6 +929,16 @@ export class Chat< this.logger.debug("Registered assistant context changed handler"); } + onAgentSessionStopped(handler: AgentSessionStoppedHandler): void { + this.agentSessionStoppedHandlers.push(handler); + this.logger.debug("Registered agent session stopped handler"); + } + + onAgentSessionTitleChanged(handler: AgentSessionTitleChangedHandler): void { + this.agentSessionTitleChangedHandlers.push(handler); + this.logger.debug("Registered agent session title changed handler"); + } + onAppHomeOpened(handler: AppHomeOpenedHandler): void { this.appHomeOpenedHandlers.push(handler); this.logger.debug("Registered app home opened handler"); @@ -1290,6 +1330,62 @@ export class Chat< } } + processAgentSessionStopped( + event: AgentSessionStoppedEvent, + options?: WebhookOptions + ): void { + const task = runInConversation(event.threadId, async () => { + for (const handler of this.agentSessionStoppedHandlers) { + await handler(event); + } + }).catch((err) => { + this.logger.error("Agent session stopped handler error", { + error: err, + threadId: event.threadId, + }); + }); + + options?.waitUntil?.(task); + } + + processAgentSessionTitleChanged( + event: AgentSessionTitleChangedEvent, + options?: WebhookOptions + ): void { + const task = runInConversation(event.threadId, async () => { + for (const handler of this.agentSessionTitleChangedHandlers) { + await handler(event); + } + }).catch((err) => { + this.logger.error("Agent session title changed handler error", { + error: err, + threadId: event.threadId, + }); + }); + + options?.waitUntil?.(task); + } + + async abortTurn(threadId: string): Promise { + const localControllers = this.activeTurnControllers.get(threadId); + if (localControllers) { + for (const controller of localControllers.values()) { + controller.abort(); + } + } + + const activeTurnId = await this._stateAdapter.get( + this.activeTurnKey(threadId) + ); + if (activeTurnId) { + await this._stateAdapter.set( + this.abortTurnKey(threadId), + activeTurnId, + ACTIVE_TURN_TTL_MS + ); + } + } + processAssistantContextChanged( event: AssistantContextChangedEvent, options?: WebhookOptions @@ -2699,6 +2795,123 @@ export class Chat< threadId: string, message: Message, context?: MessageContext + ): Promise { + const turnId = randomUUID(); + const controller = new AbortController(); + let controllers = this.activeTurnControllers.get(threadId); + if (!controllers) { + controllers = new Map(); + this.activeTurnControllers.set(threadId, controllers); + } + controllers.set(turnId, controller); + + if (adapter.supportsTurnCancellation) { + try { + await this._stateAdapter.set( + this.activeTurnKey(threadId), + turnId, + ACTIVE_TURN_TTL_MS + ); + } catch (error) { + this.logger.warn("Could not publish active turn for cancellation", { + error, + threadId, + }); + } + } + + const monitorStop = new AbortController(); + const monitor = adapter.supportsTurnCancellation + ? this.monitorTurnAbort(threadId, turnId, controller, monitorStop.signal) + : Promise.resolve(); + try { + await this.dispatchToHandlersWithSignal( + adapter, + threadId, + message, + controller.signal, + context + ); + } finally { + monitorStop.abort(); + await monitor; + controllers.delete(turnId); + if (controllers.size === 0) { + this.activeTurnControllers.delete(threadId); + } + if (adapter.supportsTurnCancellation) { + await this.clearTurnMarkers(threadId, turnId); + } + } + } + + private activeTurnKey(threadId: string): string { + return `active-turn:${threadId}`; + } + + private abortTurnKey(threadId: string): string { + return `abort-turn:${threadId}`; + } + + private async monitorTurnAbort( + threadId: string, + turnId: string, + controller: AbortController, + stopSignal: AbortSignal + ): Promise { + while (!(stopSignal.aborted || controller.signal.aborted)) { + try { + const abortedTurnId = await this._stateAdapter.get( + this.abortTurnKey(threadId) + ); + if (abortedTurnId === turnId) { + controller.abort(); + return; + } + } catch (error) { + this.logger.warn("Could not poll turn cancellation state", { + error, + threadId, + }); + return; + } + if (!stopSignal.aborted) { + await sleepUntilAbort(ABORT_POLL_INTERVAL_MS, stopSignal); + } + } + } + + private async clearTurnMarkers( + threadId: string, + turnId: string + ): Promise { + try { + const activeTurnId = await this._stateAdapter.get( + this.activeTurnKey(threadId) + ); + if (activeTurnId === turnId) { + await this._stateAdapter.delete(this.activeTurnKey(threadId)); + } + const abortedTurnId = await this._stateAdapter.get( + this.abortTurnKey(threadId) + ); + if (abortedTurnId === turnId) { + await this._stateAdapter.delete(this.abortTurnKey(threadId)); + } + } catch (error) { + this.logger.warn("Could not clear turn cancellation state", { + error, + threadId, + }); + } + } + + private async dispatchToHandlersWithSignal( + adapter: Adapter, + threadId: string, + message: Message, + signal: AbortSignal, + context?: MessageContext ): Promise { const hasMention = this.setMentionFlags(adapter, message, context); @@ -2715,7 +2928,8 @@ export class Chat< adapter, threadId, message, - isSubscribed + isSubscribed, + signal ); await this.resolveMessageIdentity(adapter, threadId, message); @@ -2849,7 +3063,8 @@ export class Chat< adapter: Adapter, threadId: string, initialMessage: Message | undefined, - isSubscribedContext = false + isSubscribedContext = false, + signal?: AbortSignal ): Thread { // Parse thread ID to get channel ID with adapter const channelId = adapter.channelIdFromThreadId(threadId); @@ -2871,6 +3086,7 @@ export class Chat< isDM, channelVisibility, currentMessage: initialMessage, + signal, logger: this.logger, streamingUpdateIntervalMs: this._streamingUpdateIntervalMs, fallbackStreamingPlaceholderText: this._fallbackStreamingPlaceholderText, diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index b0e14910d..6ae32d283 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -340,6 +340,11 @@ export type { ActionHandler, Adapter, AdapterPostableMessage, + AgentSessionStatus, + AgentSessionStoppedEvent, + AgentSessionStoppedHandler, + AgentSessionTitleChangedEvent, + AgentSessionTitleChangedHandler, AppContextCanvasEntity, AppContextChangedEvent, AppContextChangedHandler, @@ -445,6 +450,7 @@ export type { TranscriptRole, TranscriptsApi, TranscriptsConfig, + TypingOptions, UserInfo, WebhookOptions, WellKnownEmoji, diff --git a/packages/chat/src/streaming-plan.ts b/packages/chat/src/streaming-plan.ts index 0f212d6e7..af80c9ca1 100644 --- a/packages/chat/src/streaming-plan.ts +++ b/packages/chat/src/streaming-plan.ts @@ -3,7 +3,12 @@ import { type PostableObject, type PostableObjectContext, } from "./postable-object"; -import type { Adapter, StreamChunk, StreamEvent } from "./types"; +import type { + Adapter, + AgentSessionStatus, + StreamChunk, + StreamEvent, +} from "./types"; export interface StreamingPlanOptions { /** @@ -17,6 +22,11 @@ export interface StreamingPlanOptions { * - `"timeline"` - individual task cards shown inline with text (default) */ groupTasks?: "plan" | "timeline"; + /** + * Slack Agent Session state after streaming stops. Defaults to `"active"`. + * Use `"suspended"` when the agent needs user input or approval. + */ + sessionStatus?: AgentSessionStatus; /** * Minimum interval between updates in ms (default: 500). * Used by post+edit streaming paths. diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts index afff8af90..162756d43 100644 --- a/packages/chat/src/thread.test.ts +++ b/packages/chat/src/thread.test.ts @@ -750,6 +750,7 @@ describe("ThreadImpl", () => { const streamMsg = new StreamingPlan(textStream, { groupTasks: "plan", endWith: [{ type: "actions" }], + sessionStatus: "suspended", updateIntervalMs: 1000, }); await thread.post(streamMsg); @@ -760,6 +761,7 @@ describe("ThreadImpl", () => { expect.objectContaining({ taskDisplayMode: "plan", stopBlocks: [{ type: "actions" }], + sessionStatus: "suspended", updateIntervalMs: 1000, }) ); @@ -2359,6 +2361,32 @@ describe("ThreadImpl", () => { "thinking..." ); }); + + it("passes the initiating user and clears processing after posting", async () => { + const adapter = createMockAdapter(); + adapter.endTyping = vi.fn().mockResolvedValue(undefined); + const currentMessage = createTestMessage("msg-1", "Hello"); + const thread = new ThreadImpl({ + id: "slack:C123:1234.5678", + adapter, + channelId: "C123", + stateAdapter: createMockState(), + currentMessage, + }); + + await thread.startTyping(); + await thread.post("Done"); + + expect(adapter.startTyping).toHaveBeenCalledWith( + "slack:C123:1234.5678", + undefined, + { initiatorUserId: currentMessage.author.userId } + ); + expect(adapter.endTyping).toHaveBeenCalledWith( + "slack:C123:1234.5678", + "active" + ); + }); }); describe("markAsRead", () => { diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts index 54002c79f..45e9ec4db 100644 --- a/packages/chat/src/thread.ts +++ b/packages/chat/src/thread.ts @@ -29,6 +29,7 @@ import type { PostableMessage, PostableObject, PostEphemeralOptions, + RawMessage, ScheduledMessage, SentMessage, StateAdapter, @@ -66,6 +67,7 @@ interface ThreadImplConfigWithAdapter { isDM?: boolean; isSubscribedContext?: boolean; logger?: Logger; + signal?: AbortSignal; stateAdapter: StateAdapter; streamingUpdateIntervalMs?: number; threadHistory?: ThreadHistoryCache; @@ -86,6 +88,7 @@ interface ThreadImplConfigLazy { isDM?: boolean; isSubscribedContext?: boolean; logger?: Logger; + signal?: AbortSignal; streamingUpdateIntervalMs?: number; } @@ -111,6 +114,43 @@ function isAsyncIterable( ); } +const NEVER_ABORTED_SIGNAL = new AbortController().signal; + +async function* takeUntilAborted( + source: AsyncIterable, + signal: AbortSignal +): AsyncIterable { + const iterator = source[Symbol.asyncIterator](); + let onAbort: (() => void) | undefined; + try { + while (!signal.aborted) { + const aborted = new Promise>((resolve) => { + onAbort = () => + resolve({ done: true, value: undefined as unknown as T }); + signal.addEventListener("abort", onAbort, { once: true }); + }); + const next = iterator.next(); + const result = await Promise.race([next, aborted]); + if (onAbort) { + signal.removeEventListener("abort", onAbort); + } + if (result.done || signal.aborted) { + return; + } + yield result.value; + } + } finally { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + } + if (iterator.return) { + iterator.return().catch(() => { + // Cancellation is best-effort. Model APIs should also receive signal. + }); + } + } +} + export class ThreadImpl> implements Thread { @@ -118,6 +158,7 @@ export class ThreadImpl> readonly channelId: string; readonly isDM: boolean; readonly channelVisibility: ChannelVisibility; + readonly signal: AbortSignal; /** Direct adapter instance (if provided) */ private _adapter?: Adapter; @@ -138,12 +179,14 @@ export class ThreadImpl> /** Thread history cache (set only for adapters with persistThreadHistory) */ private readonly _threadHistory?: ThreadHistoryCache; private readonly _logger?: Logger; + private _typingStarted = false; constructor(config: ThreadImplConfig) { this.id = config.id; this.channelId = config.channelId; this.isDM = config.isDM ?? false; this.channelVisibility = config.channelVisibility ?? "unknown"; + this.signal = config.signal ?? NEVER_ABORTED_SIGNAL; this._isSubscribedContext = config.isSubscribedContext ?? false; this._currentMessage = config.currentMessage; this._logger = config.logger; @@ -421,6 +464,7 @@ export class ThreadImpl> options: { groupTasks?: "plan" | "timeline"; endWith?: unknown[]; + sessionStatus?: StreamOptions["sessionStatus"]; updateIntervalMs?: number; }; }; @@ -432,6 +476,9 @@ export class ThreadImpl> ? { taskDisplayMode: data.options.groupTasks } : {}), ...(data.options.endWith ? { stopBlocks: data.options.endWith } : {}), + ...(data.options.sessionStatus + ? { sessionStatus: data.options.sessionStatus } + : {}), }; await this.handleStream(data.stream, streamOptions); return message; @@ -460,7 +507,12 @@ export class ThreadImpl> postable = await this.processCallbackUrls(postable); - const rawMessage = await this.adapter.postMessage(this.id, postable); + let rawMessage: RawMessage; + try { + rawMessage = await this.adapter.postMessage(this.id, postable); + } finally { + await this.finishTyping(); + } // Create a SentMessage with edit/delete capabilities const result = this.createSentMessage( @@ -478,13 +530,17 @@ export class ThreadImpl> } private async handlePostableObject(obj: PostableObject): Promise { - await postPostableObject( - obj, - this.adapter, - this.id, - (threadId, message) => this.adapter.postMessage(threadId, message), - this._logger - ); + try { + await postPostableObject( + obj, + this.adapter, + this.id, + (threadId, message) => this.adapter.postMessage(threadId, message), + this._logger + ); + } finally { + await this.finishTyping(); + } } async postEphemeral( @@ -674,9 +730,10 @@ export class ThreadImpl> callerOptions?: StreamOptions ): Promise { // Normalize: handles plain strings, AI SDK fullStream events, and StreamChunk objects - const textStream = fromFullStream(rawStream); + const textStream = takeUntilAborted(fromFullStream(rawStream), this.signal); // Build streaming options from current message context + caller options const options: StreamOptions = { + signal: this.signal, updateIntervalMs: this._streamingUpdateIntervalMs, ...callerOptions, ...(this._fallbackStreamingPlaceholderText !== undefined @@ -722,8 +779,15 @@ export class ThreadImpl> }, }; - const raw = await this.adapter.stream(this.id, wrappedStream, options); + let raw: RawMessage | null; + try { + raw = await this.adapter.stream(this.id, wrappedStream, options); + } catch (error) { + await this.finishTyping(); + throw error; + } if (raw) { + this._typingStarted = false; const sent = this.createSentMessage( raw.id, { markdown: accumulated }, @@ -808,7 +872,24 @@ export class ThreadImpl> } async startTyping(status?: string): Promise { + const initiatorUserId = this._currentMessage?.author.userId; + if (initiatorUserId) { + await this.adapter.startTyping(this.id, status, { initiatorUserId }); + this._typingStarted = true; + return; + } await this.adapter.startTyping(this.id, status); + this._typingStarted = true; + } + + private async finishTyping( + status: StreamOptions["sessionStatus"] = "active" + ): Promise { + if (!this._typingStarted) { + return; + } + this._typingStarted = false; + await this.adapter.endTyping?.(this.id, status); } async markAsRead(message?: string | Message): Promise { @@ -850,6 +931,17 @@ export class ThreadImpl> private async fallbackStream( textStream: AsyncIterable, options?: StreamOptions + ): Promise { + try { + return await this.runFallbackStream(textStream, options); + } finally { + await this.finishTyping(options?.sessionStatus); + } + } + + private async runFallbackStream( + textStream: AsyncIterable, + options?: StreamOptions ): Promise { const intervalMs = options?.updateIntervalMs ?? this._streamingUpdateIntervalMs; diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts index d2a68801e..efc30b6e9 100644 --- a/packages/chat/src/types.ts +++ b/packages/chat/src/types.ts @@ -269,6 +269,15 @@ export interface Adapter { /** Encode platform-specific data into a thread ID string */ encodeThreadId(platformData: TThreadId): string; + /** + * Clear a typing/processing indicator after a reply finishes. + * + * Optional because most platforms clear typing indicators automatically. + * Agent-session platforms can implement this to transition the session back + * to an active state. + */ + endTyping?(threadId: string, status?: AgentSessionStatus): Promise; + /** * Fetch channel info/metadata. */ @@ -547,7 +556,11 @@ export interface Adapter { ): Promise>; /** Show typing indicator */ - startTyping(threadId: string, status?: string): Promise; + startTyping( + threadId: string, + status?: string, + options?: TypingOptions + ): Promise; /** * Stream a message using platform-native streaming APIs. @@ -571,6 +584,8 @@ export interface Adapter { textStream: AsyncIterable, options?: StreamOptions ): Promise | null>; + /** Whether active turns should be published for cross-process cancellation. */ + readonly supportsTurnCancellation?: boolean; /** Bot username (can override global userName) */ readonly userName: string; } @@ -618,6 +633,10 @@ export interface StreamOptions { recipientTeamId?: string; /** Slack: The user ID to stream to (for AI assistant context) */ recipientUserId?: string; + /** Slack: Agent session state after the stream stops. Defaults to "active". */ + sessionStatus?: AgentSessionStatus; + /** Stop consuming the stream when this signal is aborted. */ + signal?: AbortSignal; /** Block Kit elements to attach when stopping the stream (Slack only, via chat.stopStream) */ stopBlocks?: unknown[]; /** @@ -630,8 +649,27 @@ export interface StreamOptions { updateIntervalMs?: number; } +/** Lifecycle states supported by Slack Agent Sessions. */ +export type AgentSessionStatus = + | "active" + | "closed" + | "processing" + | "suspended"; + +/** Context supplied when an adapter starts a typing/processing indicator. */ +export interface TypingOptions { + /** User who initiated the current turn, when known. */ + initiatorUserId?: string; +} + /** Internal interface for Chat instance passed to adapters */ export interface ChatInstance { + /** + * Abort active work for a conversation, including work running in another + * process that shares the configured state adapter. + */ + abortTurn(threadId: string): Promise; + /** Get the configured logger, optionally with a child prefix */ getLogger(prefix?: string): Logger; @@ -659,6 +697,16 @@ export interface ChatInstance { options: WebhookOptions | undefined ): Promise; + processAgentSessionStopped( + event: AgentSessionStoppedEvent, + options?: WebhookOptions + ): void; + + processAgentSessionTitleChanged( + event: AgentSessionTitleChangedEvent, + options?: WebhookOptions + ): void; + processAppContextChanged( event: AppContextChangedEvent, options?: WebhookOptions @@ -1358,6 +1406,14 @@ export interface Thread, TRawMessage = unknown> | ChatElement ): Promise>; + /** + * Aborted when the platform or application stops the active turn. + * + * Pass this to AI/model APIs so cancellation stops upstream generation, not + * only message delivery. + */ + readonly signal: AbortSignal; + /** * Show typing indicator in the thread. * @@ -2517,6 +2573,33 @@ export type SlashCommandHandler> = ( // Assistant Events (Slack Assistants API / AI Apps) // ============================================================================= +export interface AgentSessionStoppedEvent { + adapter: Adapter; + channelId: string; + streamingMessageTs: string[]; + threadId: string; + threadTs: string; + userId: string; +} + +export type AgentSessionStoppedHandler = ( + event: AgentSessionStoppedEvent +) => void | Promise; + +export interface AgentSessionTitleChangedEvent { + adapter: Adapter; + channelId: string; + previousTitle?: string; + threadId: string; + threadTs: string; + title: string; + userId: string; +} + +export type AgentSessionTitleChangedHandler = ( + event: AgentSessionTitleChangedEvent +) => void | Promise; + export interface AssistantThreadStartedEvent { adapter: Adapter; channelId: string; diff --git a/packages/tests/src/factories.ts b/packages/tests/src/factories.ts index d371ab1e8..ef5f6befe 100644 --- a/packages/tests/src/factories.ts +++ b/packages/tests/src/factories.ts @@ -277,6 +277,7 @@ export function createMockChatInstance( const userName = options.userName ?? "test-bot"; const base = { + abortTurn: vi.fn().mockResolvedValue(undefined), processMessage: vi.fn(), processMessageUpdated: vi.fn(), processMessageDeleted: vi.fn(), @@ -289,6 +290,8 @@ export function createMockChatInstance( processSlashCommand: vi.fn(), processMemberJoinedChannel: vi.fn(), processAppHomeOpened: vi.fn(), + processAgentSessionStopped: vi.fn(), + processAgentSessionTitleChanged: vi.fn(), processAssistantThreadStarted: vi.fn(), processAssistantContextChanged: vi.fn(), processAppContextChanged: vi.fn(),