diff --git a/packages/mom/CHANGELOG.md b/packages/mom/CHANGELOG.md index dcc35221..611383e3 100644 --- a/packages/mom/CHANGELOG.md +++ b/packages/mom/CHANGELOG.md @@ -20,6 +20,13 @@ - Tracks tokens (input, output, cache read, cache write) and costs per run - Displays summary at end of each agent run in console and Slack thread - Example: `💰 Usage: 12,543 in + 847 out (5,234 cache read, 127 cache write) = $0.0234` +- Working indicator in Slack messages + - Channel messages show "..." while mom is processing + - Automatically removed when work completes +- Improved stop command behavior + - Separate "Stopping..." message that updates to "Stopped" when abort completes + - Original working message continues to show tool results (including abort errors) + - Clean separation between status and results ### Changed @@ -42,6 +49,9 @@ - Tool result display now extracts actual text instead of showing JSON wrapper - Slack thread messages now show cleaner tool call formatting with duration and label - All console logging centralized and removed from scattered locations +- Agent run now returns `{ stopReason }` instead of throwing exceptions + - Clean handling of "aborted", "error", "stop", "length", "toolUse" cases + - No more error-based control flow ### Fixed diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index b0722641..213d77a2 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -13,7 +13,7 @@ import { createMomTools, setUploadFunction } from "./tools/index.js"; const model = getModel("anthropic", "claude-sonnet-4-5"); export interface AgentRunner { - run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise; + run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<{ stopReason: string }>; abort(): void; } @@ -316,7 +316,7 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner { const executor = createExecutor(sandboxConfig); return { - async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise { + async run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<{ stopReason: string }> { // Ensure channel directory exists await mkdir(channelDir, { recursive: true }); @@ -374,6 +374,9 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner { }, }; + // Track stop reason + let stopReason = "stop"; + // Subscribe to events agent.subscribe(async (event: AgentEvent) => { switch (event.type) { @@ -474,6 +477,11 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner { if (event.message.role === "assistant") { const assistantMsg = event.message as any; // AssistantMessage type + // Track stop reason + if (assistantMsg.stopReason) { + stopReason = assistantMsg.stopReason; + } + // Accumulate usage if (assistantMsg.usage) { totalUsage.input += assistantMsg.usage.input; @@ -520,6 +528,8 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner { const summary = log.logUsageSummary(logCtx, totalUsage); await ctx.respondInThread(summary); } + + return { stopReason }; }, abort(): void { diff --git a/packages/mom/src/main.ts b/packages/mom/src/main.ts index 05af80d3..cc839fe3 100644 --- a/packages/mom/src/main.ts +++ b/packages/mom/src/main.ts @@ -68,7 +68,7 @@ if (!MOM_SLACK_APP_TOKEN || !MOM_SLACK_BOT_TOKEN || (!ANTHROPIC_API_KEY && !ANTH await validateSandbox(sandbox); // Track active agent runs per channel -const activeRuns = new Map(); +const activeRuns = new Map(); async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Promise { const channelId = ctx.message.channel; @@ -82,11 +82,15 @@ async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Prom // Check for stop command if (messageText === "stop") { - const runner = activeRuns.get(channelId); - if (runner) { + const active = activeRuns.get(channelId); + if (active) { log.logStopRequest(logCtx); - runner.abort(); + // Post a NEW message saying "Stopping..." await ctx.respond("_Stopping..._"); + // Store this context to update it to "Stopped" later + active.stopContext = ctx; + // Abort the runner + active.runner.abort(); } else { await ctx.respond("_Nothing running._"); } @@ -103,23 +107,31 @@ async function handleMessage(ctx: SlackContext, _source: "channel" | "dm"): Prom const channelDir = join(workingDir, channelId); const runner = createAgentRunner(sandbox); - activeRuns.set(channelId, runner); + activeRuns.set(channelId, { runner, context: ctx }); await ctx.setTyping(true); - try { - await runner.run(ctx, channelDir, ctx.store); - } catch (error) { - // Don't report abort errors - const msg = error instanceof Error ? error.message : String(error); - if (msg.includes("aborted") || msg.includes("Aborted")) { - // Already said "Stopping..." - nothing more to say - } else { - log.logAgentError(logCtx, msg); - await ctx.respond(`❌ Error: ${msg}`); + await ctx.setWorking(true); + + const result = await runner.run(ctx, channelDir, ctx.store); + + // Remove working indicator + await ctx.setWorking(false); + + // Handle different stop reasons + const active = activeRuns.get(channelId); + if (result.stopReason === "aborted") { + // Replace the STOP message with "Stopped" + if (active?.stopContext) { + await active.stopContext.setWorking(false); + await active.stopContext.replaceMessage("_Stopped_"); } - } finally { - activeRuns.delete(channelId); + } else if (result.stopReason === "error") { + // Agent encountered an error + log.logAgentError(logCtx, "Agent stopped with error"); } + // "stop", "length", "toolUse" are normal completions - nothing extra to do + + activeRuns.delete(channelId); } const bot = new MomBot( diff --git a/packages/mom/src/slack.ts b/packages/mom/src/slack.ts index 0e3b3f69..fc63796c 100644 --- a/packages/mom/src/slack.ts +++ b/packages/mom/src/slack.ts @@ -21,12 +21,16 @@ export interface SlackContext { store: ChannelStore; /** Send/update the main message (accumulates text) */ respond(text: string): Promise; + /** Replace the entire message text (not append) */ + replaceMessage(text: string): Promise; /** Post a message in the thread under the main message (for verbose details) */ respondInThread(text: string): Promise; /** Show/hide typing indicator */ setTyping(isTyping: boolean): Promise; /** Upload a file to the channel */ uploadFile(filePath: string, title?: string): Promise; + /** Set working state (adds/removes working indicator emoji) */ + setWorking(working: boolean): Promise; } export interface MomHandler { @@ -201,6 +205,8 @@ export class MomBot { let messageTs: string | null = null; let accumulatedText = ""; let isThinking = true; // Track if we're still in "thinking" state + let isWorking = true; // Track if still processing + const workingIndicator = " ..."; let updatePromise: Promise = Promise.resolve(); return { @@ -227,18 +233,21 @@ export class MomBot { accumulatedText += "\n" + responseText; } + // Add working indicator if still working + const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; + if (messageTs) { // Update existing message await this.webClient.chat.update({ channel: event.channel, ts: messageTs, - text: accumulatedText, + text: displayText, }); } else { // Post initial message const result = await this.webClient.chat.postMessage({ channel: event.channel, - text: accumulatedText, + text: displayText, }); messageTs = result.ts as string; } @@ -267,8 +276,8 @@ export class MomBot { }, setTyping: async (isTyping: boolean) => { if (isTyping && !messageTs) { - // Post initial "thinking" message - accumulatedText = "_Thinking..._"; + // Post initial "thinking" message (... auto-appended by working indicator) + accumulatedText = "_Thinking_"; const result = await this.webClient.chat.postMessage({ channel: event.channel, text: accumulatedText, @@ -288,6 +297,46 @@ export class MomBot { title: fileName, }); }, + replaceMessage: async (text: string) => { + updatePromise = updatePromise.then(async () => { + // Replace the accumulated text entirely + accumulatedText = text; + + const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; + + if (messageTs) { + await this.webClient.chat.update({ + channel: event.channel, + ts: messageTs, + text: displayText, + }); + } else { + // Post initial message + const result = await this.webClient.chat.postMessage({ + channel: event.channel, + text: displayText, + }); + messageTs = result.ts as string; + } + }); + await updatePromise; + }, + setWorking: async (working: boolean) => { + updatePromise = updatePromise.then(async () => { + isWorking = working; + + // If we have a message, update it to add/remove indicator + if (messageTs) { + const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText; + await this.webClient.chat.update({ + channel: event.channel, + ts: messageTs, + text: displayText, + }); + } + }); + await updatePromise; + }, }; }