diff --git a/docs/slack-agents-surface.md b/docs/slack-agents-surface.md new file mode 100644 index 00000000..e7aa252d --- /dev/null +++ b/docs/slack-agents-surface.md @@ -0,0 +1,322 @@ +# Slack Agents Interaction Surface + +**Branch:** `feat/slack-agents-surface` +**Status:** Implementation in progress +**Author:** Digby +**Date:** 2026-06-17 +**Decisions locked:** 2026-06-17 (by Tom) + +--- + +## Summary + +Implement Slack's first-class [Agents & AI Apps](https://docs.slack.dev/ai/agent-entry-and-interaction) features in pi-digby. Full cut-over — all phases ship together on this branch, no backward compat shim needed. + +The three changes land as one: +1. **`setStatus`** — native "_Digby is thinking..._" loading indicator in thread/DM header +2. **Block Kit task cards** — structured, visually-scannable tool step tracking +3. **`setSuggestedPrompts` + `setTitle`** — context-aware prompts and auto-titled threads (scope assumed present) + +--- + +## Decisions (locked) + +| # | Question | Decision | +|---|---|---| +| 1 | Block Kit update rate limits | Fine as-is, no debounce needed | +| 2 | Fresh post vs update-in-place | _Never_ post fresh. Always: setStatus → task card (update) → final answer (update). Everywhere, including top-level channel messages. | +| 3 | Backward compat on `MessageTransport` interface | Full cut-over. No compat shim. Delete old path. | +| 4 | Phased rollout | Ship all phases at once. Work carefully on branch, test, then cut over. | + +--- + +## New interaction flow (everywhere — DMs, threads, channels) + +``` +1. Message arrives +2. setStatus("is thinking...", loadingMessages) ← API call, no message posted yet +3. Post Block Kit task card (empty / "starting") ← first message post +4. For each tool call: + stepStart → update task card (new step highlighted as in-progress) + stepEnd → update task card (step marked done with duration) +5. emitResponse(text) → update task card: collapse steps to summary + show response text +6. resolve() → final update with cost footer +``` + +The Slack notification fires on step 3 (first post). `setStatus` shows the animated indicator immediately at step 2. `setStatus` auto-clears when the message is posted at step 3. + +There is never a second "response" message. The task card message IS the response message — it evolves in place. + +--- + +## API reference + +### `assistant.threads.setStatus` +``` +POST https://slack.com/api/assistant.threads.setStatus +Scope: chat:write (we have this) + +{ + "channel_id": "C123...", + "thread_ts": "1234567890.123456", // required — use replyThreadTs or the message ts + "status": "is thinking...", + "loading_messages": [ // optional, up to 10, Slack rotates + "is reading the context...", + "is thinking...", + "is working on it...", + "is almost done..." + ] +} +``` +- Auto-clears when any message is posted in the thread +- Displays as "_AppName_ is thinking..." in the thread header +- For top-level channel messages: call with `thread_ts = the message ts we're about to post`... actually `setStatus` requires an existing thread. **See implementation note below.** + +### `assistant.threads.setSuggestedPrompts` +``` +POST https://slack.com/api/assistant.threads.setSuggestedPrompts +Scope: assistant:write (assumed present) + +{ + "channel_id": "D123...", + "thread_ts": "...", + "title": "What can I help with?", + "prompts": [ + { "title": "Linear cycle report", "message": "Give me a summary of the current Linear cycle" }, + { "title": "Check recent errors", "message": "Any new errors in the last 24h?" }, + { "title": "Draft a ticket", "message": "Create a Linear ticket for: " } + ] +} +``` + +### `assistant.threads.setTitle` +``` +POST https://slack.com/api/assistant.threads.setTitle +Scope: assistant:write (assumed present) + +{ + "channel_id": "D123...", + "thread_ts": "...", + "title": "Linear cycle report - June 17" // max ~50 chars +} +``` + +--- + +## Implementation note: `setStatus` requires `thread_ts` + +`setStatus` needs a `thread_ts`. For threaded/DM responses this is `replyThreadTs`. For top-level channel posts we don't have a `thread_ts` until we post the message. + +**Resolution:** For top-level channel messages, skip `setStatus` and post the Block Kit task card immediately. `setStatus` is called only when `replyThreadTs` is set. The Block Kit card still appears either way — it's just without the animated header indicator for top-level posts. + +--- + +## Block Kit task card design + +### While running + +```json +{ + "text": "Digby is working on it...", + "blocks": [ + { + "type": "section", + "text": { "type": "mrkdwn", "text": "🤔 *Working on it...*" } + }, + { + "type": "context", + "elements": [ + { "type": "mrkdwn", "text": "✓ `read` /data/MEMORY.md _0.3s_" }, + { "type": "mrkdwn", "text": "✓ `bash` git log --oneline _0.8s_" }, + { "type": "mrkdwn", "text": "*→ `bash` checking Linear...*" } + ] + }, + { + "type": "context", + "elements": [{ "type": "mrkdwn", "text": "_3 steps · streaming_" }] + } + ] +} +``` + +Rules: +- Completed steps: `✓ \`toolname\` label _Xs_` +- Current step: `*→ \`toolname\` label*` (bold = in progress) +- Error step: `✗ \`toolname\` label _error_` +- Max 10 steps visible; if more, show count: `... and 4 more` +- Context elements max 10 per block — split into multiple context blocks if needed + +### After completion (response folded in) + +```json +{ + "text": "", + "blocks": [ + { + "type": "section", + "text": { "type": "mrkdwn", "text": "" } + }, + { + "type": "context", + "elements": [ + { "type": "mrkdwn", "text": "✓ read, bash (×2), linear _5 steps · $0.04_" } + ] + } + ] +} +``` + +On resolve: collapse all step lines into a single summary context line. Response text goes in the top section block. + +### Overflow (response > MAX_MESSAGE_LENGTH) + +Same fallback as today: upload as file attachment, update message to `_Response too long — replying as a file attachment._` + +--- + +## Code changes + +### `src/slack/client.ts` + +Add method: +```ts +async setThreadStatus( + channel: string, + threadTs: string, + status: string, + loadingMessages?: string[] +): Promise +``` + +Update `postMessage` and `updateMessage` to accept blocks: +```ts +type MessagePayload = string | { text: string; blocks: KnownBlock[] }; + +async postMessage(channel: string, payload: MessagePayload, threadTs?: string): Promise +async updateMessage(channel: string, ts: string, payload: MessagePayload): Promise +``` + +Import `KnownBlock` from `@slack/web-api`. + +### `src/surface/types.ts` + +Update `MessageTransport` interface to match new `postMessage`/`updateMessage` signatures. Add `setThreadStatus`. + +### `src/surface/slack.ts` + +Replace the `emitProgress` text-accumulation model with a `TaskCard`: + +```ts +class TaskCard { + private steps: Array<{ + toolName: string; + label: string; + state: "running" | "done" | "error"; + durationMs?: number; + toolCallId: string; + }> = []; + + stepStart(toolCallId: string, toolName: string, label: string): void + stepEnd(toolCallId: string, durationMs: number, isError: boolean): void + toRunningBlocks(stats: RunStats): KnownBlock[] // during run + toResolvedBlocks(responseText: string, stats: RunStats): KnownBlock[] // on resolve +} +``` + +`SlackSurface` changes: +- Constructor: `taskCard = new TaskCard()` +- `emitThinking()`: + - If `replyThreadTs`: call `setThreadStatus(replyThreadTs, "is thinking...", loadingMessages)` + - Post Block Kit task card (empty state: `"🤔 Working on it..."`, no steps yet) +- `emitProgress()`: *removed* (replaced by task card updates) +- `emitToolStart(toolCallId, toolName, label)`: new method — `taskCard.stepStart(...)`, update card +- `emitToolEnd(toolCallId, durationMs, isError)`: new method — `taskCard.stepEnd(...)`, update card +- `emitResponse(text)`: store response text, update card to resolved state (folds response + step summary) +- `resolve()`: final update (streaming=false, cost computed) — if response already shown, just adds cost to footer + +The `AgentSurface` interface needs `emitToolStart`/`emitToolEnd` or the event handler (`agent/events.ts`) calls the new methods directly. + +### `src/agent/events.ts` + +In `tool_execution_start` handler: call `ctx.emitToolStart(toolCallId, toolName, label)` instead of `ctx.emitProgress(...)`. +In `tool_execution_end` handler: call `ctx.emitToolEnd(toolCallId, durationMs, isError)` instead of any progress update. +In `message_end` handler: call `ctx.emitResponse(text)` as today. + +### `src/surface/types.ts` (AgentSurface interface) + +```ts +export interface AgentSurface { + emitThinking(): void; + emitToolStart(toolCallId: string, toolName: string, label: string): void; // new + emitToolEnd(toolCallId: string, durationMs: number, isError: boolean): void; // new + emitResponse(text: string): void; + emitDetail(text: string): void; + emitReaction(emoji: string, triggerTs: string): void; + emitFile(filePath: string, title?: string): void; + resolve(): void; + reject(error: string): void; + suppress(): void; + flush(): Promise; + dispose(): void; + readonly finalMessageTs: string | null; + readonly finalText: string; + readonly wasDeleted: boolean; +} +``` + +Remove `emitProgress` from the interface (it was internal detail). + +### `src/surface/linear.ts` + +Update to implement new `AgentSurface` interface — `emitToolStart`/`emitToolEnd` can be no-ops or thin wrappers. + +### `src/slack/router.ts` (Phase 3) + +Add handler for `assistant_thread_started` event (fires when a DM or Agent Container thread opens): +```ts +client.onAssistantThreadStarted(async (event) => { + await client.setSuggestedPrompts(event.channel, event.threadTs, suggestedPrompts(event)); +}); +``` + +After first user message response, call `setTitle` with a short summary of the user's message. + +--- + +## Suggested prompts (Phase 3) + +Default set (can be made context-aware later): +```ts +const DEFAULT_PROMPTS = [ + { title: "Linear cycle report", message: "Give me a summary of the current Linear cycle" }, + { title: "Recent errors", message: "Any new errors in #errors in the last 24h?" }, + { title: "Morning digest", message: "What's new since yesterday?" }, + { title: "Draft a ticket", message: "Create a Linear ticket for: " }, +]; +``` + +--- + +## Testing checklist + +- [ ] DM conversation: setStatus shows, task card appears, steps accumulate, response folds in with cost +- [ ] Thread reply (bot mentioned): same flow +- [ ] Top-level channel message: no setStatus, task card appears immediately, steps work +- [ ] Long response: file fallback still works +- [ ] `[SILENT]` / suppress: card deleted cleanly +- [ ] Error mid-run: reject() shows error in card +- [ ] Stop command: card deleted, "Stopped" posted +- [ ] Linear surface: still works after interface change +- [ ] Suggested prompts: appear on DM open (Phase 3) +- [ ] Thread title: set after first response (Phase 3) + +--- + +## References + +- [Slack: Interaction surfaces and entry points](https://docs.slack.dev/ai/agent-entry-and-interaction/) +- [API: assistant.threads.setStatus](https://api.slack.com/methods/assistant.threads.setStatus) +- [API: assistant.threads.setSuggestedPrompts](https://api.slack.com/methods/assistant.threads.setSuggestedPrompts) +- [API: assistant.threads.setTitle](https://api.slack.com/methods/assistant.threads.setTitle) +- [Block Kit reference](https://api.slack.com/block-kit) +- Research thread: [#digby-testing 2026-06-17](https://brainwavesio.slack.com/archives/C0AB3CQSSSZ/p1781659818854589) diff --git a/src/agent/events.ts b/src/agent/events.ts index c8e82627..4a75787c 100644 --- a/src/agent/events.ts +++ b/src/agent/events.ts @@ -121,7 +121,7 @@ export function createEventHandler( }); log.toolStart(channelId, e.toolName, label); - ctx.emitProgress(`*\u2192 ${label}*`); + ctx.emitToolStart(e.toolCallId, e.toolName, label); } else if (event.type === "tool_execution_end") { const e = event as any; const resultStr = extractToolResultText(e.result); @@ -133,6 +133,8 @@ export function createEventHandler( log.toolEnd(channelId, e.toolName, durationMs, e.isError, resultStr); + ctx.emitToolEnd(e.toolCallId, durationMs, e.isError); + // Post detailed args + result to debug thread if (debugThreading) { const label = pending?.args ? ((pending.args as Record).label as string) : undefined; @@ -147,10 +149,6 @@ export function createEventHandler( threadMessage += `**Result:**\n\`\`\`\n${truncate(resultStr, 3000)}\n\`\`\``; ctx.emitDetail(threadMessage); } - - if (e.isError) { - ctx.emitProgress(`*Error: ${truncate(resultStr, 200)}*`); - } } else if (event.type === "message_end") { const e = event as any; if (e.message?.role === "assistant") { @@ -200,7 +198,6 @@ export function createEventHandler( } } else if (event.type === "compaction_start") { log.info(`[${channelId}] Compaction started (reason: ${event.reason})`); - ctx.emitProgress("*Compacting context...*"); } else if (event.type === "compaction_end") { if (event.result) { log.info(`[${channelId}] Compaction complete: ${event.result.tokensBefore} tokens compacted`); @@ -209,11 +206,9 @@ export function createEventHandler( } } else if (event.type === "auto_retry_start") { log.warn(`[${channelId}] Retrying (${event.attempt}/${event.maxAttempts}): ${event.errorMessage}`); - ctx.emitProgress(`*Retrying (${event.attempt}/${event.maxAttempts})...*`); } else if (event.type === "auto_retry_end") { if (!event.success) { log.warn(`[${channelId}] Retries exhausted: ${event.finalError}`); - ctx.emitProgress("*Retries exhausted*"); } } }; diff --git a/src/main.ts b/src/main.ts index d923f460..72aab9aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -193,7 +193,16 @@ async function runSlackEvent( const stats = createRunStats(); // Create surface — guaranteed to resolve via finally - const ctx = new SlackSurface(client, event.channel, stats, conversation.replyThreadTs); + // setTitle callback: only for DM/thread contexts (replyThreadTs required by Slack) + const titleTs = conversation.replyThreadTs; + const onFirstResponse = titleTs + ? () => { + client.setTitle(event.channel, titleTs, event.text.slice(0, 50)).catch((err) => { + log.warn("[main] setTitle error", err instanceof Error ? err.message : String(err)); + }); + } + : undefined; + const ctx = new SlackSurface(client, event.channel, stats, conversation.replyThreadTs, onFirstResponse); try { const userName = diff --git a/src/slack/client.ts b/src/slack/client.ts index 0b26a2c9..8806f638 100644 --- a/src/slack/client.ts +++ b/src/slack/client.ts @@ -3,6 +3,7 @@ import { WebClient } from "@slack/web-api"; import { readFileSync } from "fs"; import { basename } from "path"; import * as log from "../log.js"; +import type { MessagePayload } from "../surface/types.js"; import type { SlackChannel, SlackUser } from "./types.js"; // ============================================================================ @@ -104,13 +105,21 @@ export class SlackClient { }); } + onAssistantThreadStarted(handler: (event: Record) => void): void { + this.socket.on("assistant_thread_started", ({ event, ack }) => { + ack(); + handler(event); + }); + } + // ========================================================================== // Slack API (all with retry) // ========================================================================== - async postMessage(channel: string, text: string, threadTs?: string): Promise { + async postMessage(channel: string, payload: MessagePayload, threadTs?: string): Promise { + const base = typeof payload === "string" ? { text: payload } : { text: payload.text, blocks: payload.blocks }; const result = (await withRetry( - () => this.web.chat.postMessage({ channel, text, ...(threadTs && { thread_ts: threadTs }) }), + () => this.web.chat.postMessage({ channel, ...base, ...(threadTs && { thread_ts: threadTs }) }), "postMessage", )) as { ts?: string }; const ts = result.ts as string; @@ -121,8 +130,52 @@ export class SlackClient { return ts; } - async updateMessage(channel: string, ts: string, text: string): Promise { - await withRetry(() => this.web.chat.update({ channel, ts, text }), "updateMessage"); + async updateMessage(channel: string, ts: string, payload: MessagePayload): Promise { + const base = typeof payload === "string" ? { text: payload } : { text: payload.text, blocks: payload.blocks }; + await withRetry(() => this.web.chat.update({ channel, ts, ...base }), "updateMessage"); + } + + async setThreadStatus(channel: string, threadTs: string, status: string, loadingMessages?: string[]): Promise { + await withRetry( + () => + (this.web as any).apiCall("assistant.threads.setStatus", { + channel_id: channel, + thread_ts: threadTs, + status, + ...(loadingMessages && { loading_messages: loadingMessages }), + }), + "setThreadStatus", + ); + } + + async setSuggestedPrompts( + channel: string, + threadTs: string, + title: string, + prompts: Array<{ title: string; message: string }>, + ): Promise { + await withRetry( + () => + (this.web as any).apiCall("assistant.threads.setSuggestedPrompts", { + channel_id: channel, + thread_ts: threadTs, + title, + prompts, + }), + "setSuggestedPrompts", + ); + } + + async setTitle(channel: string, threadTs: string, title: string): Promise { + await withRetry( + () => + (this.web as any).apiCall("assistant.threads.setTitle", { + channel_id: channel, + thread_ts: threadTs, + title, + }), + "setTitle", + ); } async deleteMessage(channel: string, ts: string): Promise { diff --git a/src/slack/router.ts b/src/slack/router.ts index 65f2f3bb..475510a2 100644 --- a/src/slack/router.ts +++ b/src/slack/router.ts @@ -4,6 +4,13 @@ import * as log from "../log.js"; import type { SlackClient } from "./client.js"; import type { SlackEvent } from "./types.js"; +const DEFAULT_SUGGESTED_PROMPTS = [ + { title: "Linear cycle report", message: "Give me a summary of the current Linear cycle" }, + { title: "Recent errors", message: "Any new errors in #errors in the last 24h?" }, + { title: "Morning digest", message: "What's new since yesterday?" }, + { title: "Draft a ticket", message: "Create a Linear ticket for: " }, +]; + export interface RouterHandler { /** Check if this event's conversation lane is currently busy (SYNC) */ isBusy(event: SlackEvent): boolean; @@ -41,6 +48,23 @@ export function setupRouter(client: SlackClient, handler: RouterHandler, startup // Key: "channel:thread_ts". In-memory only; repopulated on restart via first @mention. const mentionedThreads = new QuickLRU({ maxSize: 500 }); + // ===== Suggested prompts on thread open ===== + client.onAssistantThreadStarted((event) => { + const e = event as { assistant_thread?: { channel_id?: string; thread_ts?: string } }; + const channel = e.assistant_thread?.channel_id; + const threadTs = e.assistant_thread?.thread_ts; + if (!channel || !threadTs) return; + + client.setSuggestedPrompts(channel, threadTs, "What can I help with?", DEFAULT_SUGGESTED_PROMPTS).catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("missing_scope")) { + log.info("setSuggestedPrompts: missing_scope (assistant:write scope not yet provisioned)"); + } else { + log.warn("Failed to set suggested prompts", msg); + } + }); + }); + // ===== Channel @mentions ===== client.onAppMention((event) => { const e = event as { diff --git a/src/surface/linear.ts b/src/surface/linear.ts index 4be0f1f0..c695ffca 100644 --- a/src/surface/linear.ts +++ b/src/surface/linear.ts @@ -34,12 +34,12 @@ export class LinearSurface implements AgentSurface { this.enqueue(() => this.client.emitThought(this.sessionId, "Picking up this issue...")); } - emitProgress(text: string): void { - // Persistent: progress lines are the headline timeline of the session - // (tool starts, retries, compaction, errors). Each one stacks rather - // than replacing the previous, so the user can scroll back through - // what the agent actually did. - this.enqueue(() => this.client.emitThought(this.sessionId, text, false)); + emitToolStart(_toolCallId: string, _toolName: string, _label: string): void { + // no-op: Linear surface doesn't show per-tool progress inline + } + + emitToolEnd(_toolCallId: string, _durationMs: number, _isError: boolean): void { + // no-op: Linear surface doesn't show per-tool progress inline } emitResponse(text: string): void { diff --git a/src/surface/slack.ts b/src/surface/slack.ts index b37ed121..d42d0eb6 100644 --- a/src/surface/slack.ts +++ b/src/surface/slack.ts @@ -1,10 +1,15 @@ +import type { KnownBlock } from "@slack/web-api"; import type { RunStats } from "../channel/run-stats.js"; import * as log from "../log.js"; -import type { AgentSurface } from "./types.js"; -import { THINKING_PLACEHOLDER } from "./types.js"; +import type { AgentSurface, MessagePayload, MessageTransport } from "./types.js"; -const MAX_MESSAGE_LENGTH = 35000; +export type { MessageTransport }; + +const MAX_SECTION_TEXT = 3000; const MAX_THREAD_MESSAGE_LENGTH = 20000; +const MAX_VISIBLE_STEPS = 10; + +const LOADING_MESSAGES = ["is reading the context...", "is thinking...", "is working on it...", "is almost done..."]; /** * Convert standard markdown to Slack mrkdwn: @@ -17,24 +22,138 @@ function mdToMrkdwn(text: string): string { .replace(/\*\*([^*]+)\*\*/g, "*$1*"); // then collapse ** to * } -export interface MessageTransport { - postMessage(channel: string, text: string, threadTs?: string): Promise; - updateMessage(channel: string, ts: string, text: string): Promise; - deleteMessage(channel: string, ts: string): Promise; - addReaction(channel: string, ts: string, emoji: string): Promise; - uploadFile(channel: string, filePath: string, title?: string, threadTs?: string): Promise; - uploadContent(channel: string, content: string, filename: string, title?: string, threadTs?: string): Promise; +function truncateText(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen)}\n_(truncated)_`; } +// ============================================================================= +// TaskCard — tracks tool steps and renders Block Kit blocks +// ============================================================================= + +interface Step { + toolCallId: string; + toolName: string; + label: string; + state: "running" | "done" | "error"; + durationMs?: number; +} + +function formatStep(step: Step): string { + if (step.state === "done") { + const dur = step.durationMs !== undefined ? (step.durationMs / 1000).toFixed(1) : "?"; + return `✓ \`${step.toolName}\` ${step.label} _${dur}s_`; + } + if (step.state === "error") { + return `✗ \`${step.toolName}\` ${step.label} _error_`; + } + // running + return `*→ \`${step.toolName}\` ${step.label}*`; +} + +class TaskCard { + private steps: Step[] = []; + + stepStart(toolCallId: string, toolName: string, label: string): void { + this.steps.push({ toolCallId, toolName, label, state: "running" }); + } + + stepEnd(toolCallId: string, durationMs: number, isError: boolean): void { + const step = this.steps.find((s) => s.toolCallId === toolCallId); + if (step) { + step.state = isError ? "error" : "done"; + step.durationMs = durationMs; + } + } + + toRunningBlocks(stats: RunStats, isStreaming: boolean): KnownBlock[] { + const total = this.steps.length; + const hidden = Math.max(0, total - MAX_VISIBLE_STEPS); + const visible = this.steps.slice(hidden); + + const elements: Array<{ type: "mrkdwn"; text: string }> = visible.map((step) => ({ + type: "mrkdwn", + text: formatStep(step), + })); + + if (hidden > 0) { + elements.unshift({ type: "mrkdwn", text: `_... and ${hidden} more_` }); + } + + const cost = isStreaming ? "streaming" : `$${stats.totalCost.toFixed(2)}`; + const footerText = `_${stats.stepCount} steps · ${cost}_`; + + const blocks: KnownBlock[] = [{ type: "section", text: { type: "mrkdwn", text: "🤔 *Working on it...*" } }]; + + // Context block holds max 10 elements — split if needed + for (let i = 0; i < elements.length; i += 10) { + blocks.push({ type: "context", elements: elements.slice(i, i + 10) }); + } + + blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: footerText }] }); + + return blocks; + } + + toResolvedBlocks(responseText: string, stats: RunStats, isStreaming: boolean): KnownBlock[] { + const TRUNC_NOTE = "\n_... (truncated — full response in thread)_"; + const sectionText = + responseText.length > MAX_SECTION_TEXT + ? responseText.slice(0, MAX_SECTION_TEXT - TRUNC_NOTE.length) + TRUNC_NOTE + : responseText; + + // Collapse steps to a summary: "read, bash (×2), linear" + const counts = new Map(); + for (const step of this.steps) { + counts.set(step.toolName, (counts.get(step.toolName) ?? 0) + 1); + } + const parts: string[] = []; + for (const [name, count] of counts) { + parts.push(count > 1 ? `${name} (×${count})` : name); + } + const toolSummary = parts.length > 0 ? parts.join(", ") : "no tools"; + + const cost = isStreaming ? "streaming" : stats.totalCost > 0 ? `$${stats.totalCost.toFixed(2)}` : "—"; + const summary = `✓ ${toolSummary} _${stats.stepCount} steps · ${cost}_`; + + return [ + { type: "section", text: { type: "mrkdwn", text: sectionText } }, + { type: "context", elements: [{ type: "mrkdwn", text: summary }] }, + ]; + } + + toErrorBlocks(errorText: string, stats: RunStats): KnownBlock[] { + const counts = new Map(); + for (const step of this.steps) { + counts.set(step.toolName, (counts.get(step.toolName) ?? 0) + 1); + } + const parts: string[] = []; + for (const [name, count] of counts) { + parts.push(count > 1 ? `${name} (×${count})` : name); + } + const toolSummary = parts.length > 0 ? parts.join(", ") : "no tools"; + const summary = `_${stats.stepCount} steps · ${toolSummary}_`; + + return [ + { type: "section", text: { type: "mrkdwn", text: `_⚠ ${errorText}_` } }, + { type: "context", elements: [{ type: "mrkdwn", text: summary }] }, + ]; + } +} + +// ============================================================================= +// SlackSurface +// ============================================================================= + /** * Slack implementation of AgentSurface. * - * Owns the lifecycle of a single Slack message for one agent run. + * Owns the lifecycle of a single Block Kit task card for one agent run. * * Guarantees: * - Every run ends with exactly one terminal op: resolve(), reject(), or suppress() * - dispose() is the safety net — if no terminal op was called, it rejects - * - The footer (steps/cost) is auto-computed from stats; callers never compose it + * - The task card updates in place through the whole lifecycle (never a fresh post) * - All Slack operations are serialized through updateChain */ export class SlackSurface implements AgentSurface { @@ -42,42 +161,57 @@ export class SlackSurface implements AgentSurface { private channel: string; private replyThreadTs?: string; private stats: RunStats; + private onFirstResponseFn?: () => void; + private taskCard = new TaskCard(); private messageTs: string | null = null; - private accumulatedText = ""; + private responseText = ""; + private hasResponse = false; private streaming = true; private resolved = false; private deleted = false; private hasFallenBackToFile = false; + private titleSet = false; private threadMessageTs: string[] = []; private updateChain: Promise = Promise.resolve(); - constructor(client: MessageTransport, channel: string, stats: RunStats, replyThreadTs?: string) { + constructor( + client: MessageTransport, + channel: string, + stats: RunStats, + replyThreadTs?: string, + onFirstResponse?: () => void, + ) { this.client = client; this.channel = channel; this.stats = stats; this.replyThreadTs = replyThreadTs; + this.onFirstResponseFn = onFirstResponse; } // ========================================================================== - // Display computation + // Block Kit payload helpers // ========================================================================== - /** Auto-computed footer from stats + streaming state */ - private get footer(): string { - if (this.stats.stepCount === 0 && this.stats.totalCost === 0) return ""; - const cost = this.streaming ? "streaming" : `$${this.stats.totalCost.toFixed(2)}`; - return ` _\u00AB${this.stats.stepCount} steps \u00B7 ${cost}\u00BB_`; + private runningPayload(): MessagePayload { + return { + text: "Digby is working on it...", + blocks: this.taskCard.toRunningBlocks(this.stats, this.streaming), + }; } - /** Full display text: accumulated content + auto footer */ - private get displayText(): string { - return (this.accumulatedText || "") + this.footer; + private resolvedPayload(): MessagePayload { + return { + text: this.responseText || "Done.", + blocks: this.taskCard.toResolvedBlocks(this.responseText, this.stats, this.streaming), + }; } - private truncate(text: string, limit: number): string { - if (text.length <= limit) return text; - return `${text.slice(0, limit)}\n_(truncated)_`; + private errorPayload(errorText: string): MessagePayload { + return { + text: `Error: ${errorText}`, + blocks: this.taskCard.toErrorBlocks(errorText, this.stats), + }; } // ========================================================================== @@ -90,28 +224,40 @@ export class SlackSurface implements AgentSurface { }); } - private enqueuePostOrUpdate(display: string): void { + private enqueuePostOrUpdate(payload: MessagePayload): void { this.enqueueUpdate(async () => { - const doUpdate = async (text: string) => { + const doUpdate = async (p: MessagePayload) => { if (this.messageTs) { - await this.client.updateMessage(this.channel, this.messageTs, text); + await this.client.updateMessage(this.channel, this.messageTs, p); } else { - this.messageTs = await this.client.postMessage(this.channel, text, this.replyThreadTs); + this.messageTs = await this.client.postMessage(this.channel, p, this.replyThreadTs); } }; try { - await doUpdate(display); + await doUpdate(payload); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (!msg.includes("msg_too_long")) { log.warn("[slack-surface] post/update error", msg); return; } + // Truncate the section text in blocks if it's a block payload + const truncated: MessagePayload = + typeof payload === "object" && payload.blocks + ? { + text: truncateText(payload.text, 30000), + blocks: payload.blocks.map((b: any) => { + if (b.type === "section" && b.text?.text) { + return { ...b, text: { ...b.text, text: truncateText(b.text.text, MAX_SECTION_TEXT) } }; + } + return b; + }), + } + : truncateText(payload as string, 30000); try { - await doUpdate(this.truncate(display, 30000)); + await doUpdate(truncated); } catch { - // Both update attempts failed — upload the full content as a file if (!this.hasFallenBackToFile) { this.hasFallenBackToFile = true; try { @@ -119,16 +265,15 @@ export class SlackSurface implements AgentSurface { const filename = `response-${ts}.md`; await this.client.uploadContent( this.channel, - this.accumulatedText || display, + this.responseText || (typeof payload === "string" ? payload : payload.text), filename, "Response (too long for message)", this.replyThreadTs, ); - // Update the placeholder message to indicate the file was uploaded try { await doUpdate("_Response too long — replying as a file attachment._"); } catch { - // ignore — placeholder update is best-effort + // best-effort placeholder update } } catch (uploadErr) { log.warn( @@ -137,7 +282,6 @@ export class SlackSurface implements AgentSurface { ); } } - // else: already uploaded once, skip duplicate } } }); @@ -148,11 +292,14 @@ export class SlackSurface implements AgentSurface { // ========================================================================== emitThinking(): void { - this.accumulatedText = THINKING_PLACEHOLDER; this.enqueueUpdate(async () => { try { + // setStatus requires an existing thread — skip for top-level channel posts + if (this.replyThreadTs) { + await this.client.setThreadStatus(this.channel, this.replyThreadTs, "is thinking...", LOADING_MESSAGES); + } if (!this.messageTs) { - this.messageTs = await this.client.postMessage(this.channel, THINKING_PLACEHOLDER, this.replyThreadTs); + this.messageTs = await this.client.postMessage(this.channel, this.runningPayload(), this.replyThreadTs); } } catch (err) { log.warn("[slack-surface] emitThinking error", err instanceof Error ? err.message : String(err)); @@ -160,26 +307,41 @@ export class SlackSurface implements AgentSurface { }); } - emitProgress(text: string): void { - const mrkdwn = mdToMrkdwn(text); - if (this.accumulatedText === THINKING_PLACEHOLDER) { - this.accumulatedText = mrkdwn; - } else { - this.accumulatedText = this.accumulatedText ? `${this.accumulatedText}\n${mrkdwn}` : mrkdwn; - } - this.enqueuePostOrUpdate(this.truncate(this.displayText, MAX_MESSAGE_LENGTH)); + emitToolStart(toolCallId: string, toolName: string, label: string): void { + this.taskCard.stepStart(toolCallId, toolName, label); + this.enqueuePostOrUpdate(this.runningPayload()); + } + + emitToolEnd(toolCallId: string, durationMs: number, isError: boolean): void { + this.taskCard.stepEnd(toolCallId, durationMs, isError); + this.enqueuePostOrUpdate(this.runningPayload()); } emitResponse(text: string): void { - this.accumulatedText = mdToMrkdwn(text); - this.enqueuePostOrUpdate(this.truncate(this.displayText, MAX_MESSAGE_LENGTH)); + this.responseText = mdToMrkdwn(text); + this.hasResponse = true; + this.enqueuePostOrUpdate(this.resolvedPayload()); + if (text.length > MAX_SECTION_TEXT) { + this.emitDetail(text); + } + // setTitle on first response (DM threads only) + if (!this.titleSet && this.onFirstResponseFn) { + this.titleSet = true; + this.enqueueUpdate(async () => { + try { + this.onFirstResponseFn!(); + } catch (err) { + log.warn("[slack-surface] setTitle error", err instanceof Error ? err.message : String(err)); + } + }); + } } emitDetail(text: string): void { this.enqueueUpdate(async () => { if (!this.messageTs) return; try { - const truncated = this.truncate(mdToMrkdwn(text), MAX_THREAD_MESSAGE_LENGTH); + const truncated = truncateText(mdToMrkdwn(text), MAX_THREAD_MESSAGE_LENGTH); const ts = await this.client.postMessage(this.channel, truncated, this.messageTs); this.threadMessageTs.push(ts); } catch (err) { @@ -221,20 +383,15 @@ export class SlackSurface implements AgentSurface { if (this.resolved) return; this.resolved = true; this.streaming = false; - this.enqueuePostOrUpdate(this.truncate(this.displayText, MAX_MESSAGE_LENGTH)); + const payload = this.hasResponse ? this.resolvedPayload() : this.runningPayload(); + this.enqueuePostOrUpdate(payload); } reject(error: string): void { if (this.resolved) return; this.resolved = true; this.streaming = false; - if (this.accumulatedText === THINKING_PLACEHOLDER) { - this.accumulatedText = ""; - } - this.accumulatedText = this.accumulatedText - ? `${this.accumulatedText}\n\n_\u26a0 ${error}_` - : `_\u26a0 ${error}_`; - this.enqueuePostOrUpdate(this.truncate(this.displayText, MAX_MESSAGE_LENGTH)); + this.enqueuePostOrUpdate(this.errorPayload(error)); } suppress(): void { @@ -281,7 +438,7 @@ export class SlackSurface implements AgentSurface { } get finalText(): string { - return this.accumulatedText; + return this.responseText; } get wasDeleted(): boolean { diff --git a/src/surface/types.ts b/src/surface/types.ts index 27371be4..462168c5 100644 --- a/src/surface/types.ts +++ b/src/surface/types.ts @@ -1,12 +1,30 @@ +import type { KnownBlock } from "@slack/web-api"; + +export type MessagePayload = string | { text: string; blocks: KnownBlock[] }; + +export interface MessageTransport { + postMessage(channel: string, payload: MessagePayload, threadTs?: string): Promise; + updateMessage(channel: string, ts: string, payload: MessagePayload): Promise; + deleteMessage(channel: string, ts: string): Promise; + addReaction(channel: string, ts: string, emoji: string): Promise; + uploadFile(channel: string, filePath: string, title?: string, threadTs?: string): Promise; + uploadContent(channel: string, content: string, filename: string, title?: string, threadTs?: string): Promise; + setThreadStatus(channel: string, threadTs: string, status: string, loadingMessages?: string[]): Promise; + setTitle(channel: string, threadTs: string, title: string): Promise; +} + /** Surface through which the agent communicates with the user. */ export interface AgentSurface { /** Agent is starting work. */ emitThinking(): void; - /** Tool/step progress (tool labels, retry notices). Appended to output stream. */ - emitProgress(text: string): void; + /** Tool call started. */ + emitToolStart(toolCallId: string, toolName: string, label: string): void; + + /** Tool call completed. */ + emitToolEnd(toolCallId: string, durationMs: number, isError: boolean): void; - /** Final response text. Replaces all prior progress. */ + /** Final response text. */ emitResponse(text: string): void; /** Supplementary detail (reasoning, debug info). Collapsed/threaded. */ @@ -38,4 +56,4 @@ export interface AgentSurface { readonly wasDeleted: boolean; } -export const THINKING_PLACEHOLDER = "\ud83e\udd14 _Thinking_"; +export const THINKING_PLACEHOLDER = "🤔 _Thinking_";