switchroom 0.15.45 → 0.16.5

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -3,11 +3,14 @@ import {
3
3
  describeToolUse,
4
4
  appendActivityLine,
5
5
  appendActivityLabel,
6
+ clipNarrative,
6
7
  renderActivityFeed,
7
8
  renderActivityFeedWithNested,
8
- MIRROR_MAX_LINES,
9
- NESTED_MAX_LINES,
9
+ renderActivityHeader,
10
+ formatFeedElapsed,
11
+ type SessionActivityHeader,
10
12
  } from "../tool-activity-summary.js";
13
+ import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
11
14
 
12
15
  describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () => {
13
16
  it("Bash uses the model-authored description verbatim, never the command", () => {
@@ -101,17 +104,21 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
101
104
  expect(lines).toEqual([]);
102
105
  });
103
106
 
104
- it("caps to the last MIRROR_MAX_LINES with a '✓ +N earlier…' header", () => {
105
- const lines = Array.from({ length: 9 }, (_, i) => `Action ${i + 1}`);
107
+ it("windows to the last STATUS_ROLLING_LINES with a '✓ +N earlier…' header on the AGENT surface", () => {
108
+ // The +N earlier header now appears on the AGENT surface too (flag retired).
109
+ const total = STATUS_ROLLING_LINES + 4;
110
+ const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
106
111
  const out = renderActivityFeed(lines)!;
107
- expect(out.startsWith("<i>✓ +3 earlier…</i>\n")).toBe(true);
108
- // Only the last 6 actions are shown; the oldest 3 are collapsed.
109
- expect(out).toContain("<i>✓ Action 4</i>");
110
- expect(out).not.toContain("Action 3");
112
+ const hidden = total - STATUS_ROLLING_LINES;
113
+ expect(out.startsWith(`<i>✓ +${hidden} earlier…</i>\n`)).toBe(true);
114
+ // Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
115
+ const firstVisible = total - STATUS_ROLLING_LINES + 1;
116
+ expect(out).toContain(`<i>✓ Action ${firstVisible}</i>`);
117
+ expect(out).not.toContain(`Action ${firstVisible - 1}<`);
111
118
  // The newest action is the in-progress step (bold →); the rest are done (✓).
112
- expect(out).toContain("<b>→ Action 9</b>");
113
- expect(out).toContain("<i>✓ Action 8</i>");
114
- expect(out).not.toContain("<b>→ Action 8</b>");
119
+ expect(out).toContain(`<b>→ Action ${total}</b>`);
120
+ expect(out).toContain(`<i>✓ Action ${total - 1}</i>`);
121
+ expect(out).not.toContain(`<b>→ Action ${total - 1}</b>`);
115
122
  });
116
123
 
117
124
  it("HTML-escapes &, <, > in action text (no double-escaping by callers)", () => {
@@ -193,6 +200,96 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
193
200
  });
194
201
  });
195
202
 
203
+ describe("clipNarrative — narrative-step clip (JSONL-text-narrative primitive)", () => {
204
+ it("takes the first line only, trims, slices to 200 chars (Fix 1: raised from 120 to match STATUS_LINE_MAX)", () => {
205
+ expect(clipNarrative("On it. Let me find the repo…\nthen build")).toBe(
206
+ "On it. Let me find the repo…",
207
+ );
208
+ expect(clipNarrative(" Found both: ")).toBe("Found both:");
209
+ // Fix 1: cap is now 200 chars (STATUS_LINE_MAX), not 120.
210
+ const long = "x".repeat(250);
211
+ expect(clipNarrative(long).length).toBe(200);
212
+ });
213
+
214
+ it("a shown narrative renders identically to a tool label via the same feed path", () => {
215
+ // A SHOWN narrative line is pushed through appendActivityLabel exactly
216
+ // like a tool label, so the rendered feed string is byte-identical.
217
+ const narrativeLines: string[] = [];
218
+ const labelLines: string[] = [];
219
+ const text = "Important find: no main branch yet — branching off origin.";
220
+ const narrativeRender = appendActivityLabel(narrativeLines, clipNarrative(text));
221
+ const labelRender = appendActivityLabel(labelLines, clipNarrative(text));
222
+ expect(narrativeRender).toBe(labelRender);
223
+ expect(narrativeRender).toBe(`<b>→ ${text}</b>`);
224
+ });
225
+
226
+ it("empty string yields an empty clip (appendActivityLabel then drops it)", () => {
227
+ expect(clipNarrative("")).toBe("");
228
+ expect(appendActivityLabel([], clipNarrative(" \n "))).toBeNull();
229
+ });
230
+ });
231
+
232
+ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)", () => {
233
+ it("appends '✓ N steps' footer when final=true and stepCount > 0", () => {
234
+ const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
235
+ const out = renderActivityFeed(lines, true, "", 5)!;
236
+ // All lines are done (✓) and the footer is appended.
237
+ expect(out).toContain("<i>✓ Running a command</i>");
238
+ expect(out).toContain("<i>✓ 5 steps</i>");
239
+ expect(out.endsWith("<i>✓ 5 steps</i>")).toBe(true);
240
+ });
241
+
242
+ it("stepCount=0 → no footer (no tools fired that were surfaced)", () => {
243
+ const lines = ["Reading CLAUDE.md"];
244
+ const out = renderActivityFeed(lines, true, "", 0)!;
245
+ expect(out).not.toContain("steps");
246
+ expect(out).toBe("<i>✓ Reading CLAUDE.md</i>");
247
+ });
248
+
249
+ it("stepCount undefined → no footer (live/non-final callers omit it)", () => {
250
+ const lines = ["Reading CLAUDE.md"];
251
+ const out = renderActivityFeed(lines, true)!;
252
+ expect(out).not.toContain("steps");
253
+ });
254
+
255
+ it("stepCount present but final=false → no footer (live in-progress feed stays clean)", () => {
256
+ const lines = ["Reading CLAUDE.md", "Running a command"];
257
+ const out = renderActivityFeed(lines, false, "", 7)!;
258
+ expect(out).not.toContain("steps");
259
+ // The newest line is still the live in-progress step.
260
+ expect(out).toContain("<b>→ Running a command</b>");
261
+ });
262
+
263
+ it("stepCount=1 footer reads '✓ 1 steps' (no special-casing)", () => {
264
+ const lines = ["Reading CLAUDE.md"];
265
+ const out = renderActivityFeed(lines, true, "", 1)!;
266
+ expect(out).toContain("<i>✓ 1 steps</i>");
267
+ });
268
+
269
+ it("footer appears even when the feed overflows the rolling window", () => {
270
+ const total = STATUS_ROLLING_LINES + 4;
271
+ const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
272
+ const out = renderActivityFeed(lines, true, "", total)!;
273
+ expect(out).toContain(`<i>✓ +${total - STATUS_ROLLING_LINES} earlier…</i>`);
274
+ expect(out).toContain(`<i>✓ ${total} steps</i>`);
275
+ expect(out.endsWith(`<i>✓ ${total} steps</i>`)).toBe(true);
276
+ });
277
+
278
+ it("stepCount is surface-tool-excluded: reply/react count 0, Read+mcp count correctly", () => {
279
+ // This test documents the counting contract: stepCount is incremented
280
+ // after the isTelegramSurfaceTool guard in case 'tool_label', so surface
281
+ // tools (reply/stream_reply/edit_message/react) are never counted. The
282
+ // rendered footer reflects only the surfaced non-surface steps.
283
+ const lines = ["Reading CLAUDE.md", "Searching memory"]; // 2 non-surface labels
284
+ // stepCount=2 (Read + mcp__hindsight__recall) — reply is NOT counted.
285
+ const out = renderActivityFeed(lines, true, "", 2)!;
286
+ expect(out).toContain("<i>✓ 2 steps</i>");
287
+ // stepCount=0 would mean only surface tools fired — no footer.
288
+ const outSurfaceOnly = renderActivityFeed(["Reading CLAUDE.md"], true, "", 0)!;
289
+ expect(outSurfaceOnly).not.toContain("steps");
290
+ });
291
+ });
292
+
196
293
  describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A)", () => {
197
294
  it("with no child lines, is identical to the flat feed", () => {
198
295
  const lines = ["Searching memory", "Delegating: review the migration"];
@@ -214,12 +311,13 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
214
311
  expect(out).toContain(" ↳ <b>→ Looking for foreign keys</b>");
215
312
  });
216
313
 
217
- it("caps the nested block to NESTED_MAX_LINES with a '↳ +N earlier…' header", () => {
218
- const child = Array.from({ length: NESTED_MAX_LINES + 3 }, (_, i) => `step ${i + 1}`);
314
+ it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
315
+ const total = STATUS_ROLLING_LINES + 3;
316
+ const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
219
317
  const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
220
- expect(out).toContain(" ↳ <i>+3 earlier…</i>");
318
+ expect(out).toContain(` ↳ <i>+${total - STATUS_ROLLING_LINES} earlier…</i>`);
221
319
  // newest nested line is the live → step
222
- expect(out).toContain(` ↳ <b>→ step ${NESTED_MAX_LINES + 3}</b>`);
320
+ expect(out).toContain(` ↳ <b>→ step ${total}</b>`);
223
321
  // the oldest (collapsed) lines are not rendered verbatim
224
322
  expect(out).not.toContain("step 1<");
225
323
  });
@@ -267,6 +365,45 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
267
365
  );
268
366
  });
269
367
 
368
+ it("stepCount footer appears on final=true with children present", () => {
369
+ const parent = ["Delegating: review the migration"];
370
+ const child = ["Reading schema.ts", "Looking for foreign keys"];
371
+ const out = renderActivityFeedWithNested(parent, child, true, "", 5)!;
372
+ expect(out).toContain("<i>✓ 5 steps</i>");
373
+ expect(out.endsWith("<i>✓ 5 steps</i>")).toBe(true);
374
+ expect(out).not.toContain("→");
375
+ });
376
+
377
+ it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
378
+ const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
379
+ expect(out).toContain("<i>✓ 3 steps</i>");
380
+ expect(out).toBe("<i>✓ Reading a.ts</i>\n<i>✓ 3 steps</i>");
381
+ });
382
+
383
+ // Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
384
+ // (pure thinking / suppressed tools) gets a minimal "Working…" placeholder so
385
+ // the feed still opens and stays visibly alive. These pin the exact render
386
+ // the gateway's feedHeartbeatTick (open + climb) and clearActivitySummary
387
+ // (terminal record) depend on.
388
+ describe("liveness placeholder — 'Working…' feed", () => {
389
+ it("renders the live in-progress placeholder with climbing elapsed", () => {
390
+ expect(
391
+ renderActivityFeedWithNested(["Working…"], [], false, " · 12s"),
392
+ ).toBe("<b>→ Working… · 12s</b>");
393
+ });
394
+
395
+ it("finalizes the placeholder to a done record (no frozen → line)", () => {
396
+ expect(renderActivityFeedWithNested(["Working…"], [], true)).toBe(
397
+ "<i>✓ Working…</i>",
398
+ );
399
+ });
400
+ });
401
+
402
+ it("stepCount=0 → no footer even on final=true", () => {
403
+ const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 0)!;
404
+ expect(out).not.toContain("steps");
405
+ });
406
+
270
407
  // Pins the invariant the gateway's foreground handoff-clear path relies on:
271
408
  // on an ack-first turn the parent feed is empty (mirrorLines=[]) and the only
272
409
  // content is the foreground sub-agent's nested narrative. The finalized
@@ -293,3 +430,401 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
293
430
  });
294
431
  });
295
432
  });
433
+
434
+ // ─── Rolling-window + STATUS_LINE_MAX (flag retired) ────────────────────────
435
+ // Contract (single mode, no flag):
436
+ // - last STATUS_ROLLING_LINES lines render; overflow → `+N earlier…` header
437
+ // on BOTH surfaces;
438
+ // - each line clipped to STATUS_LINE_MAX (200) chars then escaped;
439
+ // - char-budget backstop (fitCardToBudget) is the only wire-limit ceiling.
440
+
441
+ describe("rolling window + +N earlier — renderActivityFeed", () => {
442
+ it("with 12 lines, exactly the last STATUS_ROLLING_LINES render + a +N earlier header", () => {
443
+ const lines = Array.from({ length: 12 }, (_, i) => `XAct-${String(i + 1).padStart(3, '0')}`);
444
+ const out = renderActivityFeed(lines)!;
445
+ const firstVisible = 12 - STATUS_ROLLING_LINES + 1;
446
+ for (let i = firstVisible; i <= 12; i++) {
447
+ expect(out).toContain(`XAct-${String(i).padStart(3, '0')}`);
448
+ }
449
+ for (let i = 1; i < firstVisible; i++) {
450
+ expect(out).not.toContain(`XAct-${String(i).padStart(3, '0')}`);
451
+ }
452
+ // Overflow header present on the AGENT surface now.
453
+ expect(out).toContain(`<i>✓ +${12 - STATUS_ROLLING_LINES} earlier…</i>`);
454
+ expect(out).toContain(`<b>→ XAct-012</b>`);
455
+ });
456
+
457
+ it("STATUS_LINE_MAX=200: a 250-char step is clipped to 200 with a trailing …", () => {
458
+ const longLine = "a".repeat(250);
459
+ const out = renderActivityFeed([longLine])!;
460
+ expect(out).toContain("…");
461
+ // The full 250-char line must NOT survive; the clip is 200 (199 + …).
462
+ expect(out).not.toContain(longLine);
463
+ expect(out).toContain("a".repeat(STATUS_LINE_MAX - 1) + "…");
464
+ });
465
+
466
+ it("a line at exactly STATUS_LINE_MAX is NOT clipped", () => {
467
+ const exact = "b".repeat(STATUS_LINE_MAX);
468
+ const out = renderActivityFeed([exact])!;
469
+ expect(out).toContain(exact);
470
+ expect(out).not.toContain("…");
471
+ });
472
+
473
+ it("no spurious overflow header when the feed fits the window", () => {
474
+ const lines = Array.from({ length: STATUS_ROLLING_LINES }, (_, i) => `Short ${i + 1}`);
475
+ const out = renderActivityFeed(lines)!;
476
+ expect(out).not.toContain("earlier");
477
+ });
478
+
479
+ it("pathologically oversized lines: char-budget backstop fires, output ≤ 4096 chars", () => {
480
+ // STATUS_ROLLING_LINES lines of ~900 chars each → over budget pre-clip, but
481
+ // each line is clipped to 200 first, so the budget should comfortably hold.
482
+ const bigLine = "z".repeat(900);
483
+ const lines = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
484
+ const out = renderActivityFeed(lines)!;
485
+ expect(out.length).toBeLessThanOrEqual(4096);
486
+ const hasBullet = out.includes("→") || out.includes("✓");
487
+ expect(hasBullet).toBe(true);
488
+ });
489
+ });
490
+
491
+ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
492
+ it("many parent lines → only the last STATUS_ROLLING_LINES render with a +N earlier header", () => {
493
+ const totalParent = STATUS_ROLLING_LINES + 3;
494
+ const parent = Array.from({ length: totalParent }, (_, i) => `Parent ${i + 1}`);
495
+ const child = ["Child step A", "Child step B"];
496
+ const out = renderActivityFeedWithNested(parent, child)!;
497
+ const firstVisible = totalParent - STATUS_ROLLING_LINES + 1;
498
+ for (let i = firstVisible; i <= totalParent; i++) {
499
+ expect(out).toContain(`Parent ${i}`);
500
+ }
501
+ for (let i = 1; i < firstVisible; i++) {
502
+ expect(out).not.toContain(`Parent ${i}`);
503
+ }
504
+ expect(out).toContain(`<i>✓ +${totalParent - STATUS_ROLLING_LINES} earlier…</i>`);
505
+ expect(out).toContain(" ↳ <b>→ Child step B</b>");
506
+ });
507
+
508
+ it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
509
+ const parent = ["Delegating: big task"];
510
+ const totalChild = STATUS_ROLLING_LINES + 4;
511
+ const child = Array.from({ length: totalChild }, (_, i) => `Child ${i + 1}`);
512
+ const out = renderActivityFeedWithNested(parent, child)!;
513
+ const firstVisible = totalChild - STATUS_ROLLING_LINES + 1;
514
+ for (let i = firstVisible; i <= totalChild; i++) {
515
+ expect(out).toContain(`Child ${i}`);
516
+ }
517
+ for (let i = 1; i < firstVisible; i++) {
518
+ expect(out).not.toContain(`Child ${i}`);
519
+ }
520
+ expect(out).toContain(` ↳ <i>+${totalChild - STATUS_ROLLING_LINES} earlier…</i>`);
521
+ expect(out).toContain(` ↳ <b>→ Child ${totalChild}</b>`);
522
+ });
523
+
524
+ it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
525
+ const longLine = "x".repeat(250);
526
+ const out = renderActivityFeedWithNested(["Delegating"], [longLine])!;
527
+ expect(out).toContain("…");
528
+ expect(out).not.toContain(longLine);
529
+ expect(out).toContain("x".repeat(STATUS_LINE_MAX - 1) + "…");
530
+ });
531
+
532
+ it("huge nested feed: char-budget backstop fires, output ≤ 4096 chars", () => {
533
+ const bigLine = "w".repeat(900);
534
+ const parent = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
535
+ const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
536
+ const out = renderActivityFeedWithNested(parent, child)!;
537
+ expect(out.length).toBeLessThanOrEqual(4096);
538
+ expect(out).toContain(" ↳ ");
539
+ });
540
+ });
541
+
542
+ // ─── Extreme-edge: single oversized line with HTML-special chars ─────────────
543
+ // These tests cover the _fitToCharBudget and _fitNestedToCharBudget fallback
544
+ // paths that are reachable in no-truncate mode when a Bash label contains
545
+ // special chars (e.g. && is extremely common) and is longer than the budget.
546
+
547
+ /** Cheap valid-HTML checker: balanced tags + no partial entity. */
548
+ function isValidHtml(s: string): boolean {
549
+ // No partial entity: must not end with &[a-z]* lacking semicolon.
550
+ if (/&[a-z]+$/.test(s)) return false;
551
+ if (/&[a-z]+[^;]$/.test(s)) return false;
552
+ // Every &...; must be complete.
553
+ const entityRe = /&([^;]*)/g;
554
+ let m: RegExpExecArray | null;
555
+ while ((m = entityRe.exec(s)) !== null) {
556
+ if (!s.slice(m.index).startsWith("&") || s[m.index + m[0].length] !== ";") {
557
+ // Check if this entity is actually terminated
558
+ const entityStart = m.index;
559
+ const semiIdx = s.indexOf(";", entityStart + 1);
560
+ if (semiIdx === -1) return false; // no closing semicolon
561
+ }
562
+ }
563
+ // All opening tags have a corresponding close (simple check for <b>/<i>).
564
+ const bOpen = (s.match(/<b>/g) ?? []).length;
565
+ const bClose = (s.match(/<\/b>/g) ?? []).length;
566
+ const iOpen = (s.match(/<i>/g) ?? []).length;
567
+ const iClose = (s.match(/<\/i>/g) ?? []).length;
568
+ return bOpen === bClose && iOpen === iClose;
569
+ }
570
+
571
+ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
572
+ it("renderActivityFeed: single ~4100-char line with special chars → ≤ budget and valid HTML", () => {
573
+ // Build a raw line >4000 chars with &, <, >, and a trailing &&.
574
+ // The && will escape to &amp;&amp; (5 chars each), exercising the expansion guard.
575
+ const base = "Run script with args: foo=bar && baz=<qux> & corge=1 ";
576
+ const bigLine = base.repeat(80) + "&&";
577
+ expect(bigLine.length).toBeGreaterThan(4000);
578
+
579
+ const out = renderActivityFeed([bigLine])!;
580
+ expect(out).not.toBeNull();
581
+ expect(out.length).toBeLessThanOrEqual(4000); // STATUS_CARD_CHAR_BUDGET
582
+ expect(isValidHtml(out)).toBe(true);
583
+ // Must be wrapped in <b>…</b> (live/non-final) — no dangling tag.
584
+ expect(out.startsWith("<b>→ ")).toBe(true);
585
+ expect(out.endsWith("</b>")).toBe(true);
586
+ });
587
+
588
+ it("renderActivityFeed final=true: single ~4100-char line → ≤ budget and valid HTML", () => {
589
+ const base = "Deploy build && upload && notify && cleanup: ";
590
+ const bigLine = base.repeat(90) + "done";
591
+ expect(bigLine.length).toBeGreaterThan(4000);
592
+
593
+ const out = renderActivityFeed([bigLine], true)!;
594
+ expect(out).not.toBeNull();
595
+ expect(out.length).toBeLessThanOrEqual(4000);
596
+ expect(isValidHtml(out)).toBe(true);
597
+ expect(out.startsWith("<i>✓ ")).toBe(true);
598
+ expect(out.endsWith("</i>")).toBe(true);
599
+ });
600
+
601
+ it("renderActivityFeedWithNested: single ~4100-char parent line → ≤ budget and valid HTML", () => {
602
+ const base = "Parent action with & < > special chars && bash: ";
603
+ const bigLine = base.repeat(85);
604
+ expect(bigLine.length).toBeGreaterThan(4000);
605
+
606
+ const out = renderActivityFeedWithNested([bigLine], [])!;
607
+ expect(out).not.toBeNull();
608
+ expect(out.length).toBeLessThanOrEqual(4000);
609
+ expect(isValidHtml(out)).toBe(true);
610
+ });
611
+
612
+ it("renderActivityFeedWithNested: single ~4100-char child line → ≤ budget and valid HTML", () => {
613
+ const base = "Child step: run build && test && deploy with <args> & flags=1 ";
614
+ const bigChild = base.repeat(65) + "&&";
615
+ expect(bigChild.length).toBeGreaterThan(4000);
616
+
617
+ const out = renderActivityFeedWithNested(["Delegating: big job"], [bigChild])!;
618
+ expect(out).not.toBeNull();
619
+ expect(out.length).toBeLessThanOrEqual(4000);
620
+ expect(isValidHtml(out)).toBe(true);
621
+ // Must contain the nested prefix.
622
+ expect(out).toContain(" ↳ ");
623
+ // Regression guard: an operator-precedence bug made wrapperOverhead a string
624
+ // (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
625
+ // was silently discarded, leaving only " ↳ <b>→ </b>". The empty-wrapper
626
+ // output still satisfies the toContain(" ↳ ") check above, so assert that
627
+ // real child content actually survives.
628
+ expect(out).toContain("Child step");
629
+ expect(out.length).toBeGreaterThan(100);
630
+ });
631
+
632
+ it("renderActivityFeedWithNested final=true: single ~4100-char child line → ≤ budget and valid HTML", () => {
633
+ const base = "Final child: compile && link && package & ship: ";
634
+ const bigChild = base.repeat(85);
635
+ expect(bigChild.length).toBeGreaterThan(4000);
636
+
637
+ const out = renderActivityFeedWithNested(["Delegating: big job"], [bigChild], true)!;
638
+ expect(out).not.toBeNull();
639
+ expect(out.length).toBeLessThanOrEqual(4000);
640
+ expect(isValidHtml(out)).toBe(true);
641
+ // final=true → nested line renders done (italic, no →).
642
+ expect(out).not.toContain("→");
643
+ });
644
+ });
645
+
646
+ // ─── Session activity header (unified renderer — main-session card fix) ───────
647
+ //
648
+ // The main-session card was missing the two-line header that the worker card
649
+ // already renders: elapsed time + tool count. These tests verify that the header
650
+ // is now emitted when a `SessionActivityHeader` is supplied, matching the worker
651
+ // card's style and fixing the "missing header" regression.
652
+
653
+ describe("renderActivityHeader — two-line header builder", () => {
654
+ it("renders the running header with elapsed and tool count", () => {
655
+ const [h1, h2] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running");
656
+ expect(h1).toBe("🤖 <b>Agent</b>");
657
+ expect(h2).toBe("<i>15s · 7 tools</i>");
658
+ });
659
+
660
+ it("renders the done header with tool count and elapsed", () => {
661
+ const [h1, h2] = renderActivityHeader("🤖", "Agent", "", 65_000, 3, "done");
662
+ expect(h1).toBe("🤖 <b>Agent</b>");
663
+ expect(h2).toBe("<i>done · 3 tools · 1m05s</i>");
664
+ });
665
+
666
+ it("includes the description in line 1 when non-empty", () => {
667
+ const [h1] = renderActivityHeader("🛠", "Worker", "run tests", 10_000, 2, "running");
668
+ expect(h1).toBe("🛠 <b>Worker</b> · <i>run tests</i>");
669
+ });
670
+
671
+ it("omits the description part when empty", () => {
672
+ const [h1] = renderActivityHeader("🤖", "Agent", "", 5_000, 1, "running");
673
+ expect(h1).toBe("🤖 <b>Agent</b>");
674
+ expect(h1).not.toContain(" · ");
675
+ });
676
+
677
+ it("HTML-escapes special chars in description and label", () => {
678
+ const [h1] = renderActivityHeader("🤖", "Agent", "run <foo> & <bar>", 5_000, 1, "running");
679
+ expect(h1).toContain("run &lt;foo&gt; &amp; &lt;bar&gt;");
680
+ });
681
+ });
682
+
683
+ describe("agent flat path routes through the shared step-feed primitive", () => {
684
+ it("flat renderActivityFeed === nested-with-empty-children (same window + +N output)", () => {
685
+ // The flat agent path and the nested path with NO children must produce the
686
+ // identical render — both flow through renderStatusCard's step feed.
687
+ const lines = Array.from({ length: STATUS_ROLLING_LINES + 3 }, (_, i) => `Step ${i + 1}`);
688
+ expect(renderActivityFeed(lines)).toBe(renderActivityFeedWithNested(lines, []));
689
+ expect(renderActivityFeed(lines, true, "", 9)).toBe(
690
+ renderActivityFeedWithNested(lines, [], true, "", 9),
691
+ );
692
+ // And the +N earlier marker is present on the flat agent surface.
693
+ expect(renderActivityFeed(lines)!).toContain(
694
+ `<i>✓ +${lines.length - STATUS_ROLLING_LINES} earlier…</i>`,
695
+ );
696
+ });
697
+ });
698
+
699
+ describe("escape entity is never split mid-clip (clip raw → escape last)", () => {
700
+ it("a step ending in '&' clipped at STATUS_LINE_MAX stays valid HTML", () => {
701
+ // Build a step exactly STATUS_LINE_MAX+1 chars ending in '&' so a naive
702
+ // escape-then-clip would split the &amp; entity at the boundary.
703
+ const line = "x".repeat(STATUS_LINE_MAX) + "&";
704
+ const out = renderActivityFeed([line])!;
705
+ // The clip keeps STATUS_LINE_MAX-1 chars + '…', then escapes — no stray entity.
706
+ expect(out).not.toMatch(/&amp$/);
707
+ expect(out).not.toMatch(/&am[^p]/);
708
+ expect(out).toContain("…");
709
+ // Whatever '&' survives must be a complete &amp;.
710
+ const ampCount = (out.match(/&amp;/g) ?? []).length;
711
+ const bareAmp = (out.match(/&(?!amp;|lt;|gt;)/g) ?? []).length;
712
+ expect(bareAmp).toBe(0);
713
+ expect(ampCount).toBeGreaterThanOrEqual(0);
714
+ });
715
+
716
+ it("a step ending in '<' clipped at STATUS_LINE_MAX stays valid HTML", () => {
717
+ const line = "y".repeat(STATUS_LINE_MAX) + "<";
718
+ const out = renderActivityFeed([line])!;
719
+ expect(out).not.toMatch(/&lt$/);
720
+ expect(out).not.toMatch(/&l[^t;]/);
721
+ const bareLt = (out.match(/&(?!amp;|lt;|gt;)/g) ?? []).length;
722
+ expect(bareLt).toBe(0);
723
+ });
724
+ });
725
+
726
+ describe("formatFeedElapsed — elapsed formatter", () => {
727
+ it("formats sub-minute durations as Ns", () => {
728
+ expect(formatFeedElapsed(0)).toBe("0s");
729
+ expect(formatFeedElapsed(999)).toBe("0s");
730
+ expect(formatFeedElapsed(1_000)).toBe("1s");
731
+ expect(formatFeedElapsed(59_000)).toBe("59s");
732
+ });
733
+
734
+ it("formats minute+ durations as MmSSs", () => {
735
+ expect(formatFeedElapsed(60_000)).toBe("1m00s");
736
+ expect(formatFeedElapsed(65_000)).toBe("1m05s");
737
+ expect(formatFeedElapsed(125_000)).toBe("2m05s");
738
+ });
739
+ });
740
+
741
+ describe("renderActivityFeed — header param (main-session card fix)", () => {
742
+ it("prepends the two-line header when header is supplied (running)", () => {
743
+ const header: SessionActivityHeader = {
744
+ label: "Agent",
745
+ elapsedMs: 15_000,
746
+ toolCount: 7,
747
+ state: "running",
748
+ };
749
+ const out = renderActivityFeed(["Reading CLAUDE.md", "Searching memory"], false, "", undefined, header)!;
750
+ // Must start with the header lines.
751
+ expect(out).toContain("🤖 <b>Agent</b>");
752
+ expect(out).toContain("<i>15s · 7 tools</i>");
753
+ // Step feed follows the header.
754
+ expect(out).toContain("<i>✓ Reading CLAUDE.md</i>");
755
+ expect(out).toContain("<b>→ Searching memory</b>");
756
+ });
757
+
758
+ it("prepends the done header when final=true", () => {
759
+ const header: SessionActivityHeader = {
760
+ label: "Agent",
761
+ elapsedMs: 65_000,
762
+ toolCount: 3,
763
+ state: "done",
764
+ };
765
+ const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
766
+ expect(out).toContain("🤖 <b>Agent</b>");
767
+ expect(out).toContain("<i>done · 3 tools · 1m05s</i>");
768
+ expect(out).toContain("<i>✓ Reading CLAUDE.md</i>");
769
+ // No in-progress arrow (final=true).
770
+ expect(out).not.toContain("→");
771
+ });
772
+
773
+ it("renders header-only (no steps) when lines is empty", () => {
774
+ const header: SessionActivityHeader = {
775
+ label: "Agent",
776
+ elapsedMs: 5_000,
777
+ toolCount: 0,
778
+ state: "running",
779
+ };
780
+ // renderActivityFeed normally returns null for empty lines; with header it returns content.
781
+ const out = renderActivityFeed([], false, "", undefined, header);
782
+ expect(out).not.toBeNull();
783
+ expect(out).toContain("🤖 <b>Agent</b>");
784
+ });
785
+
786
+ it("without header, renderActivityFeed still returns null for empty lines (no regression)", () => {
787
+ expect(renderActivityFeed([], false, "", undefined, undefined)).toBeNull();
788
+ });
789
+
790
+ it("header is present in renderActivityFeedWithNested output too", () => {
791
+ const header: SessionActivityHeader = {
792
+ label: "Agent",
793
+ elapsedMs: 30_000,
794
+ toolCount: 5,
795
+ state: "running",
796
+ };
797
+ const out = renderActivityFeedWithNested(
798
+ ["Delegating: review"],
799
+ ["Reading schema.ts"],
800
+ false,
801
+ "",
802
+ undefined,
803
+ header,
804
+ )!;
805
+ expect(out).toContain("🤖 <b>Agent</b>");
806
+ expect(out).toContain("<i>30s · 5 tools</i>");
807
+ // Parent step is done-styled (child is the live step).
808
+ expect(out).toContain("<i>✓ Delegating: review</i>");
809
+ // Child step is the in-progress step.
810
+ expect(out).toContain(" ↳ <b>→ Reading schema.ts</b>");
811
+ });
812
+ });
813
+
814
+ describe("describeToolUse — surface-tool suppression is key-agnostic", () => {
815
+ it("returns null for telegram reply/stream_reply under any registration key", () => {
816
+ // Standard switchroom-telegram key.
817
+ expect(describeToolUse("mcp__switchroom-telegram__reply", {})).toBeNull();
818
+ expect(describeToolUse("mcp__switchroom-telegram__stream_reply", {})).toBeNull();
819
+ // Legacy clerk-telegram key.
820
+ expect(describeToolUse("mcp__clerk-telegram__reply", {})).toBeNull();
821
+ expect(describeToolUse("mcp__clerk-telegram__stream_reply", {})).toBeNull();
822
+ // Hypothetical custom fork.
823
+ expect(describeToolUse("mcp__my-custom-telegram__reply", {})).toBeNull();
824
+ });
825
+
826
+ it("returns null for telegram edit_message and react under any key", () => {
827
+ expect(describeToolUse("mcp__clerk-telegram__edit_message", {})).toBeNull();
828
+ expect(describeToolUse("mcp__clerk-telegram__react", {})).toBeNull();
829
+ });
830
+ });