switchroom 0.13.57 → 0.13.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,7 +53,12 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
53
53
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
54
54
  import { StatusReactionController } from '../status-reactions.js'
55
55
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
56
- import { deriveIntentSurface } from '../tool-intent-surface.js'
56
+ import { allocateDraftId } from '../draft-transport.js'
57
+ import {
58
+ makeEmptyActivityState,
59
+ registerAndRender,
60
+ type ActivityState,
61
+ } from '../tool-activity-summary.js'
57
62
  import { toolLabel } from '../tool-labels.js'
58
63
  import { createTypingWrapper } from '../typing-wrap.js'
59
64
  import { type DraftStreamHandle } from '../draft-stream.js'
@@ -1292,15 +1297,39 @@ type CurrentTurn = {
1292
1297
  // Phase 1 of #332: count of tool_use events in the current turn, for
1293
1298
  // the tool_call_count column in the turns registry.
1294
1299
  toolCallCount: number
1295
- // Tool-intent surface (the human-feel UX follow-up to #1921's
1296
- // PreToolUse gate). When the model emits its first non-reply tool_use
1297
- // of a turn AND no outbound has happened yet, the gateway lifts the
1298
- // tool's already-formed intent (name + input `toolLabel()`) into a
1299
- // user-visible "<i>running</i>: ls -la /var/log" message. One-shot
1300
- // per turn subsequent tool_use events stay quiet so a multi-tool
1301
- // turn doesn't spam. The model never has to call reply just to ack;
1302
- // its own intent stream IS the ack source.
1303
- intentSurfaceFired: boolean
1300
+ // Tool-activity summary mirrors Claude Code's native chat-UI
1301
+ // rendering ("Ran 5 commands, read a file"). Counters are
1302
+ // incremented in `case 'tool_use'`; `activityMessageId` holds the
1303
+ // Telegram message id we send/edit so a single message accumulates
1304
+ // the summary in place. Stops updating once `replyCalled` flips —
1305
+ // the model's own reply lands below the summary as the actual
1306
+ // content.
1307
+ //
1308
+ // Parallel-tool-use coalescing (PR #1926 review): modern Claude
1309
+ // emits multiple tool_uses in a tight synchronous loop (e.g. 3
1310
+ // parallel Reads). Without coalescing, each would see
1311
+ // `activityMessageId == null` and fire its own sendMessage,
1312
+ // producing N messages instead of one editable summary. Pattern
1313
+ // mirrors `telegram-plugin/answer-stream.ts`:
1314
+ // - `activityInFlight` — promise that resolves when the current
1315
+ // send/edit settles. While set, NEW tool_uses just update
1316
+ // `activityState` and `activityPendingRender` and return.
1317
+ // - When the in-flight resolves, it picks the latest
1318
+ // `activityPendingRender`, fires the next send/edit, and
1319
+ // repeats until the pending matches the last-sent.
1320
+ // Result: at most one Telegram call in flight at a time; the
1321
+ // final state always lands.
1322
+ toolActivity: ActivityState
1323
+ activityMessageId: number | null
1324
+ // Draft-transport id when the activity summary is streamed via
1325
+ // sendMessageDraft (DM-only, no thread). Each call to
1326
+ // sendMessageDraft(chat, draftId, text) REPLACES the draft text —
1327
+ // simpler than send+edit. Cleared by `clearActivitySummary` (which
1328
+ // sends an empty draft) when the model's reply takes over.
1329
+ activityDraftId: number | null
1330
+ activityInFlight: Promise<void> | null
1331
+ activityPendingRender: string | null
1332
+ activityLastSentRender: string | null
1304
1333
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1305
1334
  // event of a turn (once enough text has accumulated, the stream itself
1306
1335
  // gates on minInitialChars). Materialized and cleared at turn_end.
@@ -6777,6 +6806,120 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
6777
6806
  }
6778
6807
  }
6779
6808
 
6809
+ /**
6810
+ * Drain the tool-activity summary's pending render queue. Single-flight
6811
+ * by construction (caller assigns the returned promise to
6812
+ * `turn.activityInFlight`; while set, new tool_uses only update
6813
+ * `turn.activityPendingRender` and return).
6814
+ *
6815
+ * Transport priority (mirrors the existing answer-stream pattern):
6816
+ *
6817
+ * 1. DM with no thread AND sendMessageDraft API available →
6818
+ * DRAFT TRANSPORT. Each call REPLACES the draft text (no
6819
+ * edit-in-place needed); the user sees a live preview in their
6820
+ * Telegram compose area as the agent works. When the model's
6821
+ * reply tool lands, `clearActivitySummary` sends an empty draft
6822
+ * to wipe it — only the real reply persists.
6823
+ *
6824
+ * 2. Anything else (forum topic, draft API absent) → fall through
6825
+ * to sendMessage + editMessageText. The activity message is a
6826
+ * real chat message; `clearActivitySummary` deletes it when the
6827
+ * reply tool takes over.
6828
+ *
6829
+ * The drain holds a reference to `turn`, so a turn-swap mid-drain
6830
+ * doesn't corrupt the next turn's atom — late writes land on the
6831
+ * captured `turn` (already-completed turn, harmless).
6832
+ */
6833
+ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6834
+ try {
6835
+ while (turn.activityPendingRender !== turn.activityLastSentRender) {
6836
+ const target = turn.activityPendingRender
6837
+ if (target == null) break
6838
+ const html = `<i>${target}</i>`
6839
+ const chat = turn.sessionChatId
6840
+ const thread = turn.sessionThreadId
6841
+ // sendMessageDraft doesn't support forum threads.
6842
+ const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
6843
+ try {
6844
+ if (useDraft) {
6845
+ if (turn.activityDraftId == null) {
6846
+ turn.activityDraftId = allocateDraftId()
6847
+ }
6848
+ const draftId = turn.activityDraftId
6849
+ await sendMessageDraftFn!(chat, draftId, html, undefined)
6850
+ } else if (turn.activityMessageId == null) {
6851
+ const sent = await robustApiCall(
6852
+ () => bot.api.sendMessage(chat, html, {
6853
+ ...(thread != null ? { message_thread_id: thread } : {}),
6854
+ parse_mode: 'HTML',
6855
+ disable_notification: true,
6856
+ }),
6857
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
6858
+ )
6859
+ turn.activityMessageId = sent.message_id
6860
+ } else {
6861
+ const id = turn.activityMessageId
6862
+ await robustApiCall(
6863
+ () => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
6864
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
6865
+ )
6866
+ }
6867
+ turn.activityLastSentRender = target
6868
+ } catch (err) {
6869
+ const msg = err instanceof Error ? err.message : String(err)
6870
+ if (!msg.includes('message is not modified')) {
6871
+ process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}\n`)
6872
+ }
6873
+ // Mark as sent so we don't infinite-loop on a stuck render.
6874
+ turn.activityLastSentRender = target
6875
+ }
6876
+ }
6877
+ } finally {
6878
+ turn.activityInFlight = null
6879
+ }
6880
+ }
6881
+
6882
+ /**
6883
+ * Clear the activity summary when the model's reply tool takes over
6884
+ * as the authoritative surface. Awaits any in-flight render so we
6885
+ * don't race a stale write against the clear, then either sends an
6886
+ * empty draft (clears the compose-area preview) or deletes the
6887
+ * persisted message. Idempotent + best-effort — failure stderr-logs
6888
+ * but does not block.
6889
+ *
6890
+ * Called from `case 'tool_use'` the moment we see a Telegram reply
6891
+ * tool fire, so the user sees the real reply land in the same beat
6892
+ * the summary disappears.
6893
+ */
6894
+ function clearActivitySummary(turn: CurrentTurn): void {
6895
+ const chat = turn.sessionChatId
6896
+ const thread = turn.sessionThreadId
6897
+ const inFlight = turn.activityInFlight ?? Promise.resolve()
6898
+ void inFlight.then(async () => {
6899
+ if (turn.activityDraftId != null && sendMessageDraftFn != null) {
6900
+ const draftId = turn.activityDraftId
6901
+ turn.activityDraftId = null
6902
+ try {
6903
+ // Empty text → Telegram clears the draft.
6904
+ await sendMessageDraftFn(chat, draftId, '', undefined)
6905
+ } catch (err) {
6906
+ process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}\n`)
6907
+ }
6908
+ } else if (turn.activityMessageId != null) {
6909
+ const id = turn.activityMessageId
6910
+ turn.activityMessageId = null
6911
+ try {
6912
+ await robustApiCall(
6913
+ () => bot.api.deleteMessage(chat, id),
6914
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
6915
+ )
6916
+ } catch (err) {
6917
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
6918
+ }
6919
+ }
6920
+ })
6921
+ }
6922
+
6780
6923
  function handleSessionEvent(ev: SessionEvent): void {
6781
6924
  switch (ev.kind) {
6782
6925
  case 'enqueue': {
@@ -6842,7 +6985,12 @@ function handleSessionEvent(ev: SessionEvent): void {
6842
6985
  lastAssistantMsgId: null,
6843
6986
  lastAssistantDone: false,
6844
6987
  toolCallCount: 0,
6845
- intentSurfaceFired: false,
6988
+ toolActivity: makeEmptyActivityState(),
6989
+ activityMessageId: null,
6990
+ activityDraftId: null,
6991
+ activityInFlight: null,
6992
+ activityPendingRender: null,
6993
+ activityLastSentRender: null,
6846
6994
  answerStream: null,
6847
6995
  isDm: isDmChatId(ev.chatId),
6848
6996
  }
@@ -6954,70 +7102,56 @@ function handleSessionEvent(ev: SessionEvent): void {
6954
7102
  // Phase tracking removed in #553 PR 5 — phases only fed the
6955
7103
  // placeholder-heartbeat label, which has been retired.
6956
7104
  if (isTelegramReplyTool(name)) {
7105
+ const wasFirstReply = !turn.replyCalled
6957
7106
  turn.replyCalled = true
6958
7107
  if (turn.orphanedReplyTimeoutId != null) {
6959
7108
  clearTimeout(turn.orphanedReplyTimeoutId)
6960
7109
  turn.orphanedReplyTimeoutId = null
6961
7110
  }
7111
+ // The model's real reply takes over as the authoritative
7112
+ // surface. Clear the activity summary — for drafts, send an
7113
+ // empty draft to wipe the compose-area preview; for persisted
7114
+ // messages, delete. The user sees the real reply land in the
7115
+ // same beat the summary disappears.
7116
+ if (wasFirstReply) {
7117
+ clearActivitySummary(turn)
7118
+ }
6962
7119
  }
6963
7120
  // Tool-intent surface — companion to the PreToolUse ack-first gate
6964
7121
  // (#1921). On the FIRST non-reply tool_use of a turn AND only when
6965
- // no outbound has happened yet, the gateway lifts the model's tool
6966
- // intent (name + input → `toolLabel()`) into a brief framework-voice
6967
- // status: `<i>running:</i> ls -la /var/log`. The model never has to
6968
- // call reply just to ack its own intent stream IS the ack. The
6969
- // gate continues to fire IN PARALLEL: if it produces a model-voice
6970
- // ack first (`replyCalled=true`), the surface stays quiet by the
6971
- // condition below. One-shot per turn.
6972
- if (
6973
- !turn.replyCalled
6974
- && !turn.intentSurfaceFired
6975
- && !isTelegramSurfaceTool(name)
6976
- ) {
6977
- turn.intentSurfaceFired = true
6978
- // `ev.input` is the canonical SessionEvent property
6979
- // (`telegram-plugin/session-tail.ts:95`). All other tool_use
6980
- // sites in this file use `ev.input` keep that consistent.
6981
- const surface = deriveIntentSurface(name, ev.input, ev.precomputedLabel)
6982
- if (surface.text != null) {
6983
- // Mark the ack-flag synchronously BEFORE the async send so a
6984
- // PreToolUse ack-first hook (#1921) firing concurrently for this
6985
- // same tool call sees the flag already present and allows the
6986
- // tool through. The Telegram send is fire-and-forget; failure
7122
+ // Tool-activity summary same shape Claude Code natively renders
7123
+ // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7124
+ // accumulates non-reply tool_use events into `turn.toolActivity`
7125
+ // and sends ONE Telegram message that edits in place as more tools
7126
+ // land. Stops editing once the model calls `reply` the summary
7127
+ // line stays as the final state. No model-side prompting; no per-
7128
+ // tool labels. Just surface what's already in the stream.
7129
+ //
7130
+ // Single-flight coalescing (PR #1926 review): modern Claude emits
7131
+ // multiple tool_uses in a synchronous burst (parallel Reads,
7132
+ // Bashes, etc.). All would otherwise race past the message-id
7133
+ // capture and produce N messages. Pattern mirrors answer-stream:
7134
+ // update `activityPendingRender` synchronously here; a single
7135
+ // worker promise drains the pending state, sending or editing
7136
+ // exactly once at a time and re-running until pending matches
7137
+ // the last-sent. Captures `turn` so a late drain after turn-swap
7138
+ // can't corrupt the next turn's atom.
7139
+ if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
7140
+ const rendered = registerAndRender(turn.toolActivity, name)
7141
+ if (rendered != null) {
7142
+ // Mark the ack-flag synchronously so a PreToolUse hook firing
7143
+ // concurrently for THIS tool call (#1921) sees the flag set
7144
+ // and allows the tool through. The drain runs async; failure
6987
7145
  // is logged but does not block the model.
6988
7146
  try {
6989
7147
  markAckSent()
6990
7148
  } catch (err) {
6991
- process.stderr.write(`telegram gateway: intent-surface markAckSent failed: ${err}\n`)
7149
+ process.stderr.write(`telegram gateway: activity-summary markAckSent failed: ${err}\n`)
7150
+ }
7151
+ turn.activityPendingRender = rendered
7152
+ if (turn.activityInFlight == null) {
7153
+ turn.activityInFlight = drainActivitySummary(turn)
6992
7154
  }
6993
- const surfaceChat = turn.sessionChatId
6994
- const surfaceThread = turn.sessionThreadId
6995
- const surfaceText = surface.text
6996
- void (async () => {
6997
- try {
6998
- await robustApiCall(
6999
- () => bot.api.sendMessage(surfaceChat, surfaceText, {
7000
- ...(surfaceThread != null ? { message_thread_id: surfaceThread } : {}),
7001
- parse_mode: 'HTML',
7002
- // Framework-narrating beat — silent, ambient, not a
7003
- // device buzz. The user is meant to glance and know
7004
- // the model is alive + on-task.
7005
- disable_notification: true,
7006
- }),
7007
- { chat_id: surfaceChat, ...(surfaceThread != null ? { threadId: surfaceThread } : {}), verb: 'intent-surface' },
7008
- )
7009
- // Deliberately NOT calling signalTracker.noteOutbound /
7010
- // silencePoke.noteOutbound here — framework-owned
7011
- // ambient messages are not model-author outbounds, so
7012
- // they should not reset the TTFO clock or short-circuit
7013
- // the silence-poke ladder. Mirrors the sibling
7014
- // `onAwarenessPing` handler (silence-poke.ts:169
7015
- // contract: "Caller must NOT call back into noteOutbound
7016
- // for this — it's a framework-sourced message").
7017
- } catch (err) {
7018
- process.stderr.write(`telegram gateway: intent-surface send failed: ${err}\n`)
7019
- }
7020
- })()
7021
7155
  }
7022
7156
  }
7023
7157
  if (!ctrl) return
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ makeEmptyActivityState,
4
+ register,
5
+ formatSummary,
6
+ registerAndRender,
7
+ verbForTool,
8
+ } from "../tool-activity-summary.js";
9
+
10
+ describe("verbForTool — tool name → past-tense verb", () => {
11
+ it("maps standard CLI tools to readable verbs", () => {
12
+ expect(verbForTool("Read")).toBe("read");
13
+ expect(verbForTool("Write")).toBe("created");
14
+ expect(verbForTool("Edit")).toBe("edited");
15
+ expect(verbForTool("MultiEdit")).toBe("edited");
16
+ expect(verbForTool("NotebookEdit")).toBe("edited");
17
+ expect(verbForTool("Bash")).toBe("ran");
18
+ expect(verbForTool("BashOutput")).toBe("ran");
19
+ expect(verbForTool("WebSearch")).toBe("searched");
20
+ expect(verbForTool("Grep")).toBe("searched");
21
+ expect(verbForTool("Glob")).toBe("searched");
22
+ expect(verbForTool("WebFetch")).toBe("fetched");
23
+ expect(verbForTool("Task")).toBe("dispatched");
24
+ expect(verbForTool("Agent")).toBe("dispatched");
25
+ expect(verbForTool("TodoWrite")).toBe("noted");
26
+ });
27
+
28
+ it("skips user-facing switchroom-telegram tools (those ARE the surface)", () => {
29
+ expect(verbForTool("mcp__switchroom-telegram__reply")).toBeNull();
30
+ expect(verbForTool("mcp__switchroom-telegram__stream_reply")).toBeNull();
31
+ expect(verbForTool("mcp__switchroom-telegram__edit_message")).toBeNull();
32
+ expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
33
+ });
34
+
35
+ it("returns 'used' for unknown / non-switchroom MCP tools", () => {
36
+ expect(verbForTool("mcp__google-workspace__list_files")).toBe("used");
37
+ expect(verbForTool("mcp__notion__query_database")).toBe("used");
38
+ expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
39
+ });
40
+
41
+ it("returns null for empty toolName (defensive)", () => {
42
+ expect(verbForTool("")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("register + formatSummary — Claude Code-style summary", () => {
47
+ it("formats a single Read as 'Read a file'", () => {
48
+ const s = makeEmptyActivityState();
49
+ register(s, "Read");
50
+ expect(formatSummary(s)).toBe("Read a file");
51
+ });
52
+
53
+ it("formats multiple Reads as 'Read N files'", () => {
54
+ const s = makeEmptyActivityState();
55
+ register(s, "Read");
56
+ register(s, "Read");
57
+ register(s, "Read");
58
+ expect(formatSummary(s)).toBe("Read 3 files");
59
+ });
60
+
61
+ it("formats single Bash as 'Ran a command'", () => {
62
+ const s = makeEmptyActivityState();
63
+ register(s, "Bash");
64
+ expect(formatSummary(s)).toBe("Ran a command");
65
+ });
66
+
67
+ it("formats multiple Bash as 'Ran N commands'", () => {
68
+ const s = makeEmptyActivityState();
69
+ for (let i = 0; i < 5; i++) register(s, "Bash");
70
+ expect(formatSummary(s)).toBe("Ran 5 commands");
71
+ });
72
+
73
+ it("joins multiple verb-classes with commas (first-occurrence order)", () => {
74
+ const s = makeEmptyActivityState();
75
+ // Tools fire in this order: Read → Bash → Edit
76
+ register(s, "Read");
77
+ register(s, "Bash");
78
+ register(s, "Edit");
79
+ // The summary renders chronologically: read, ran, edited.
80
+ expect(formatSummary(s)).toBe("Read a file, ran a command, edited a file");
81
+ });
82
+
83
+ it("matches the Claude Code screenshot examples", () => {
84
+ // "Ran 5 commands, read a file"
85
+ const s1 = makeEmptyActivityState();
86
+ for (let i = 0; i < 5; i++) register(s1, "Bash");
87
+ register(s1, "Read");
88
+ expect(formatSummary(s1)).toBe("Ran 5 commands, read a file");
89
+
90
+ // "Edited a file, read a file, ran a command"
91
+ const s2 = makeEmptyActivityState();
92
+ register(s2, "Edit");
93
+ register(s2, "Read");
94
+ register(s2, "Bash");
95
+ expect(formatSummary(s2)).toBe("Edited a file, read a file, ran a command");
96
+
97
+ // "Created a file, ran a command"
98
+ const s3 = makeEmptyActivityState();
99
+ register(s3, "Write");
100
+ register(s3, "Bash");
101
+ expect(formatSummary(s3)).toBe("Created a file, ran a command");
102
+ });
103
+
104
+ it("returns null when state is empty", () => {
105
+ expect(formatSummary(makeEmptyActivityState())).toBeNull();
106
+ });
107
+
108
+ it("ignores user-facing tools (reply/stream_reply etc.)", () => {
109
+ const s = makeEmptyActivityState();
110
+ register(s, "mcp__switchroom-telegram__reply");
111
+ register(s, "mcp__switchroom-telegram__stream_reply");
112
+ expect(formatSummary(s)).toBeNull(); // nothing tracked
113
+ });
114
+
115
+ it("includes generic 'used' for unknown MCP tools", () => {
116
+ const s = makeEmptyActivityState();
117
+ register(s, "mcp__google-workspace__list_files");
118
+ expect(formatSummary(s)).toBe("Used a tool");
119
+ register(s, "mcp__google-workspace__create_file");
120
+ expect(formatSummary(s)).toBe("Used 2 tools");
121
+ });
122
+
123
+ it("tracks firstToolName for forensic / telemetry use", () => {
124
+ const s = makeEmptyActivityState();
125
+ register(s, "Read");
126
+ register(s, "Bash");
127
+ expect(s.firstToolName).toBe("Read");
128
+ });
129
+ });
130
+
131
+ describe("parallel-tool-use coalescing — render only reflects accumulated state", () => {
132
+ it("synchronous burst of N tool_uses produces the right summary at each step", () => {
133
+ // Modern Claude emits parallel tool_uses in a tight sync loop. The
134
+ // gateway calls register() N times before any async drain runs.
135
+ // After N registers, the rendered string should reflect ALL of them
136
+ // — so when the drain fires once with the latest pendingRender, the
137
+ // sent text is correct and complete.
138
+ const s = makeEmptyActivityState();
139
+ register(s, "Read");
140
+ register(s, "Read");
141
+ register(s, "Read");
142
+ register(s, "Bash");
143
+ register(s, "Bash");
144
+ expect(formatSummary(s)).toBe("Read 3 files, ran 2 commands");
145
+ });
146
+
147
+ it("ordering is preserved across a chronological burst", () => {
148
+ const s = makeEmptyActivityState();
149
+ // Simulates: Bash, then Read, then Bash, then Read, then Edit
150
+ register(s, "Bash");
151
+ register(s, "Read");
152
+ register(s, "Bash");
153
+ register(s, "Read");
154
+ register(s, "Edit");
155
+ // Bash was first, then Read, then Edit. Counts: bash 2, read 2, edit 1.
156
+ expect(formatSummary(s)).toBe(
157
+ "Ran 2 commands, read 2 files, edited a file",
158
+ );
159
+ });
160
+
161
+ it("registerAndRender returns null on user-facing tools (no race contribution)", () => {
162
+ const s = makeEmptyActivityState();
163
+ register(s, "Read");
164
+ // A reply tool fires concurrently — should not enter the activity state.
165
+ expect(
166
+ registerAndRender(s, "mcp__switchroom-telegram__reply"),
167
+ ).toBeNull();
168
+ // State still reflects only the Read.
169
+ expect(formatSummary(s)).toBe("Read a file");
170
+ });
171
+ });
172
+
173
+ describe("registerAndRender — ergonomic full-pipeline call", () => {
174
+ it("returns the updated rendered text on a real tool (chronological)", () => {
175
+ const s = makeEmptyActivityState();
176
+ expect(registerAndRender(s, "Read")).toBe("Read a file");
177
+ // Bash fires AFTER Read — chronological order shows read first.
178
+ expect(registerAndRender(s, "Bash")).toBe(
179
+ "Read a file, ran a command",
180
+ );
181
+ });
182
+
183
+ it("returns null on a surface tool (no-op)", () => {
184
+ const s = makeEmptyActivityState();
185
+ expect(
186
+ registerAndRender(s, "mcp__switchroom-telegram__reply"),
187
+ ).toBeNull();
188
+ // State unchanged
189
+ expect(s.firstToolName).toBeNull();
190
+ });
191
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Tool-activity summary — Claude Code-style natural-language progress
3
+ * line that batches tool_use events for a turn into a single Telegram
4
+ * message that updates in place.
5
+ *
6
+ * Replaces the per-tool intent surface (#1924). The screenshot from
7
+ * Claude Code's own UI shows lines like:
8
+ *
9
+ * "Ran 5 commands, read a file"
10
+ * "Edited a file, read a file, ran a command"
11
+ *
12
+ * Past tense, comma-joined, singular/plural-aware. One message per
13
+ * "phase" (turn start → first reply), progressively edited as tools
14
+ * accumulate. NOT raw tool calls — descriptions of what the agent has
15
+ * been doing.
16
+ *
17
+ * Why this beats per-tool labels:
18
+ * - One Telegram message per phase (low signal-to-noise vs N
19
+ * messages on a heavy turn)
20
+ * - The user sees ACCUMULATED work in a glanceable form, not a flood
21
+ * - Plays nicely with the existing answer-lane stream that handles
22
+ * the actual reply text
23
+ *
24
+ * Tracking shape: per-turn counters keyed by `verb` (the action class
25
+ * derived from tool name). One counter per verb so the summary line
26
+ * collapses neatly regardless of which specific Read/Bash/WebSearch
27
+ * the model chose. `register()` increments the counter; `formatSummary()`
28
+ * renders the current state.
29
+ */
30
+
31
+ const READ_VERBS = new Set(["read"]);
32
+ const WRITE_VERBS = new Set(["wrote", "created", "edited"]);
33
+
34
+ export type ActivityVerb =
35
+ | "read"
36
+ | "edited"
37
+ | "created"
38
+ | "ran"
39
+ | "searched"
40
+ | "fetched"
41
+ | "dispatched"
42
+ | "noted"
43
+ | "used"; // generic fallback
44
+
45
+ /** Object form so `register()` can mutate; pure functions inside the
46
+ * module work against this shape (easier to unit-test than a Map). */
47
+ export interface ActivityState {
48
+ counts: Partial<Record<ActivityVerb, number>>;
49
+ /** Order verbs were first observed this turn. The summary renders in
50
+ * this order so the line reads as a chronological natural-language
51
+ * account: "edited a file, read a file, ran a command" matches the
52
+ * agent's actual sequence of actions. Stable — once a verb is added
53
+ * to this list, it never moves. */
54
+ order: ActivityVerb[];
55
+ /** First non-trivial tool name observed this turn (for telemetry / future
56
+ * "what kicked this off" forensic). Not used in the rendered summary. */
57
+ firstToolName: string | null;
58
+ }
59
+
60
+ export function makeEmptyActivityState(): ActivityState {
61
+ return { counts: {}, order: [], firstToolName: null };
62
+ }
63
+
64
+ /** Map a tool name → verb. Mirrors the existing `tool-intent-surface.ts`
65
+ * verb table but in past tense. Tools that don't map (or surface tools
66
+ * like reply/stream_reply) return null — the caller skips them. */
67
+ export function verbForTool(toolName: string): ActivityVerb | null {
68
+ if (!toolName) return null;
69
+ const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
70
+ // Skip user-facing Telegram-plugin tools entirely — those ARE the
71
+ // surface, never to be summarised.
72
+ if (mcpMatch && mcpMatch[1] === "switchroom-telegram") return null;
73
+ const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
74
+ switch (suffix) {
75
+ case "read":
76
+ return "read";
77
+ case "write":
78
+ return "created";
79
+ case "edit":
80
+ case "multiedit":
81
+ case "notebookedit":
82
+ return "edited";
83
+ case "bash":
84
+ case "bashoutput":
85
+ case "killshell":
86
+ return "ran";
87
+ case "websearch":
88
+ case "grep":
89
+ case "glob":
90
+ return "searched";
91
+ case "webfetch":
92
+ return "fetched";
93
+ case "task":
94
+ case "agent":
95
+ return "dispatched";
96
+ case "todowrite":
97
+ case "todoread":
98
+ return "noted";
99
+ default:
100
+ return "used";
101
+ }
102
+ }
103
+
104
+ /** Mutates `state` to record one tool_use of `toolName`. Returns true
105
+ * iff the activity state changed (so the caller knows to refresh the
106
+ * rendered summary). */
107
+ export function register(state: ActivityState, toolName: string): boolean {
108
+ const verb = verbForTool(toolName);
109
+ if (!verb) return false;
110
+ if (state.firstToolName == null) state.firstToolName = toolName;
111
+ const prior = state.counts[verb] ?? 0;
112
+ if (prior === 0) state.order.push(verb);
113
+ state.counts[verb] = prior + 1;
114
+ return true;
115
+ }
116
+
117
+ interface VerbPhrase {
118
+ singular: string;
119
+ plural: string;
120
+ }
121
+
122
+ const VERB_PHRASE: Record<ActivityVerb, VerbPhrase> = {
123
+ read: { singular: "read a file", plural: "read $N files" },
124
+ edited: { singular: "edited a file", plural: "edited $N files" },
125
+ created: { singular: "created a file", plural: "created $N files" },
126
+ ran: { singular: "ran a command", plural: "ran $N commands" },
127
+ searched: { singular: "ran a search", plural: "ran $N searches" },
128
+ fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
129
+ dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
130
+ noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
131
+ used: { singular: "used a tool", plural: "used $N tools" },
132
+ };
133
+
134
+ /** Render the activity state as a single natural-language line.
135
+ * Verbs are rendered in `state.order` — first-occurrence order — so
136
+ * the line reads chronologically ("edited a file, read a file, ran
137
+ * a command" mirrors the agent's actual action sequence). Returns
138
+ * null when the state is empty (nothing to show yet). */
139
+ export function formatSummary(state: ActivityState): string | null {
140
+ const phrases: string[] = [];
141
+ for (const verb of state.order) {
142
+ const n = state.counts[verb] ?? 0;
143
+ if (n <= 0) continue;
144
+ const p = VERB_PHRASE[verb];
145
+ phrases.push(n === 1 ? p.singular : p.plural.replace("$N", String(n)));
146
+ }
147
+ if (phrases.length === 0) return null;
148
+ // Capitalize first letter so the sentence reads as a statement.
149
+ const sentence = phrases.join(", ");
150
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
151
+ }
152
+
153
+ /** Convenience: ergonomic full pipeline for callers that just want
154
+ * "given the new tool name and prior state, give me the updated rendered
155
+ * text or null if nothing changed". Returns null when the tool is a
156
+ * surface tool / no-op (so the caller can skip the Telegram edit). */
157
+ export function registerAndRender(
158
+ state: ActivityState,
159
+ toolName: string,
160
+ ): string | null {
161
+ const changed = register(state, toolName);
162
+ if (!changed) return null;
163
+ return formatSummary(state);
164
+ }