switchroom 0.13.57 → 0.13.59

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'
@@ -81,7 +86,6 @@ import { classifyInbound } from '../inbound-classifier.js'
81
86
  import * as silencePoke from '../silence-poke.js'
82
87
  import * as pendingProgress from '../pending-work-progress.js'
83
88
  import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
84
- import { markAckSent, clearAckSent } from '../ack-flag.js'
85
89
  import { isFinalAnswerReply } from '../final-answer-detect.js'
86
90
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
87
91
  import { type SessionEvent } from '../session-tail.js'
@@ -1292,15 +1296,39 @@ type CurrentTurn = {
1292
1296
  // Phase 1 of #332: count of tool_use events in the current turn, for
1293
1297
  // the tool_call_count column in the turns registry.
1294
1298
  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
1299
+ // Tool-activity summary mirrors Claude Code's native chat-UI
1300
+ // rendering ("Ran 5 commands, read a file"). Counters are
1301
+ // incremented in `case 'tool_use'`; `activityMessageId` holds the
1302
+ // Telegram message id we send/edit so a single message accumulates
1303
+ // the summary in place. Stops updating once `replyCalled` flips —
1304
+ // the model's own reply lands below the summary as the actual
1305
+ // content.
1306
+ //
1307
+ // Parallel-tool-use coalescing (PR #1926 review): modern Claude
1308
+ // emits multiple tool_uses in a tight synchronous loop (e.g. 3
1309
+ // parallel Reads). Without coalescing, each would see
1310
+ // `activityMessageId == null` and fire its own sendMessage,
1311
+ // producing N messages instead of one editable summary. Pattern
1312
+ // mirrors `telegram-plugin/answer-stream.ts`:
1313
+ // - `activityInFlight` — promise that resolves when the current
1314
+ // send/edit settles. While set, NEW tool_uses just update
1315
+ // `activityState` and `activityPendingRender` and return.
1316
+ // - When the in-flight resolves, it picks the latest
1317
+ // `activityPendingRender`, fires the next send/edit, and
1318
+ // repeats until the pending matches the last-sent.
1319
+ // Result: at most one Telegram call in flight at a time; the
1320
+ // final state always lands.
1321
+ toolActivity: ActivityState
1322
+ activityMessageId: number | null
1323
+ // Draft-transport id when the activity summary is streamed via
1324
+ // sendMessageDraft (DM-only, no thread). Each call to
1325
+ // sendMessageDraft(chat, draftId, text) REPLACES the draft text —
1326
+ // simpler than send+edit. Cleared by `clearActivitySummary` (which
1327
+ // sends an empty draft) when the model's reply takes over.
1328
+ activityDraftId: number | null
1329
+ activityInFlight: Promise<void> | null
1330
+ activityPendingRender: string | null
1331
+ activityLastSentRender: string | null
1304
1332
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1305
1333
  // event of a turn (once enough text has accumulated, the stream itself
1306
1334
  // gates on minInitialChars). Materialized and cleared at turn_end.
@@ -4955,16 +4983,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4955
4983
  // silence-poke clock so the next poke is measured from this send.
4956
4984
  signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
4957
4985
  silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
4958
- // Ack-first gate (`reference/conversational-pacing.md` beat 1):
4959
- // touch the state-dir flag so the ack-first-pretool hook lets
4960
- // subsequent non-reply tool calls through this turn. Cleared at
4961
- // turn_started. Best-effort — a write failure shouldn't break
4962
- // reply, and the hook is kill-switched anyway.
4963
- try {
4964
- markAckSent()
4965
- } catch (err) {
4966
- process.stderr.write(`telegram gateway: markAckSent failed: ${err}\n`)
4967
- }
4968
4986
  // #1741 — only clear silent-end state on a plausibly-final reply.
4969
4987
  // An interim ack (disable_notification:true, short text, no done)
4970
4988
  // must NOT clear the state file; otherwise a turn that ends with
@@ -5560,13 +5578,6 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5560
5578
  const sKey = statusKey(streamChatId, streamThreadId)
5561
5579
  signalTracker.noteOutbound(sKey, Date.now())
5562
5580
  silencePoke.noteOutbound(sKey, Date.now())
5563
- // Ack-first gate: stream_reply's first emit also unlocks subsequent
5564
- // tool calls. See ack-flag.ts + ack-first-pretool.ts.
5565
- try {
5566
- markAckSent()
5567
- } catch (err) {
5568
- process.stderr.write(`telegram gateway: markAckSent (stream_reply) failed: ${err}\n`)
5569
- }
5570
5581
  // #1741 — see executeReply for the rationale: only a plausibly-
5571
5582
  // final stream_reply clears the silent-end state. An interim
5572
5583
  // ack via stream_reply must NOT clear; the Stop hook needs
@@ -6777,6 +6788,120 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
6777
6788
  }
6778
6789
  }
6779
6790
 
6791
+ /**
6792
+ * Drain the tool-activity summary's pending render queue. Single-flight
6793
+ * by construction (caller assigns the returned promise to
6794
+ * `turn.activityInFlight`; while set, new tool_uses only update
6795
+ * `turn.activityPendingRender` and return).
6796
+ *
6797
+ * Transport priority (mirrors the existing answer-stream pattern):
6798
+ *
6799
+ * 1. DM with no thread AND sendMessageDraft API available →
6800
+ * DRAFT TRANSPORT. Each call REPLACES the draft text (no
6801
+ * edit-in-place needed); the user sees a live preview in their
6802
+ * Telegram compose area as the agent works. When the model's
6803
+ * reply tool lands, `clearActivitySummary` sends an empty draft
6804
+ * to wipe it — only the real reply persists.
6805
+ *
6806
+ * 2. Anything else (forum topic, draft API absent) → fall through
6807
+ * to sendMessage + editMessageText. The activity message is a
6808
+ * real chat message; `clearActivitySummary` deletes it when the
6809
+ * reply tool takes over.
6810
+ *
6811
+ * The drain holds a reference to `turn`, so a turn-swap mid-drain
6812
+ * doesn't corrupt the next turn's atom — late writes land on the
6813
+ * captured `turn` (already-completed turn, harmless).
6814
+ */
6815
+ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6816
+ try {
6817
+ while (turn.activityPendingRender !== turn.activityLastSentRender) {
6818
+ const target = turn.activityPendingRender
6819
+ if (target == null) break
6820
+ const html = `<i>${target}</i>`
6821
+ const chat = turn.sessionChatId
6822
+ const thread = turn.sessionThreadId
6823
+ // sendMessageDraft doesn't support forum threads.
6824
+ const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
6825
+ try {
6826
+ if (useDraft) {
6827
+ if (turn.activityDraftId == null) {
6828
+ turn.activityDraftId = allocateDraftId()
6829
+ }
6830
+ const draftId = turn.activityDraftId
6831
+ await sendMessageDraftFn!(chat, draftId, html, undefined)
6832
+ } else if (turn.activityMessageId == null) {
6833
+ const sent = await robustApiCall(
6834
+ () => bot.api.sendMessage(chat, html, {
6835
+ ...(thread != null ? { message_thread_id: thread } : {}),
6836
+ parse_mode: 'HTML',
6837
+ disable_notification: true,
6838
+ }),
6839
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
6840
+ )
6841
+ turn.activityMessageId = sent.message_id
6842
+ } else {
6843
+ const id = turn.activityMessageId
6844
+ await robustApiCall(
6845
+ () => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
6846
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
6847
+ )
6848
+ }
6849
+ turn.activityLastSentRender = target
6850
+ } catch (err) {
6851
+ const msg = err instanceof Error ? err.message : String(err)
6852
+ if (!msg.includes('message is not modified')) {
6853
+ process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}\n`)
6854
+ }
6855
+ // Mark as sent so we don't infinite-loop on a stuck render.
6856
+ turn.activityLastSentRender = target
6857
+ }
6858
+ }
6859
+ } finally {
6860
+ turn.activityInFlight = null
6861
+ }
6862
+ }
6863
+
6864
+ /**
6865
+ * Clear the activity summary when the model's reply tool takes over
6866
+ * as the authoritative surface. Awaits any in-flight render so we
6867
+ * don't race a stale write against the clear, then either sends an
6868
+ * empty draft (clears the compose-area preview) or deletes the
6869
+ * persisted message. Idempotent + best-effort — failure stderr-logs
6870
+ * but does not block.
6871
+ *
6872
+ * Called from `case 'tool_use'` the moment we see a Telegram reply
6873
+ * tool fire, so the user sees the real reply land in the same beat
6874
+ * the summary disappears.
6875
+ */
6876
+ function clearActivitySummary(turn: CurrentTurn): void {
6877
+ const chat = turn.sessionChatId
6878
+ const thread = turn.sessionThreadId
6879
+ const inFlight = turn.activityInFlight ?? Promise.resolve()
6880
+ void inFlight.then(async () => {
6881
+ if (turn.activityDraftId != null && sendMessageDraftFn != null) {
6882
+ const draftId = turn.activityDraftId
6883
+ turn.activityDraftId = null
6884
+ try {
6885
+ // Empty text → Telegram clears the draft.
6886
+ await sendMessageDraftFn(chat, draftId, '', undefined)
6887
+ } catch (err) {
6888
+ process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}\n`)
6889
+ }
6890
+ } else if (turn.activityMessageId != null) {
6891
+ const id = turn.activityMessageId
6892
+ turn.activityMessageId = null
6893
+ try {
6894
+ await robustApiCall(
6895
+ () => bot.api.deleteMessage(chat, id),
6896
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
6897
+ )
6898
+ } catch (err) {
6899
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
6900
+ }
6901
+ }
6902
+ })
6903
+ }
6904
+
6780
6905
  function handleSessionEvent(ev: SessionEvent): void {
6781
6906
  switch (ev.kind) {
6782
6907
  case 'enqueue': {
@@ -6801,14 +6926,6 @@ function handleSessionEvent(ev: SessionEvent): void {
6801
6926
  statusKey(ev.chatId, enqThreadId),
6802
6927
  'handback',
6803
6928
  )
6804
- // Ack-first gate (`reference/conversational-pacing.md` beat 1):
6805
- // wipe the prior turn's `ack-sent.flag` so the ack-first-
6806
- // pretool hook re-arms for this fresh turn. Centralised HERE
6807
- // (not in handleInbound) because `enqueue` is the single
6808
- // canonical fresh-turn atom — fires for real inbounds, cron
6809
- // fires, subagent-handback channel wakes, vault-grant resumes,
6810
- // and restart markers alike. Best-effort — see ack-flag.ts.
6811
- clearAckSent()
6812
6929
  }
6813
6930
  if (ev.chatId) {
6814
6931
  // Issue #195: if a previous turn left an answer-lane stream open
@@ -6842,7 +6959,12 @@ function handleSessionEvent(ev: SessionEvent): void {
6842
6959
  lastAssistantMsgId: null,
6843
6960
  lastAssistantDone: false,
6844
6961
  toolCallCount: 0,
6845
- intentSurfaceFired: false,
6962
+ toolActivity: makeEmptyActivityState(),
6963
+ activityMessageId: null,
6964
+ activityDraftId: null,
6965
+ activityInFlight: null,
6966
+ activityPendingRender: null,
6967
+ activityLastSentRender: null,
6846
6968
  answerStream: null,
6847
6969
  isDm: isDmChatId(ev.chatId),
6848
6970
  }
@@ -6954,70 +7076,47 @@ function handleSessionEvent(ev: SessionEvent): void {
6954
7076
  // Phase tracking removed in #553 PR 5 — phases only fed the
6955
7077
  // placeholder-heartbeat label, which has been retired.
6956
7078
  if (isTelegramReplyTool(name)) {
7079
+ const wasFirstReply = !turn.replyCalled
6957
7080
  turn.replyCalled = true
6958
7081
  if (turn.orphanedReplyTimeoutId != null) {
6959
7082
  clearTimeout(turn.orphanedReplyTimeoutId)
6960
7083
  turn.orphanedReplyTimeoutId = null
6961
7084
  }
7085
+ // The model's real reply takes over as the authoritative
7086
+ // surface. Clear the activity summary — for drafts, send an
7087
+ // empty draft to wipe the compose-area preview; for persisted
7088
+ // messages, delete. The user sees the real reply land in the
7089
+ // same beat the summary disappears.
7090
+ if (wasFirstReply) {
7091
+ clearActivitySummary(turn)
7092
+ }
6962
7093
  }
6963
7094
  // Tool-intent surface — companion to the PreToolUse ack-first gate
6964
7095
  // (#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
6987
- // is logged but does not block the model.
6988
- try {
6989
- markAckSent()
6990
- } catch (err) {
6991
- process.stderr.write(`telegram gateway: intent-surface markAckSent failed: ${err}\n`)
7096
+ // Tool-activity summary same shape Claude Code natively renders
7097
+ // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7098
+ // accumulates non-reply tool_use events into `turn.toolActivity`
7099
+ // and sends ONE Telegram message that edits in place as more tools
7100
+ // land. Stops editing once the model calls `reply` the summary
7101
+ // line stays as the final state. No model-side prompting; no per-
7102
+ // tool labels. Just surface what's already in the stream.
7103
+ //
7104
+ // Single-flight coalescing (PR #1926 review): modern Claude emits
7105
+ // multiple tool_uses in a synchronous burst (parallel Reads,
7106
+ // Bashes, etc.). All would otherwise race past the message-id
7107
+ // capture and produce N messages. Pattern mirrors answer-stream:
7108
+ // update `activityPendingRender` synchronously here; a single
7109
+ // worker promise drains the pending state, sending or editing
7110
+ // exactly once at a time and re-running until pending matches
7111
+ // the last-sent. Captures `turn` so a late drain after turn-swap
7112
+ // can't corrupt the next turn's atom.
7113
+ if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
7114
+ const rendered = registerAndRender(turn.toolActivity, name)
7115
+ if (rendered != null) {
7116
+ turn.activityPendingRender = rendered
7117
+ if (turn.activityInFlight == null) {
7118
+ turn.activityInFlight = drainActivitySummary(turn)
6992
7119
  }
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
7120
  }
7022
7121
  }
7023
7122
  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
+ });