grok-telegram-bot 2.3.0 → 2.4.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.
Files changed (68) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +55 -0
  3. package/package.json +1 -1
  4. package/scripts/analyze-jsonl.ts +33 -0
  5. package/scripts/delayed-restart.ps1 +29 -0
  6. package/scripts/probe-exit-response-shape.py +77 -0
  7. package/scripts/probe-plan-exit.py +60 -0
  8. package/scripts/probe-plan-exit2.py +48 -0
  9. package/scripts/probe-plan-fields.py +41 -0
  10. package/scripts/probe-plan-fields2.py +58 -0
  11. package/scripts/probe-plan-response-path.py +48 -0
  12. package/scripts/sample-claude-tooluse.ts +21 -0
  13. package/scripts/sample-kiro-events.ts +31 -0
  14. package/scripts/smoke-exit-plan.ts +274 -0
  15. package/scripts/smoke-exit-shapes.ts +252 -0
  16. package/scripts/smoke-import.mjs +82 -0
  17. package/scripts/smoke-import.ts +73 -0
  18. package/src/app/accounts.ts +84 -0
  19. package/src/app/instance-lock.ts +6 -0
  20. package/src/app/types.ts +19 -2
  21. package/src/app/updater.ts +17 -6
  22. package/src/app/usage.ts +204 -7
  23. package/src/bot/account-rotator.ts +71 -2
  24. package/src/bot/bot.ts +36 -0
  25. package/src/bot/chat-controller.ts +35 -0
  26. package/src/bot/commands.ts +2 -0
  27. package/src/bot/complexity-gate.ts +69 -0
  28. package/src/bot/deps.ts +19 -0
  29. package/src/bot/handlers/accounts.ts +55 -5
  30. package/src/bot/handlers/import-session.ts +290 -0
  31. package/src/bot/handlers/menu.ts +17 -38
  32. package/src/bot/handlers/message.ts +1 -0
  33. package/src/bot/handlers/running.ts +35 -5
  34. package/src/bot/handlers/session-card.ts +12 -0
  35. package/src/bot/handlers/sessions.ts +14 -3
  36. package/src/bot/handlers/usage.ts +118 -16
  37. package/src/bot/menu/keyboard.ts +5 -4
  38. package/src/bot/menu/status-panel.ts +19 -6
  39. package/src/bot/prompt-content.ts +4 -0
  40. package/src/bot/reauth-controller.ts +2 -2
  41. package/src/bot/session-fork.ts +11 -0
  42. package/src/bot/session-runtime.ts +831 -64
  43. package/src/bot/suggestions.ts +429 -0
  44. package/src/config.ts +41 -0
  45. package/src/grok/client.ts +106 -20
  46. package/src/grok/plan-approval.ts +72 -0
  47. package/src/grok/session-log.ts +16 -0
  48. package/src/grok/types.ts +21 -2
  49. package/src/import/build-import.ts +132 -0
  50. package/src/import/history-readers.ts +681 -0
  51. package/src/import/list-running.ts +100 -0
  52. package/src/import/sources.ts +78 -0
  53. package/src/index.ts +179 -24
  54. package/src/render/diff.ts +11 -2
  55. package/src/render/file-summary.ts +31 -1
  56. package/src/render/markdown.ts +293 -35
  57. package/src/render/plan.ts +127 -0
  58. package/src/render/session-comment.ts +261 -0
  59. package/src/render/tool-call-detail.ts +400 -19
  60. package/src/render/tool-call-merge.ts +115 -0
  61. package/src/render/tool-call.ts +405 -142
  62. package/src/render/truncate.ts +85 -0
  63. package/src/service/windows.ts +14 -2
  64. package/src/sessions/history.ts +57 -0
  65. package/src/sessions/store.ts +3 -0
  66. package/src/sessions/types.ts +5 -0
  67. package/src/stream/streamer.ts +73 -9
  68. package/src/tasks/runner.ts +4 -3
@@ -1,24 +1,59 @@
1
1
  /**
2
2
  * tool-call-detail.ts
3
3
  *
4
- * Rich detail extractors for specific tool kinds: search queries, file reads,
5
- * URLs, writes/creates content previews, move/rename source+dest, delete paths,
6
- * web search queries, and MCP argument previews.
7
- *
8
- * Each function returns a RAW markdown string (code blocks, etc.) appended after
9
- * the tool-call icon + title line.
4
+ * Rich detail extractors + tool identity resolution for ACP tool updates.
5
+ * Maps Grok tool names (read_file, run_terminal_command, grep, …) to display
6
+ * kinds and pulls path / command / query / offset args from many raw shapes.
10
7
  */
11
- import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
8
+ import { contentText, type SessionUpdate, type ToolCallContent } from "../grok/types.js";
12
9
 
13
10
  /** Max chars to show for search queries, command previews, etc. */
14
11
  export const PREVIEW_MAX = 600;
15
12
  /** Max chars for file content preview on write/create. */
16
13
  export const CONTENT_PREVIEW_MAX = 1000;
14
+ /** Max chars for terminal / tool result bodies (display-only middle truncate). */
15
+ export const OUTPUT_PREVIEW_MAX = 3500;
16
+ /** Max lines for multi-line tool outputs before middle-omission. */
17
+ export const OUTPUT_PREVIEW_LINES = 80;
18
+
19
+ /**
20
+ * Canonical display kinds used by the Telegram renderer.
21
+ * (ACP often reports kind "other" — we re-derive from the tool name.)
22
+ */
23
+ export type ToolDisplayKind =
24
+ | "read"
25
+ | "edit"
26
+ | "write"
27
+ | "create"
28
+ | "execute"
29
+ | "search"
30
+ | "delete"
31
+ | "move"
32
+ | "rename"
33
+ | "fetch"
34
+ | "web_search"
35
+ | "web_fetch"
36
+ | "list"
37
+ | "todo"
38
+ | "think"
39
+ | "image"
40
+ | "mcp"
41
+ | "other";
42
+
43
+ export interface ToolIdentity {
44
+ /** Canonical kind for icons + formatters. */
45
+ kind: ToolDisplayKind;
46
+ /** Raw tool name when known (read_file, grep, server__tool, …). */
47
+ toolName: string;
48
+ /** True when this is a real namespaced MCP call. */
49
+ isMcp: boolean;
50
+ mcpServer?: string;
51
+ mcpMethod?: string;
52
+ }
17
53
 
18
- /** Normalize a tool-call kind string to a canonical lowercase value. */
54
+ /** Normalize a loose ACP kind string (aliases only no name inference). */
19
55
  export function normalizeKind(kind: string | undefined): string {
20
56
  const k = (kind || "other").toLowerCase().trim();
21
- // Map common variants to canonical kinds.
22
57
  const ALIASES: Record<string, string> = {
23
58
  bash: "execute",
24
59
  shell: "execute",
@@ -28,8 +63,8 @@ export function normalizeKind(kind: string | undefined): string {
28
63
  glob: "search",
29
64
  find: "search",
30
65
  ripgrep: "search",
31
- "web_search": "web_search",
32
- "web_fetch": "fetch",
66
+ web_search: "web_search",
67
+ web_fetch: "fetch",
33
68
  url: "fetch",
34
69
  http: "fetch",
35
70
  request: "fetch",
@@ -37,18 +72,223 @@ export function normalizeKind(kind: string | undefined): string {
37
72
  copy: "move",
38
73
  mkdir: "create",
39
74
  touch: "create",
75
+ list: "list",
76
+ ls: "list",
40
77
  };
41
78
  return ALIASES[k] ?? k;
42
79
  }
43
80
 
44
- /** Extract the primary file path from a tool-call raw input. */
45
- export function extractPath(raw: Record<string, unknown>): string {
81
+ /**
82
+ * Resolve display identity from ACP kind + title + rawInput.
83
+ * Prefer the real tool name (read_file / grep / …) over a vague ACP `other`.
84
+ */
85
+ export function resolveToolIdentity(u: SessionUpdate, raw: Record<string, unknown>): ToolIdentity {
86
+ const toolName = extractToolName(u, raw);
87
+ const lower = toolName.toLowerCase();
88
+ const acpKind = normalizeKind(u.kind);
89
+
90
+ // True MCP only when namespaced (server__tool, @server/tool, …).
91
+ const mcp = parseMcpName(toolName);
92
+ if (mcp) {
93
+ // Still map known methods (e.g. filesystem__read_file) to a display kind.
94
+ const methodKind = kindFromToolName(mcp.method);
95
+ const kind: ToolDisplayKind =
96
+ methodKind && methodKind !== "other" ? methodKind : "mcp";
97
+ return {
98
+ kind,
99
+ toolName,
100
+ isMcp: true,
101
+ mcpServer: mcp.server,
102
+ mcpMethod: mcp.method,
103
+ };
104
+ }
105
+
106
+ const fromName = kindFromToolName(toolName) || kindFromTitle(u.title);
107
+ // ACP kinds that are specific win; "other"/empty defer to the name.
108
+ if (acpKind && acpKind !== "other" && acpKind !== "") {
109
+ // Name is more specific for Grok tools (kind=other often, or wrong kind).
110
+ if (fromName && fromName !== "other" && (acpKind === "other" || looksLikeGrokToolName(lower))) {
111
+ return { kind: fromName, toolName, isMcp: false };
112
+ }
113
+ return { kind: (acpKind as ToolDisplayKind) || "other", toolName, isMcp: false };
114
+ }
115
+ return { kind: fromName ?? "other", toolName, isMcp: false };
116
+ }
117
+
118
+ /** Extract the tool name from update fields / raw input / title. */
119
+ export function extractToolName(u: SessionUpdate, raw: Record<string, unknown>): string {
120
+ // ACP top-level name (RFD) and common Grok extensions first.
121
+ const top =
122
+ strOf(u.name) ||
123
+ strOf(u.toolName) ||
124
+ strOf((u as { tool_name?: unknown }).tool_name);
125
+ if (top) return top.trim();
126
+
127
+ const explicit =
128
+ strOf(raw.tool_name) ||
129
+ strOf(raw.toolName) ||
130
+ strOf(raw.name) ||
131
+ strOf(raw.tool) ||
132
+ strOf(raw.function) ||
133
+ strOf(raw.function_name);
134
+ if (explicit) return explicit.trim();
135
+ const t = (u.title || "").trim();
136
+ // Title that looks like an identifier (read_file, run_terminal_command).
137
+ if (t && /^[@a-zA-Z0-9._/-]+$/.test(t) && !t.includes(" ") && !/^tool[_ ]?call$/i.test(t)) {
138
+ return t;
139
+ }
140
+ return "";
141
+ }
142
+
143
+ /** Map a tool name (or MCP method) to a display kind. */
144
+ export function kindFromToolName(name: string): ToolDisplayKind | undefined {
145
+ if (!name) return undefined;
146
+ const n = name.toLowerCase().replace(/[\s-]+/g, "_");
147
+
148
+ // Exact / suffix matches for Grok Build + common agent tools.
149
+ if (
150
+ /^(read_file|read|fs_read|file_read|view|open_file|cat)$/.test(n) ||
151
+ n.endsWith("_read") ||
152
+ n.includes("read_file")
153
+ ) {
154
+ return "read";
155
+ }
156
+ if (
157
+ /^(write|write_file|fs_write|create_file|create)$/.test(n) ||
158
+ n.includes("write_file") ||
159
+ n === "write"
160
+ ) {
161
+ return "write";
162
+ }
163
+ if (
164
+ /^(search_replace|str_replace|string_replace|edit|edit_file|apply_patch|multi_edit|smart_edit)$/.test(n) ||
165
+ n.includes("replace") ||
166
+ n.includes("search_replace")
167
+ ) {
168
+ return "edit";
169
+ }
170
+ if (/^(delete|delete_file|rm|unlink|remove_file)$/.test(n) || n.includes("delete_file")) {
171
+ return "delete";
172
+ }
173
+ if (/^(move|rename|mv|copy|cp)$/.test(n)) {
174
+ return n.includes("rename") ? "rename" : "move";
175
+ }
176
+ if (
177
+ /^(run_terminal_command|run_terminal|shell|bash|execute|execute_bash|command|terminal)$/.test(n) ||
178
+ n.includes("terminal") ||
179
+ n.includes("shell") ||
180
+ n.startsWith("run_")
181
+ ) {
182
+ return "execute";
183
+ }
184
+ if (
185
+ /^(grep|glob|search|rg|smart_grep|smart_glob|find_files|codebase_search)$/.test(n) ||
186
+ n.includes("grep") ||
187
+ n.includes("glob") ||
188
+ n.endsWith("_search")
189
+ ) {
190
+ // web_search handled below
191
+ if (n.includes("web")) return "web_search";
192
+ return "search";
193
+ }
194
+ if (/^(list_dir|list|ls|listdir|list_directory|read_dir)$/.test(n) || n.includes("list_dir")) {
195
+ return "list";
196
+ }
197
+ if (/^(web_search|websearch)$/.test(n) || (n.includes("web") && n.includes("search"))) {
198
+ return "web_search";
199
+ }
200
+ if (
201
+ /^(web_fetch|webfetch|open_page|browse|fetch_url|http_request)$/.test(n) ||
202
+ n.includes("web_fetch") ||
203
+ n.includes("open_page")
204
+ ) {
205
+ return "web_fetch";
206
+ }
207
+ if (/^(todo_write|todo_list|todowrite|todoread)$/.test(n) || n.includes("todo")) {
208
+ return "todo";
209
+ }
210
+ if (
211
+ /^(spawn_subagent|task|delegate|subagent|think|thinking)$/.test(n) ||
212
+ n.includes("subagent") ||
213
+ n.includes("spawn")
214
+ ) {
215
+ return "think";
216
+ }
217
+ if (/image|video|imagine|nano.?banana|screenshot/.test(n)) {
218
+ return "image";
219
+ }
220
+ // Broad regex fallbacks (same spirit as models.toolKind).
221
+ if (/write|edit|create|apply|patch|str_replace|delete|move|mkdir/.test(n) && !/read/.test(n)) {
222
+ if (/delete|rm|unlink/.test(n)) return "delete";
223
+ if (/move|rename/.test(n)) return "move";
224
+ if (/write|create|mkdir|touch/.test(n)) return "write";
225
+ return "edit";
226
+ }
227
+ if (/read|view|open|cat|stat/.test(n)) return "read";
228
+ if (/search|grep|find|glob/.test(n)) return "search";
229
+ if (/bash|shell|exec|run|command|terminal|process/.test(n)) return "execute";
230
+ if (/fetch|http|curl|browse/.test(n)) return "fetch";
231
+ return undefined;
232
+ }
233
+
234
+ function kindFromTitle(title: string | undefined): ToolDisplayKind | undefined {
235
+ if (!title) return undefined;
236
+ const t = title.toLowerCase();
237
+ if (/^read\b|reading\b/.test(t)) return "read";
238
+ if (/^edit\b|^write\b|^create\b|edited|writing/.test(t)) return "edit";
239
+ if (/^run\b|^exec\b|command|shell|bash|terminal/.test(t)) return "execute";
240
+ if (/search|grep|glob|find/.test(t)) return "search";
241
+ if (/delete|remove/.test(t)) return "delete";
242
+ if (/fetch|http|url/.test(t)) return "fetch";
243
+ if (/web search/.test(t)) return "web_search";
244
+ if (/list|directory|folder/.test(t)) return "list";
245
+ return kindFromToolName(title.replace(/\s+/g, "_"));
246
+ }
247
+
248
+ function looksLikeGrokToolName(n: string): boolean {
249
+ return (
250
+ n.includes("_") ||
251
+ /^(read|write|grep|glob|shell|bash|edit|search|list|todo|fetch|run)/i.test(n)
252
+ );
253
+ }
254
+
255
+ /** Parse server__method / @server/method MCP-style names. */
256
+ export function parseMcpName(name: string): { server: string; method: string } | undefined {
257
+ if (!name) return undefined;
258
+ const patterns = [
259
+ /^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i,
260
+ /^([a-z0-9.-]+)___(.+)$/i,
261
+ /^([a-z0-9.-]+)__(.+)$/i,
262
+ /^([a-z0-9.-]+)\/([a-z0-9_.-]+)$/i,
263
+ ];
264
+ for (const re of patterns) {
265
+ const m = re.exec(name);
266
+ if (m) return { server: m[1]!, method: m[2]! };
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ /** Extract the primary file path from a tool-call raw input (+ locations). */
272
+ export function extractPath(raw: Record<string, unknown>, u?: SessionUpdate): string {
273
+ const fromLoc = u?.locations?.find((l) => l.path)?.path;
46
274
  return (
275
+ strOf(fromLoc) ||
47
276
  strOf(raw.path) ||
48
277
  strOf(raw.file_path) ||
278
+ strOf(raw.filePath) ||
279
+ strOf(raw.target_file) ||
280
+ strOf(raw.targetFile) ||
281
+ strOf(raw.target_path) ||
282
+ strOf(raw.targetPath) ||
49
283
  strOf(raw.filename) ||
50
284
  strOf(raw.file) ||
51
- strOf(raw.filePath) ||
285
+ strOf(raw.uri) ||
286
+ // Nested args (some MCP wrappers).
287
+ strOf(nested(raw, "arguments", "path")) ||
288
+ strOf(nested(raw, "arguments", "file_path")) ||
289
+ strOf(nested(raw, "arguments", "target_file")) ||
290
+ strOf(nested(raw, "input", "path")) ||
291
+ strOf(nested(raw, "input", "target_file")) ||
52
292
  ""
53
293
  );
54
294
  }
@@ -77,6 +317,8 @@ export function extractSearchQuery(raw: Record<string, unknown>): string {
77
317
  strOf(raw.glob) ||
78
318
  strOf(raw.term) ||
79
319
  strOf(raw.q) ||
320
+ strOf(nested(raw, "arguments", "pattern")) ||
321
+ strOf(nested(raw, "arguments", "query")) ||
80
322
  ""
81
323
  );
82
324
  }
@@ -90,6 +332,8 @@ export function extractSearchPath(raw: Record<string, unknown>): string {
90
332
  strOf(raw.scope) ||
91
333
  strOf(raw.cwd) ||
92
334
  strOf(raw.folder) ||
335
+ strOf(raw.target_directory) ||
336
+ strOf(raw.targetDirectory) ||
93
337
  ""
94
338
  );
95
339
  }
@@ -101,13 +345,21 @@ export function extractUrl(raw: Record<string, unknown>): string {
101
345
  strOf(raw.uri) ||
102
346
  strOf(raw.link) ||
103
347
  strOf(raw.endpoint) ||
348
+ strOf(nested(raw, "arguments", "url")) ||
104
349
  ""
105
350
  );
106
351
  }
107
352
 
108
353
  /** Extract command string from an execute/shell call. */
109
354
  export function extractCommand(raw: Record<string, unknown>): string {
110
- return strOf(raw.command) || strOf(raw.cmd) || strOf(raw.shell_command) || "";
355
+ return (
356
+ strOf(raw.command) ||
357
+ strOf(raw.cmd) ||
358
+ strOf(raw.shell_command) ||
359
+ strOf(raw.shellCommand) ||
360
+ strOf(nested(raw, "arguments", "command")) ||
361
+ ""
362
+ );
111
363
  }
112
364
 
113
365
  /** Extract file content for write/create operations. */
@@ -117,26 +369,140 @@ export function extractContent(raw: Record<string, unknown>): string {
117
369
  strOf(raw.file_text) ||
118
370
  strOf(raw.text) ||
119
371
  strOf(raw.data) ||
372
+ strOf(raw.new_string) ||
373
+ strOf(raw.newString) ||
120
374
  ""
121
375
  );
122
376
  }
123
377
 
378
+ /** Read-range / paging args for display. */
379
+ export function extractReadRange(raw: Record<string, unknown>): {
380
+ offset?: string;
381
+ limit?: string;
382
+ startLine?: string;
383
+ pages?: string;
384
+ } {
385
+ const offset = strOf(raw.offset) || strOf(raw.start) || numStr(raw.offset);
386
+ const limit = strOf(raw.limit) || strOf(raw.count) || numStr(raw.limit);
387
+ const startLine =
388
+ strOf(raw.start_line) ||
389
+ strOf(raw.startLine) ||
390
+ strOf(raw.line) ||
391
+ numStr(raw.start_line) ||
392
+ numStr(raw.line);
393
+ const pages = strOf(raw.pages) || strOf(raw.page);
394
+ const out: { offset?: string; limit?: string; startLine?: string; pages?: string } = {};
395
+ if (offset) out.offset = offset;
396
+ if (limit) out.limit = limit;
397
+ if (startLine) out.startLine = startLine;
398
+ if (pages) out.pages = pages;
399
+ return out;
400
+ }
401
+
124
402
  /** Extract include/exclude filters from a search call. */
125
403
  export function extractFilters(raw: Record<string, unknown>): { include?: string; exclude?: string } {
126
- const include = strOf(raw.include) || strOf(raw.glob) || strOf(raw.file_pattern) || strOf(raw.type);
127
- const exclude = strOf(raw.exclude) || strOf(raw.ignore);
404
+ const include =
405
+ strOf(raw.include) || strOf(raw.glob) || strOf(raw.file_pattern) || strOf(raw.type) || strOf(raw.glob_pattern);
406
+ const exclude = strOf(raw.exclude) || strOf(raw.ignore) || strOf(raw.exclude_pattern);
128
407
  const out: { include?: string; exclude?: string } = {};
129
408
  if (include) out.include = include;
130
409
  if (exclude) out.exclude = exclude;
131
410
  return out;
132
411
  }
133
412
 
134
- /** Truncate text to max chars with ellipsis. */
413
+ /**
414
+ * Pretty-print non-empty raw args for "other" / MCP tools so nothing important
415
+ * is hidden. Skips meta keys and huge blobs (middle-truncated later by caller).
416
+ */
417
+ export function formatArgLines(raw: Record<string, unknown>, maxLines = 24): string {
418
+ const SKIP = new Set([
419
+ "tool_name",
420
+ "toolName",
421
+ "name",
422
+ "tool",
423
+ "type",
424
+ "_meta",
425
+ "function",
426
+ "function_name",
427
+ ]);
428
+ const lines: string[] = [];
429
+ for (const [key, val] of Object.entries(raw)) {
430
+ if (SKIP.has(key)) continue;
431
+ if (val === undefined || val === null || val === "") continue;
432
+ let s: string;
433
+ if (typeof val === "string") s = val;
434
+ else if (typeof val === "number" || typeof val === "boolean") s = String(val);
435
+ else {
436
+ try {
437
+ s = JSON.stringify(val);
438
+ } catch {
439
+ s = String(val);
440
+ }
441
+ }
442
+ if (s.length > 240) s = s.slice(0, 239) + "\u2026";
443
+ lines.push(`${key}: ${s}`);
444
+ if (lines.length >= maxLines) {
445
+ lines.push("\u2026");
446
+ break;
447
+ }
448
+ }
449
+ return lines.join("\n");
450
+ }
451
+
452
+ /** Truncate text to max chars with ellipsis (head-only). */
135
453
  export function truncate(text: string, max: number): string {
136
454
  if (text.length <= max) return text;
137
455
  return text.slice(0, max - 1) + "\u2026";
138
456
  }
139
457
 
458
+ /**
459
+ * Extract human-readable tool result text from ACP content blocks / content.
460
+ * Used so completed execute/search/fetch calls show their output in Telegram
461
+ * (display-truncated later — full results still live in the agent session).
462
+ */
463
+ export function extractToolOutput(u: SessionUpdate): string {
464
+ const parts: string[] = [];
465
+ const push = (v: unknown): void => {
466
+ if (typeof v === "string" && v.trim()) parts.push(v);
467
+ };
468
+
469
+ for (const b of collectContent(u)) {
470
+ if (b.type === "diff") continue; // handled separately as a unified diff
471
+ const nested = b.content;
472
+ if (nested && typeof nested === "object") {
473
+ push((nested as { text?: unknown }).text);
474
+ const data = (nested as { data?: unknown }).data;
475
+ if (typeof data === "string") push(data);
476
+ }
477
+ const extra = b as Record<string, unknown>;
478
+ push(extra.text);
479
+ push(extra.output);
480
+ push(extra.stdout);
481
+ push(extra.stderr);
482
+ if (typeof extra.data === "string") push(extra.data);
483
+ }
484
+
485
+ push(contentText(u.content));
486
+ const rawOut = (u as { rawOutput?: unknown }).rawOutput;
487
+ if (rawOut && typeof rawOut === "object") {
488
+ const ro = rawOut as Record<string, unknown>;
489
+ push(ro.output);
490
+ push(ro.stdout);
491
+ push(ro.stderr);
492
+ push(ro.text);
493
+ push(ro.message);
494
+ } else if (typeof rawOut === "string") {
495
+ push(rawOut);
496
+ }
497
+
498
+ const out: string[] = [];
499
+ for (const p of parts) {
500
+ if (out[out.length - 1] === p) continue;
501
+ out.push(p);
502
+ }
503
+ return out.join("\n").trim();
504
+ }
505
+
140
506
  /** Collect all content blocks (diffs, text, etc.) from a tool update. */
141
507
  export function collectContent(u: SessionUpdate): ToolCallContent[] {
142
508
  const out: ToolCallContent[] = [];
@@ -156,6 +522,9 @@ export function gatherPaths(u: SessionUpdate, raw: Record<string, unknown>): str
156
522
  add(raw.file_path);
157
523
  add(raw.filename);
158
524
  add(raw.file);
525
+ add(raw.target_file);
526
+ add(raw.targetFile);
527
+ add(raw.target_path);
159
528
  if (Array.isArray(raw.operations)) {
160
529
  for (const op of raw.operations) {
161
530
  if (op && typeof op === "object") add((op as Record<string, unknown>).path);
@@ -165,6 +534,18 @@ export function gatherPaths(u: SessionUpdate, raw: Record<string, unknown>): str
165
534
  return out;
166
535
  }
167
536
 
537
+ function nested(raw: Record<string, unknown>, a: string, b: string): unknown {
538
+ const o = raw[a];
539
+ if (o && typeof o === "object" && !Array.isArray(o)) {
540
+ return (o as Record<string, unknown>)[b];
541
+ }
542
+ return undefined;
543
+ }
544
+
168
545
  function strOf(v: unknown): string {
169
546
  return typeof v === "string" ? v : "";
170
- }
547
+ }
548
+
549
+ function numStr(v: unknown): string {
550
+ return typeof v === "number" && Number.isFinite(v) ? String(v) : "";
551
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Merge ACP tool_call + tool_call_update patches by toolCallId.
3
+ *
4
+ * Grok often sends a rich first notification (title/kind/rawInput/locations),
5
+ * then a completed update with only { toolCallId, status }. Formatting the
6
+ * update alone produces the useless "Tool call ✓" line — always format the
7
+ * merged snapshot instead.
8
+ */
9
+ import type { SessionUpdate, ToolCallContent } from "../grok/types.js";
10
+
11
+ export type ToolSnapshot = SessionUpdate & {
12
+ toolCallId?: string;
13
+ rawInput?: Record<string, unknown>;
14
+ locations?: Array<{ path?: string; line?: number }>;
15
+ };
16
+
17
+ /** Merge a new patch onto a previous snapshot (patch semantics: only set fields replace). */
18
+ export function mergeToolSnapshot(prev: ToolSnapshot | undefined, patch: SessionUpdate): ToolSnapshot {
19
+ const base: ToolSnapshot = prev ? { ...prev } : { sessionUpdate: patch.sessionUpdate };
20
+ // Always keep latest sessionUpdate type for debugging; display uses fields not type.
21
+ base.sessionUpdate = patch.sessionUpdate || base.sessionUpdate;
22
+
23
+ if (patch.toolCallId) base.toolCallId = patch.toolCallId;
24
+ if (patch.title != null && String(patch.title).trim() && !isGenericTitle(String(patch.title))) {
25
+ base.title = patch.title;
26
+ } else if (patch.title != null && !base.title) {
27
+ base.title = patch.title;
28
+ }
29
+ if (patch.name != null && String(patch.name).trim()) base.name = patch.name;
30
+ if (patch.toolName != null && String(patch.toolName).trim()) base.toolName = patch.toolName;
31
+ if (patch.kind != null && String(patch.kind).trim() && String(patch.kind).toLowerCase() !== "other") {
32
+ base.kind = patch.kind;
33
+ } else if (patch.kind != null && !base.kind) {
34
+ base.kind = patch.kind;
35
+ }
36
+ if (patch.status != null) base.status = patch.status;
37
+
38
+ if (patch.rawInput && typeof patch.rawInput === "object") {
39
+ base.rawInput = { ...(base.rawInput || {}), ...normalizeRawKeys(patch.rawInput as Record<string, unknown>) };
40
+ }
41
+ // snake_case wire variants
42
+ const snakeIn = (patch as { raw_input?: unknown }).raw_input;
43
+ if (snakeIn && typeof snakeIn === "object" && !Array.isArray(snakeIn)) {
44
+ base.rawInput = {
45
+ ...(base.rawInput || {}),
46
+ ...normalizeRawKeys(snakeIn as Record<string, unknown>),
47
+ };
48
+ }
49
+
50
+ if (patch.rawOutput !== undefined) base.rawOutput = patch.rawOutput;
51
+ const snakeOut = (patch as { raw_output?: unknown }).raw_output;
52
+ if (snakeOut !== undefined) base.rawOutput = snakeOut;
53
+
54
+ if (Array.isArray(patch.locations) && patch.locations.length) {
55
+ base.locations = patch.locations;
56
+ }
57
+
58
+ // Content: prefer non-empty arrays
59
+ if (Array.isArray(patch.content_blocks) && patch.content_blocks.length) {
60
+ base.content_blocks = patch.content_blocks as ToolCallContent[];
61
+ }
62
+ if (Array.isArray(patch.content) && (patch.content as unknown[]).length) {
63
+ base.content = patch.content;
64
+ // Mirror into content_blocks for older formatters.
65
+ if (!base.content_blocks?.length) {
66
+ base.content_blocks = patch.content as ToolCallContent[];
67
+ }
68
+ }
69
+
70
+ // Lift path from locations into rawInput for path extractors.
71
+ if (base.locations?.[0]?.path) {
72
+ base.rawInput = base.rawInput || {};
73
+ if (!base.rawInput.path && !base.rawInput.target_file && !base.rawInput.file_path) {
74
+ base.rawInput.path = base.locations[0].path;
75
+ }
76
+ if (base.locations[0].line != null && base.rawInput.start_line == null) {
77
+ base.rawInput.start_line = base.locations[0].line;
78
+ }
79
+ }
80
+
81
+ return base;
82
+ }
83
+
84
+ /** True when a title is useless alone (Grok default). */
85
+ export function isGenericTitle(title: string): boolean {
86
+ const t = title.trim().toLowerCase();
87
+ return !t || t === "tool call" || t === "tool" || t === "other" || t === "tool_call";
88
+ }
89
+
90
+ /** Whether the snapshot has enough info to show a meaningful card. */
91
+ export function snapshotHasDetail(s: ToolSnapshot): boolean {
92
+ if (s.name || s.toolName) return true;
93
+ if (s.title && !isGenericTitle(String(s.title))) return true;
94
+ if (s.kind && String(s.kind).toLowerCase() !== "other") return true;
95
+ const raw = s.rawInput || {};
96
+ if (Object.keys(raw).length > 0) return true;
97
+ if (s.locations?.some((l) => l.path)) return true;
98
+ if (Array.isArray(s.content_blocks) && s.content_blocks.length > 0) return true;
99
+ if (Array.isArray(s.content) && (s.content as unknown[]).length > 0) return true;
100
+ return false;
101
+ }
102
+
103
+ function normalizeRawKeys(raw: Record<string, unknown>): Record<string, unknown> {
104
+ // Flatten nested arguments once so path/command extractors see them.
105
+ const out: Record<string, unknown> = { ...raw };
106
+ for (const key of ["arguments", "input", "args", "parameters"]) {
107
+ const nested = raw[key];
108
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
109
+ for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
110
+ if (out[k] === undefined) out[k] = v;
111
+ }
112
+ }
113
+ }
114
+ return out;
115
+ }