switchroom 0.13.65 → 0.14.0

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.
@@ -57,6 +57,7 @@ import { allocateDraftId } from '../draft-transport.js'
57
57
  import {
58
58
  makeEmptyActivityState,
59
59
  registerAndRender,
60
+ describeToolUse,
60
61
  type ActivityState,
61
62
  } from '../tool-activity-summary.js'
62
63
  import { toolLabel } from '../tool-labels.js'
@@ -6837,7 +6838,12 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6837
6838
  while (turn.activityPendingRender !== turn.activityLastSentRender) {
6838
6839
  const target = turn.activityPendingRender
6839
6840
  if (target == null) break
6840
- const html = `<i>${target}</i>`
6841
+ // Escape before wrapping in <i> + parse_mode HTML. The legacy
6842
+ // verb-count summaries were safe ASCII, but the draft-mirror's
6843
+ // describeToolUse content (file names, Bash descriptions, search
6844
+ // queries) can contain <, >, & — which would break HTML parsing
6845
+ // and surface literal tags (the exact #1942 bug class).
6846
+ const html = `<i>${escapeHtmlForTg(target)}</i>`
6841
6847
  const chat = turn.sessionChatId
6842
6848
  const thread = turn.sessionThreadId
6843
6849
  // sendMessageDraft doesn't support forum threads.
@@ -7130,14 +7136,21 @@ function handleSessionEvent(ev: SessionEvent): void {
7130
7136
  // exactly once at a time and re-running until pending matches
7131
7137
  // the last-sent. Captures `turn` so a late drain after turn-swap
7132
7138
  // can't corrupt the next turn's atom.
7133
- // DRAFT_MIRROR (RFC draft-mirror-preview, Phase 1): the model's
7134
- // prose narration owns the single per-chat draft slot. Suppress
7135
- // the activity-summary tool-count draft so the two don't collide
7136
- // (Telegram shows one draft per chat the later write clobbers
7137
- // the earlier). The activity-summary code stays intact for the
7138
- // kill-switch path; it's retired for good only in Phase 4.
7139
- if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
7140
- const rendered = registerAndRender(turn.toolActivity, name)
7139
+ // DRAFT_MIRROR (RFC draft-mirror-preview): render each tool_use as a
7140
+ // human-friendly line in the live preview, using the model-authored
7141
+ // descriptive field (Bash.description, Read/Edit file basename,
7142
+ // hindsight→"Searching memory", etc.see describeToolUse). Latest
7143
+ // action wins (the draft shows "doing X" live), clears on reply.
7144
+ // Never surfaces raw shell/query syntax option A, uniform across
7145
+ // code + non-code agents.
7146
+ //
7147
+ // Flag OFF (default): the legacy generic verb-count summary
7148
+ // ("Ran 5 commands") via registerAndRender — byte-identical to
7149
+ // pre-draft-mirror behavior.
7150
+ if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
7151
+ const rendered = DRAFT_MIRROR_ENABLED
7152
+ ? describeToolUse(name, ev.input)
7153
+ : registerAndRender(turn.toolActivity, name)
7141
7154
  if (rendered != null) {
7142
7155
  turn.activityPendingRender = rendered
7143
7156
  if (turn.activityInFlight == null) {
@@ -7185,19 +7198,19 @@ function handleSessionEvent(ev: SessionEvent): void {
7185
7198
  isPrivateChat: turn.isDm,
7186
7199
  threadId: turn.sessionThreadId,
7187
7200
  // Transport selection:
7188
- // - DRAFT_MIRROR (RFC draft-mirror-preview, Phase 1): force
7189
- // the ephemeral compose-area draft so narration is a
7190
- // clears-on-reply preview. Wins over visible-answer-stream.
7191
- // No-reply delivery is owned by turn-flush, not materialize.
7192
- // - else #869-Phase1 visible-answer-stream: omit the draft
7193
- // API so the lane edits a user-visible chat-timeline
7194
- // message (minInitialChars:1 opens it on the first chunk).
7195
- // - else legacy: draft transport.
7196
- ...(DRAFT_MIRROR_ENABLED
7197
- ? { sendMessageDraft: sendMessageDraftFn }
7198
- : ANSWER_STREAM_VISIBLE_ENABLED
7199
- ? { minInitialChars: 1 }
7200
- : { sendMessageDraft: sendMessageDraftFn }),
7201
+ // #869-Phase1 visible-answer-stream: omit the draft API so
7202
+ // the lane edits a user-visible chat-timeline message
7203
+ // (minInitialChars:1 opens it on the first chunk). The
7204
+ // draft-mirror does NOT touch this lane the canary proved
7205
+ // the model emits almost no interstitial assistant.text
7206
+ // (it thinks→tool→reply), so routing it to the draft just
7207
+ // emptied the preview. The draft-mirror instead renders the
7208
+ // tool_use stream (case 'tool_use' above) where the real
7209
+ // signal lives. assistant.text keeps its visible-message
7210
+ // home; the reply tool stays the canonical answer.
7211
+ ...(ANSWER_STREAM_VISIBLE_ENABLED
7212
+ ? { minInitialChars: 1 }
7213
+ : { sendMessageDraft: sendMessageDraftFn }),
7201
7214
  // #1075: route through robustApiCall so flood-wait,
7202
7215
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
7203
7216
  // instead of crashing the answer-stream loop on a deleted
@@ -5,8 +5,74 @@ import {
5
5
  formatSummary,
6
6
  registerAndRender,
7
7
  verbForTool,
8
+ describeToolUse,
8
9
  } from "../tool-activity-summary.js";
9
10
 
11
+ describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () => {
12
+ it("Bash uses the model-authored description verbatim, never the command", () => {
13
+ expect(
14
+ describeToolUse("Bash", { command: "ls -la /tmp", description: "List workspace" }),
15
+ ).toBe("List workspace");
16
+ // No description → safe generic, still never the raw command.
17
+ expect(describeToolUse("Bash", { command: "grep -r foo ." })).toBe("Running a command");
18
+ });
19
+
20
+ it("Read/Edit/Write render the file basename, not the full path", () => {
21
+ expect(describeToolUse("Read", { file_path: "/home/ken/code/switchroom/gateway.ts" })).toBe(
22
+ "Reading gateway.ts",
23
+ );
24
+ expect(describeToolUse("Edit", { file_path: "/a/b/CLAUDE.md" })).toBe("Editing CLAUDE.md");
25
+ expect(describeToolUse("Write", { file_path: "notes.txt" })).toBe("Writing notes.txt");
26
+ expect(describeToolUse("Read", {})).toBe("Reading a file");
27
+ });
28
+
29
+ it("Grep/Glob show the pattern; WebFetch shows the hostname", () => {
30
+ expect(describeToolUse("Grep", { pattern: "TODO" })).toBe("Searching for TODO");
31
+ expect(describeToolUse("WebFetch", { url: "https://www.example.com/path?q=1" })).toBe(
32
+ "Reading example.com",
33
+ );
34
+ expect(describeToolUse("WebSearch", { query: "best running shoes" })).toBe(
35
+ "Searching the web for best running shoes",
36
+ );
37
+ });
38
+
39
+ it("Task/Agent surface the sub-agent task description", () => {
40
+ expect(describeToolUse("Task", { description: "Review the migration" })).toBe(
41
+ "Delegating: Review the migration",
42
+ );
43
+ });
44
+
45
+ it("domain MCP tools render human-meaningful labels (no jargon)", () => {
46
+ expect(describeToolUse("mcp__hindsight__reflect", { query: "x" })).toBe("Searching memory");
47
+ expect(describeToolUse("mcp__hindsight__retain", {})).toBe("Saving to memory");
48
+ expect(describeToolUse("mcp__claude_ai_Google_Calendar__list_events", {})).toBe(
49
+ "Checking your calendar",
50
+ );
51
+ expect(describeToolUse("mcp__claude_ai_Gmail__search", {})).toBe("Checking your email");
52
+ expect(describeToolUse("mcp__claude_ai_Google_Drive__search_files", {})).toBe(
53
+ "Looking through your files",
54
+ );
55
+ expect(describeToolUse("mcp__claude_ai_Notion__notion-search", {})).toBe("Checking your notes");
56
+ });
57
+
58
+ it("surface tools (reply/stream_reply) return null — never mirrored", () => {
59
+ expect(describeToolUse("mcp__switchroom-telegram__reply", { text: "hi" })).toBeNull();
60
+ expect(describeToolUse("mcp__switchroom-telegram__stream_reply", {})).toBeNull();
61
+ });
62
+
63
+ it("unknown MCP tool prefers a model-authored field, else humanizes the name", () => {
64
+ expect(describeToolUse("mcp__acme__do_thing", { description: "Fetched the report" })).toBe(
65
+ "Fetched the report",
66
+ );
67
+ expect(describeToolUse("mcp__acme__do_thing", {})).toBe("Using do thing");
68
+ });
69
+
70
+ it("unknown built-in falls back to a generic working line, never raw syntax", () => {
71
+ expect(describeToolUse("SomeFutureTool", {})).toBe("Working…");
72
+ expect(describeToolUse("", {})).toBeNull();
73
+ });
74
+ });
75
+
10
76
  describe("verbForTool — tool name → past-tense verb", () => {
11
77
  it("maps standard CLI tools to readable verbs", () => {
12
78
  expect(verbForTool("Read")).toBe("read");
@@ -198,3 +198,140 @@ export function registerAndRender(
198
198
  if (!changed) return null;
199
199
  return formatSummary(state);
200
200
  }
201
+
202
+ // ─── Friendly per-tool rendering (draft-mirror, RFC draft-mirror-preview) ───
203
+ //
204
+ // Claude Code's own UI reads human-friendly because the model AUTHORS the
205
+ // descriptive text inside each tool_use.input — verified against a real
206
+ // session JSONL (1360 Bash calls etc.):
207
+ // Bash → input.description ("Get CLAUDE.md size and recent history")
208
+ // Read → input.file_path (basename → "Reading CLAUDE.md")
209
+ // Edit/Write → input.file_path (basename)
210
+ // Grep/Glob → input.pattern
211
+ // Task/Agent → input.description (the sub-agent's task)
212
+ // WebFetch → input.url (hostname → "Reading example.com")
213
+ // hindsight → friendly label ("Searching memory")
214
+ // There is never a raw `grep`/`jq`/`ls` to surface — only the model's own
215
+ // plain-English description or a domain label. This is the signal the
216
+ // draft-mirror renders (option A: uniform across code + non-code agents).
217
+
218
+ /** Strip a path to its basename for display. */
219
+ function baseName(p: unknown): string | null {
220
+ if (typeof p !== "string" || p.length === 0) return null;
221
+ const parts = p.split("/").filter(Boolean);
222
+ return parts.length > 0 ? parts[parts.length - 1] : p;
223
+ }
224
+
225
+ /** Extract a bare hostname from a URL for display (no scheme/path). */
226
+ function hostName(u: unknown): string | null {
227
+ if (typeof u !== "string" || u.length === 0) return null;
228
+ try {
229
+ return new URL(u).hostname.replace(/^www\./, "");
230
+ } catch {
231
+ return u.replace(/^https?:\/\//, "").split("/")[0] || null;
232
+ }
233
+ }
234
+
235
+ function clip(s: unknown, n: number): string | null {
236
+ if (typeof s !== "string") return null;
237
+ const t = s.trim();
238
+ if (t.length === 0) return null;
239
+ return t.length > n ? t.slice(0, n - 1) + "…" : t;
240
+ }
241
+
242
+ /**
243
+ * Render a single tool_use into a human-friendly, present-tense activity
244
+ * line for the live draft preview — or null when the tool should NOT be
245
+ * surfaced (the Telegram-plugin surface tools, which ARE the conversation).
246
+ *
247
+ * Leads with the model-authored descriptive field per the map above; falls
248
+ * back to a domain label, then to a humanized tool name. Never emits raw
249
+ * shell/query syntax.
250
+ */
251
+ export function describeToolUse(
252
+ toolName: string,
253
+ input: Record<string, unknown> | undefined,
254
+ ): string | null {
255
+ if (!toolName) return null;
256
+ const inp = input ?? {};
257
+
258
+ const mcpMatch = /^mcp__(.+?)__(.+)$/.exec(toolName);
259
+ if (mcpMatch) {
260
+ const server = mcpMatch[1].toLowerCase();
261
+ const tool = mcpMatch[2].toLowerCase();
262
+ // Surface tools ARE the conversation — never mirror them.
263
+ if (server === "switchroom-telegram") return null;
264
+ if (server === "hindsight") {
265
+ if (tool === "recall" || tool === "reflect") return "Searching memory";
266
+ if (tool === "retain" || tool === "update_memory" || tool === "sync_retain")
267
+ return "Saving to memory";
268
+ return "Working with memory";
269
+ }
270
+ if (
271
+ server === "google-workspace" ||
272
+ server === "claude_ai_google_calendar"
273
+ ) {
274
+ return "Checking your calendar";
275
+ }
276
+ if (server === "claude_ai_gmail") return "Checking your email";
277
+ if (server === "claude_ai_google_drive") return "Looking through your files";
278
+ if (server === "notion" || server === "claude_ai_notion") {
279
+ return "Checking your notes";
280
+ }
281
+ // Unknown MCP tool: prefer a model-authored field, else a humanized name.
282
+ const desc = clip(inp.description, 60) ?? clip(inp.query, 50) ?? clip(inp.title, 50);
283
+ if (desc) return desc;
284
+ return "Using " + tool.replace(/[-_]+/g, " ");
285
+ }
286
+
287
+ switch (toolName) {
288
+ case "Bash": {
289
+ // The model writes a plain-English description for every command.
290
+ return clip(inp.description, 70) ?? "Running a command";
291
+ }
292
+ case "BashOutput":
293
+ case "KillShell":
294
+ return "Managing a background command";
295
+ case "Read": {
296
+ const f = baseName(inp.file_path);
297
+ return f ? `Reading ${f}` : "Reading a file";
298
+ }
299
+ case "Edit":
300
+ case "MultiEdit":
301
+ case "NotebookEdit": {
302
+ const f = baseName(inp.file_path) ?? baseName(inp.notebook_path);
303
+ return f ? `Editing ${f}` : "Editing a file";
304
+ }
305
+ case "Write": {
306
+ const f = baseName(inp.file_path);
307
+ return f ? `Writing ${f}` : "Writing a file";
308
+ }
309
+ case "Grep":
310
+ case "Glob": {
311
+ const p = clip(inp.pattern, 40);
312
+ return p ? `Searching for ${p}` : "Searching files";
313
+ }
314
+ case "WebFetch": {
315
+ const h = hostName(inp.url);
316
+ return h ? `Reading ${h}` : "Reading a web page";
317
+ }
318
+ case "WebSearch": {
319
+ const q = clip(inp.query, 50);
320
+ return q ? `Searching the web for ${q}` : "Searching the web";
321
+ }
322
+ case "Task":
323
+ case "Agent": {
324
+ const d = clip(inp.description, 60);
325
+ return d ? `Delegating: ${d}` : "Delegating to a sub-agent";
326
+ }
327
+ case "TodoWrite":
328
+ case "TaskCreate":
329
+ case "TaskUpdate":
330
+ case "TaskList":
331
+ return "Updating the plan";
332
+ case "ToolSearch":
333
+ return "Finding the right tool";
334
+ default:
335
+ return "Working…";
336
+ }
337
+ }