switchroom 0.14.8 → 0.14.9

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,14 +53,7 @@ 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 {
57
- makeEmptyActivityState,
58
- registerAndRender,
59
- describeToolUse,
60
- appendActivityLine,
61
- appendActivityLabel,
62
- type ActivityState,
63
- } from '../tool-activity-summary.js'
56
+ import { appendActivityLabel } from '../tool-activity-summary.js'
64
57
  import { toolLabel } from '../tool-labels.js'
65
58
  import { createTypingWrapper } from '../typing-wrap.js'
66
59
  import { type DraftStreamHandle } from '../draft-stream.js'
@@ -1352,16 +1345,14 @@ type CurrentTurn = {
1352
1345
  // repeats until the pending matches the last-sent.
1353
1346
  // Result: at most one Telegram call in flight at a time; the
1354
1347
  // final state always lands.
1355
- toolActivity: ActivityState
1356
1348
  activityMessageId: number | null
1357
1349
  activityInFlight: Promise<void> | null
1358
1350
  activityPendingRender: string | null
1359
1351
  activityLastSentRender: string | null
1360
- // Accumulating friendly-action feed for this turn (DRAFT_MIRROR only).
1361
- // Each non-surface tool_use appends a line via `appendActivityLine`; the
1362
- // feed renders (via `renderActivityFeed`) as a capped chronological list
1363
- // into the in-place edited activity message and clears on reply. Reset
1364
- // per turn.
1352
+ // Accumulating friendly-action feed for this turn. Each non-surface
1353
+ // tool_label appends a line via `appendActivityLabel`; the feed renders
1354
+ // (via `renderActivityFeed`) as a capped chronological list into the
1355
+ // in-place edited activity message and clears on reply. Reset per turn.
1365
1356
  mirrorLines: string[]
1366
1357
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1367
1358
  // event of a turn (once enough text has accumulated, the stream itself
@@ -3254,25 +3245,16 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
3254
3245
  return true
3255
3246
  })()
3256
3247
 
3257
- // Activity-feed flag (RFC docs/rfcs/draft-mirror-preview.md). When enabled,
3258
- // the gateway streams a live "what it's doing" tool-activity feed for the
3259
- // turn. The PreToolUse sidecar emits a `tool_label` per tool call (flush-
3260
- // independent, so it stays real-time on fast/clustered-tool turns); each
3261
- // label appends to `turn.mirrorLines`, and `renderActivityFeed` renders the
3262
- // capped list into an in-place EDITED message (sendMessage + editMessageText)
3263
- // anchored as a native reply-quote to the user's question. The feed clears on
3264
- // the first reply (hand-off to the answer) and again at turn_end (the no-reply
3265
- // safety net). It does NOT touch the answer-stream's draft/visible lane the
3266
- // two render on separate surfaces, so they never collide. (The env name is
3267
- // historical: an earlier design mirrored into the compose-area draft; the feed
3268
- // is now a normal edited message.) Default OFF (canary). Kill switch:
3269
- // SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
3270
- const DRAFT_MIRROR_ENABLED = (() => {
3271
- const raw = process.env.SWITCHROOM_DRAFT_MIRROR
3272
- if (raw == null) return false
3273
- const v = raw.trim().toLowerCase()
3274
- return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
3275
- })()
3248
+ // Activity feed. The gateway streams a live "what it's doing" tool-activity
3249
+ // feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
3250
+ // call (flush-independent, so it stays real-time on fast/clustered-tool
3251
+ // turns); each label appends to `turn.mirrorLines`, and `renderActivityFeed`
3252
+ // renders the capped list into an in-place EDITED message (sendMessage +
3253
+ // editMessageText) anchored as a native reply-quote to the user's question.
3254
+ // The feed clears on the first reply (hand-off to the answer) and again at
3255
+ // turn_end (the no-reply safety net). It does NOT touch the answer-stream's
3256
+ // draft/visible lane the two render on separate surfaces, so they never
3257
+ // collide.
3276
3258
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3277
3259
  const progressDriver: any = null
3278
3260
  const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
@@ -6946,23 +6928,18 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6946
6928
  while (turn.activityPendingRender !== turn.activityLastSentRender) {
6947
6929
  const target = turn.activityPendingRender
6948
6930
  if (target == null) break
6949
- // Two mutually-exclusive producers feed `activityPendingRender`
6950
- // (gated on DRAFT_MIRROR_ENABLED in handleSessionEvent):
6951
- // - feed ON: `renderActivityFeed` already emitted ready Telegram HTML
6952
- // with per-line markup (<b>→ current</b> / <i>✓ done</i>) and escaped
6953
- // each label's <,>,& itself (#1942 class) — send verbatim, do NOT
6954
- // re-escape or re-wrap (double-escaping would surface literal tags).
6955
- // - feed OFF: the legacy verb-count summary is plain text — escape and
6956
- // wrap in a single <i>.
6957
- const html = DRAFT_MIRROR_ENABLED ? target : `<i>${escapeHtmlForTg(target)}</i>`
6931
+ // `renderActivityFeed` already emitted ready Telegram HTML with per-line
6932
+ // markup (<b>→ current</b> / <i>✓ done</i>) and escaped each label's
6933
+ // <,>,& itself (#1942 class) send verbatim, do NOT re-escape or
6934
+ // re-wrap (double-escaping would surface literal tags).
6935
+ const html = target
6958
6936
  const chat = turn.sessionChatId
6959
6937
  const thread = turn.sessionThreadId
6960
6938
  // Native reply-quote: anchor the feed message to the user's question so
6961
6939
  // it renders as a quoted header (reply_parameters renders on a real
6962
- // message; edits preserve it). Feed-only the legacy summary is left
6963
- // visually unchanged. allow_sending_without_reply so a deleted source
6964
- // can't drop the send.
6965
- const replyAnchor = DRAFT_MIRROR_ENABLED && turn.sourceMessageId != null
6940
+ // message; edits preserve it). allow_sending_without_reply so a deleted
6941
+ // source can't drop the send.
6942
+ const replyAnchor = turn.sourceMessageId != null
6966
6943
  ? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
6967
6944
  : {}
6968
6945
  try {
@@ -7090,7 +7067,6 @@ function handleSessionEvent(ev: SessionEvent): void {
7090
7067
  lastAssistantMsgId: null,
7091
7068
  lastAssistantDone: false,
7092
7069
  toolCallCount: 0,
7093
- toolActivity: makeEmptyActivityState(),
7094
7070
  activityMessageId: null,
7095
7071
  activityInFlight: null,
7096
7072
  activityPendingRender: null,
@@ -7228,51 +7204,20 @@ function handleSessionEvent(ev: SessionEvent): void {
7228
7204
  turn.orphanedReplyTimeoutId = null
7229
7205
  }
7230
7206
  // The model's real reply takes over as the authoritative
7231
- // surface, so delete the activity summary message — the user
7232
- // sees the real reply land in the same beat the summary
7233
- // disappears. Applies to both producers (legacy verb-count and
7234
- // the DRAFT_MIRROR feed); turn_end is the no-reply safety net.
7207
+ // surface, so delete the activity feed message — the user
7208
+ // sees the real reply land in the same beat the feed
7209
+ // disappears. turn_end is the no-reply safety net.
7235
7210
  if (wasFirstReply) {
7236
7211
  clearActivitySummary(turn)
7237
7212
  }
7238
7213
  }
7239
- // Tool-activity summary same shape Claude Code natively renders
7240
- // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7241
- // accumulates non-reply tool_use events into `turn.toolActivity`
7242
- // and sends ONE Telegram message that edits in place as more tools
7243
- // land. Stops editing once the model calls `reply` — the summary
7244
- // line stays as the final state. No model-side prompting; no per-
7245
- // tool labels. Just surface what's already in the stream.
7246
- //
7247
- // Single-flight coalescing (PR #1926 review): modern Claude emits
7248
- // multiple tool_uses in a synchronous burst (parallel Reads,
7249
- // Bashes, etc.). All would otherwise race past the message-id
7250
- // capture and produce N messages. Pattern mirrors answer-stream:
7251
- // update `activityPendingRender` synchronously here; a single
7252
- // worker promise drains the pending state, sending or editing
7253
- // exactly once at a time and re-running until pending matches
7254
- // the last-sent. Captures `turn` so a late drain after turn-swap
7255
- // can't corrupt the next turn's atom.
7256
- //
7257
- // This (flush-gated) tool_use path drives the summary ONLY when
7258
- // DRAFT_MIRROR is OFF: the legacy generic verb-count summary
7259
- // ("Ran 5 commands") via registerAndRender. When DRAFT_MIRROR is
7260
- // ON the summary is instead driven by the real-time `tool_label`
7261
- // event (PreToolUse sidecar, fires at tool-call time regardless of
7262
- // when claude flushes the transcript) — see `case 'tool_label'`.
7263
- // That's the determinism fix: on a fast/clustered-tool turn the
7264
- // JSONL tool_use rows aren't on disk until ~turn-end, so sourcing
7265
- // the feed here lost it; the sidecar is flush-independent. Both
7266
- // producers feed `activityPendingRender` and clear on first reply.
7267
- if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
7268
- const rendered = registerAndRender(turn.toolActivity, name)
7269
- if (rendered != null) {
7270
- turn.activityPendingRender = rendered
7271
- if (turn.activityInFlight == null) {
7272
- turn.activityInFlight = drainActivitySummary(turn)
7273
- }
7274
- }
7275
- }
7214
+ // The live activity feed is driven by the real-time `tool_label`
7215
+ // event (PreToolUse sidecar) rather than this flush-gated tool_use
7216
+ // path see `case 'tool_label'`. The sidecar fires at tool-call
7217
+ // time regardless of when claude flushes the transcript, which is
7218
+ // the determinism fix: on a fast/clustered-tool turn the JSONL
7219
+ // tool_use rows aren't on disk until ~turn-end, so sourcing the
7220
+ // feed here would lose them.
7276
7221
  if (!ctrl) return
7277
7222
  if (isTelegramSurfaceTool(name)) return
7278
7223
  ctrl.setTool(name)
@@ -7282,13 +7227,12 @@ function handleSessionEvent(ev: SessionEvent): void {
7282
7227
  return
7283
7228
  }
7284
7229
  case 'tool_label': {
7285
- // DRAFT_MIRROR real-time driver. The PreToolUse hook wrote this
7230
+ // Real-time activity-feed driver. The PreToolUse hook wrote this
7286
7231
  // label synchronously at tool-call time; the sidecar surfaced it
7287
7232
  // here (~250ms) independent of the transcript flush. Accumulate it
7288
7233
  // into the live feed and edit the activity message in place — this
7289
7234
  // is what makes the feed deterministic on fast/clustered-tool turns
7290
7235
  // where the JSONL tool_use rows arrive too late.
7291
- if (!DRAFT_MIRROR_ENABLED) return
7292
7236
  const turn = currentTurn
7293
7237
  if (turn == null) return
7294
7238
  // Surface tools (reply/stream_reply/react) are the conversation, not
@@ -7582,13 +7526,13 @@ function handleSessionEvent(ev: SessionEvent): void {
7582
7526
  clearTimeout(turn.orphanedReplyTimeoutId)
7583
7527
  turn.orphanedReplyTimeoutId = null
7584
7528
  }
7585
- // DRAFT_MIRROR: clear the activity feed at the real end of the turn.
7586
- // This is the no-reply safety net — a turn that ends without ever
7587
- // calling reply (the answer is delivered by turn-flush / silent-end)
7588
- // still has its feed removed. On a normal turn the feed was already
7589
- // cleared at the first reply (the hand-off); clearActivitySummary is
7590
- // idempotent, so the second call is a no-op.
7591
- if (DRAFT_MIRROR_ENABLED && turn != null) {
7529
+ // Clear the activity feed at the real end of the turn. This is the
7530
+ // no-reply safety net — a turn that ends without ever calling reply
7531
+ // (the answer is delivered by turn-flush / silent-end) still has its
7532
+ // feed removed. On a normal turn the feed was already cleared at the
7533
+ // first reply (the hand-off); clearActivitySummary is idempotent, so
7534
+ // the second call is a no-op.
7535
+ if (turn != null) {
7592
7536
  clearActivitySummary(turn)
7593
7537
  }
7594
7538
  // #549 fix — flush any pending preamble BEFORE the answer stream is
@@ -1,10 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- makeEmptyActivityState,
4
- register,
5
- formatSummary,
6
- registerAndRender,
7
- verbForTool,
8
3
  describeToolUse,
9
4
  appendActivityLine,
10
5
  appendActivityLabel,
@@ -77,217 +72,6 @@ describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () =>
77
72
  });
78
73
  });
79
74
 
80
- describe("verbForTool — tool name → past-tense verb", () => {
81
- it("maps standard CLI tools to readable verbs", () => {
82
- expect(verbForTool("Read")).toBe("read");
83
- expect(verbForTool("Write")).toBe("created");
84
- expect(verbForTool("Edit")).toBe("edited");
85
- expect(verbForTool("MultiEdit")).toBe("edited");
86
- expect(verbForTool("NotebookEdit")).toBe("edited");
87
- expect(verbForTool("Bash")).toBe("ran");
88
- expect(verbForTool("BashOutput")).toBe("ran");
89
- expect(verbForTool("WebSearch")).toBe("searched");
90
- expect(verbForTool("Grep")).toBe("searched");
91
- expect(verbForTool("Glob")).toBe("searched");
92
- expect(verbForTool("WebFetch")).toBe("fetched");
93
- expect(verbForTool("Task")).toBe("dispatched");
94
- expect(verbForTool("Agent")).toBe("dispatched");
95
- expect(verbForTool("TodoWrite")).toBe("noted");
96
- });
97
-
98
- it("skips user-facing switchroom-telegram tools (those ARE the surface)", () => {
99
- expect(verbForTool("mcp__switchroom-telegram__reply")).toBeNull();
100
- expect(verbForTool("mcp__switchroom-telegram__stream_reply")).toBeNull();
101
- expect(verbForTool("mcp__switchroom-telegram__edit_message")).toBeNull();
102
- expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
103
- });
104
-
105
- it("maps recognised MCP tools (hindsight, google-workspace, notion) to specific verbs", () => {
106
- // hindsight: recall/reflect → searched, retain/update_memory → saved
107
- expect(verbForTool("mcp__hindsight__reflect")).toBe("searched");
108
- expect(verbForTool("mcp__hindsight__recall")).toBe("searched");
109
- expect(verbForTool("mcp__hindsight__retain")).toBe("saved");
110
- expect(verbForTool("mcp__hindsight__update_memory")).toBe("saved");
111
- // google-workspace / claude.ai variants: read-shaped → searched, write-shaped → edited
112
- expect(verbForTool("mcp__google-workspace__list_files")).toBe("searched");
113
- expect(verbForTool("mcp__claude_ai_Gmail__search_messages")).toBe("searched");
114
- expect(verbForTool("mcp__google-workspace__create_file")).toBe("edited");
115
- expect(verbForTool("mcp__claude_ai_Google_Drive__download_file_content")).toBe("searched");
116
- // notion: query/get → searched, create/update → edited
117
- expect(verbForTool("mcp__notion__query_database")).toBe("searched");
118
- expect(verbForTool("mcp__claude_ai_Notion__notion-search")).toBe("searched");
119
- expect(verbForTool("mcp__claude_ai_Notion__notion-update-page")).toBe("edited");
120
- });
121
-
122
- it("returns 'used' for genuinely unknown MCP / future tools (generic fallback)", () => {
123
- expect(verbForTool("mcp__random-third-party__do_thing")).toBe("used");
124
- expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
125
- });
126
-
127
- it("returns null for empty toolName (defensive)", () => {
128
- expect(verbForTool("")).toBeNull();
129
- });
130
- });
131
-
132
- describe("register + formatSummary — Claude Code-style summary", () => {
133
- it("formats a single Read as 'Read a file'", () => {
134
- const s = makeEmptyActivityState();
135
- register(s, "Read");
136
- expect(formatSummary(s)).toBe("Read a file");
137
- });
138
-
139
- it("formats multiple Reads as 'Read N files'", () => {
140
- const s = makeEmptyActivityState();
141
- register(s, "Read");
142
- register(s, "Read");
143
- register(s, "Read");
144
- expect(formatSummary(s)).toBe("Read 3 files");
145
- });
146
-
147
- it("formats single Bash as 'Ran a command'", () => {
148
- const s = makeEmptyActivityState();
149
- register(s, "Bash");
150
- expect(formatSummary(s)).toBe("Ran a command");
151
- });
152
-
153
- it("formats multiple Bash as 'Ran N commands'", () => {
154
- const s = makeEmptyActivityState();
155
- for (let i = 0; i < 5; i++) register(s, "Bash");
156
- expect(formatSummary(s)).toBe("Ran 5 commands");
157
- });
158
-
159
- it("joins multiple verb-classes with commas (first-occurrence order)", () => {
160
- const s = makeEmptyActivityState();
161
- // Tools fire in this order: Read → Bash → Edit
162
- register(s, "Read");
163
- register(s, "Bash");
164
- register(s, "Edit");
165
- // The summary renders chronologically: read, ran, edited.
166
- expect(formatSummary(s)).toBe("Read a file, ran a command, edited a file");
167
- });
168
-
169
- it("matches the Claude Code screenshot examples", () => {
170
- // "Ran 5 commands, read a file"
171
- const s1 = makeEmptyActivityState();
172
- for (let i = 0; i < 5; i++) register(s1, "Bash");
173
- register(s1, "Read");
174
- expect(formatSummary(s1)).toBe("Ran 5 commands, read a file");
175
-
176
- // "Edited a file, read a file, ran a command"
177
- const s2 = makeEmptyActivityState();
178
- register(s2, "Edit");
179
- register(s2, "Read");
180
- register(s2, "Bash");
181
- expect(formatSummary(s2)).toBe("Edited a file, read a file, ran a command");
182
-
183
- // "Created a file, ran a command"
184
- const s3 = makeEmptyActivityState();
185
- register(s3, "Write");
186
- register(s3, "Bash");
187
- expect(formatSummary(s3)).toBe("Created a file, ran a command");
188
- });
189
-
190
- it("returns null when state is empty", () => {
191
- expect(formatSummary(makeEmptyActivityState())).toBeNull();
192
- });
193
-
194
- it("ignores user-facing tools (reply/stream_reply etc.)", () => {
195
- const s = makeEmptyActivityState();
196
- register(s, "mcp__switchroom-telegram__reply");
197
- register(s, "mcp__switchroom-telegram__stream_reply");
198
- expect(formatSummary(s)).toBeNull(); // nothing tracked
199
- });
200
-
201
- it("includes generic 'used' for genuinely-unknown MCP tools (fallback)", () => {
202
- const s = makeEmptyActivityState();
203
- register(s, "mcp__random-third-party__do_thing");
204
- expect(formatSummary(s)).toBe("Used a tool");
205
- register(s, "mcp__another-unknown-server__something_else");
206
- expect(formatSummary(s)).toBe("Used 2 tools");
207
- });
208
-
209
- it("maps recognised MCP tools to natural-language summaries (no generic 'Used N tools')", () => {
210
- // hindsight search shows up as 'searched' (memory)
211
- const s = makeEmptyActivityState();
212
- register(s, "mcp__hindsight__reflect");
213
- expect(formatSummary(s)).toBe("Ran a search");
214
- register(s, "mcp__hindsight__reflect");
215
- expect(formatSummary(s)).toBe("Ran 2 searches");
216
- // hindsight retain shows up as 'saved a memory'
217
- register(s, "mcp__hindsight__retain");
218
- expect(formatSummary(s)).toBe("Ran 2 searches, saved a memory");
219
- });
220
-
221
- it("tracks firstToolName for forensic / telemetry use", () => {
222
- const s = makeEmptyActivityState();
223
- register(s, "Read");
224
- register(s, "Bash");
225
- expect(s.firstToolName).toBe("Read");
226
- });
227
- });
228
-
229
- describe("parallel-tool-use coalescing — render only reflects accumulated state", () => {
230
- it("synchronous burst of N tool_uses produces the right summary at each step", () => {
231
- // Modern Claude emits parallel tool_uses in a tight sync loop. The
232
- // gateway calls register() N times before any async drain runs.
233
- // After N registers, the rendered string should reflect ALL of them
234
- // — so when the drain fires once with the latest pendingRender, the
235
- // sent text is correct and complete.
236
- const s = makeEmptyActivityState();
237
- register(s, "Read");
238
- register(s, "Read");
239
- register(s, "Read");
240
- register(s, "Bash");
241
- register(s, "Bash");
242
- expect(formatSummary(s)).toBe("Read 3 files, ran 2 commands");
243
- });
244
-
245
- it("ordering is preserved across a chronological burst", () => {
246
- const s = makeEmptyActivityState();
247
- // Simulates: Bash, then Read, then Bash, then Read, then Edit
248
- register(s, "Bash");
249
- register(s, "Read");
250
- register(s, "Bash");
251
- register(s, "Read");
252
- register(s, "Edit");
253
- // Bash was first, then Read, then Edit. Counts: bash 2, read 2, edit 1.
254
- expect(formatSummary(s)).toBe(
255
- "Ran 2 commands, read 2 files, edited a file",
256
- );
257
- });
258
-
259
- it("registerAndRender returns null on user-facing tools (no race contribution)", () => {
260
- const s = makeEmptyActivityState();
261
- register(s, "Read");
262
- // A reply tool fires concurrently — should not enter the activity state.
263
- expect(
264
- registerAndRender(s, "mcp__switchroom-telegram__reply"),
265
- ).toBeNull();
266
- // State still reflects only the Read.
267
- expect(formatSummary(s)).toBe("Read a file");
268
- });
269
- });
270
-
271
- describe("registerAndRender — ergonomic full-pipeline call", () => {
272
- it("returns the updated rendered text on a real tool (chronological)", () => {
273
- const s = makeEmptyActivityState();
274
- expect(registerAndRender(s, "Read")).toBe("Read a file");
275
- // Bash fires AFTER Read — chronological order shows read first.
276
- expect(registerAndRender(s, "Bash")).toBe(
277
- "Read a file, ran a command",
278
- );
279
- });
280
-
281
- it("returns null on a surface tool (no-op)", () => {
282
- const s = makeEmptyActivityState();
283
- expect(
284
- registerAndRender(s, "mcp__switchroom-telegram__reply"),
285
- ).toBeNull();
286
- // State unchanged
287
- expect(s.firstToolName).toBeNull();
288
- });
289
- });
290
-
291
75
  describe("appendActivityLine + renderActivityFeed — accumulating activity feed", () => {
292
76
  it("accumulates distinct actions chronologically (newest = current → bold, earlier = done ✓ italic)", () => {
293
77
  const lines: string[] = [];