switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -46,7 +46,7 @@ import { homedir } from 'os'
46
46
  import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
47
47
  import { sanitiseToolArg } from './fleet-state.js'
48
48
  import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
49
- import { REPLY_TOOLS } from './narrative-dedup.js'
49
+ import { isReplyTool } from './narrative-dedup.js'
50
50
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from './narrative-flush.js'
51
51
  import { truncate } from './card-format.js'
52
52
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
@@ -551,6 +551,31 @@ export interface SubagentWatcherConfig {
551
551
  * that the chain is bounded, not that a lost worker gets a fresh card.
552
552
  */
553
553
  onWorkerLost?: (agentId: string, description: string) => void
554
+ /**
555
+ * Issue #3373 (SendMessage-resume feed re-surface). Fires when a worker that
556
+ * had a GENUINE terminal completion (a real `sub_agent_turn_end`, then swept
557
+ * by `cleanupTerminalAgent`) is RESUMED via SendMessage — its
558
+ * `agent-<id>.jsonl` grows PAST the byte size recorded at terminal cleanup,
559
+ * and `scanSubagentsDir` re-registers it as a fresh live entry (#3315).
560
+ *
561
+ * This is DISTINCT from `onResurrect` (#3023), which reverses a FALSE finish
562
+ * from silent-stall synthesis and carries bounded-chain semantics. A
563
+ * SendMessage resume is a real, legitimately-unbounded lifecycle event (a
564
+ * worker may be stopped and resumed many times), so it does NOT route through
565
+ * the resurrection budget — it fires here every time growth-past-terminal is
566
+ * observed.
567
+ *
568
+ * Why it exists: the live worker-activity feed latches a terminal `finalized`
569
+ * gate per agentId (worker-activity-feed.ts) so a late zombie tick can't
570
+ * repaint a done worker. The #3315 re-registration re-arms the watcher and
571
+ * re-fires `onProgress`, but those cues are SWALLOWED by that latch — so a
572
+ * resumed long-running worker reads as silence with no card. The gateway
573
+ * wires this to `feed.resurrect(agentId)` (clear the latch) so the subsequent
574
+ * `onProgress` cue repaints a fresh live row. Deterministic: gated solely on
575
+ * the file growing past the recorded terminal boundary, never on model
576
+ * discipline. Best-effort; the callback is expected to be idempotent.
577
+ */
578
+ onResume?: (agentId: string, description: string) => void
554
579
  /**
555
580
  * Called exactly once per sub-agent when its watcher observes a terminal
556
581
  * transition (`done` or `failed`). Mirrors the existing `sub_agent_started`
@@ -846,6 +871,17 @@ const DEFAULT_REAPER_INTERVAL_MS = 15 * 60_000 // 15 minutes
846
871
  */
847
872
  const TERMINAL_CLEANUP_GRACE_MS = 30_000
848
873
 
874
+ /**
875
+ * Bounded tail window (bytes) the boot-reconcile fast-path reads to decide
876
+ * whether a stale historical worker already reached `turn_end` before this
877
+ * boot (see `probeReachedTurnEndAtBoot`). Large enough to contain any realistic
878
+ * final line (a `turn_duration` line is ~100 bytes; an `end_turn` assistant
879
+ * answer line is well under this), yet a tiny fraction of the multi-MB
880
+ * transcripts a long-running worker accumulates — so the boot read is O(window)
881
+ * per record instead of O(filesize). 512 KiB.
882
+ */
883
+ const BOOT_TERMINAL_PROBE_WINDOW_BYTES = 512 * 1024
884
+
849
885
  /**
850
886
  * Throttle for the liveness-path retry of `backfillJsonlAgentId` (see
851
887
  * WorkerEntry.lastBackfillAttemptAt). Cheap (one meta.json read + two
@@ -855,13 +891,14 @@ const BACKFILL_RETRY_INTERVAL_MS = 3000
855
891
 
856
892
  /**
857
893
  * Fix #9a: cap on `terminatedAgentIds` (the re-discovery dedup guard in
858
- * `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the Set only grew
894
+ * `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the map only grew
859
895
  * (added on every terminal cleanup, only ever cleared wholesale in
860
896
  * `stop()`), so a long-lived gateway with sustained sub-agent throughput
861
- * accumulated ids without bound. A `Set` preserves insertion order, so once
897
+ * accumulated ids without bound. A `Map` preserves insertion order, so once
862
898
  * the cap is hit the OLDEST id is evicted on each new insert (simple ring
863
899
  * buffer semantics) — recently-terminated ids (the ones actually at risk of
864
- * a re-discovery race) always stay covered.
900
+ * a re-discovery race, or of a SendMessage resume — issue #3315) always stay
901
+ * covered.
865
902
  */
866
903
  const TERMINATED_AGENT_IDS_CAP = 5000
867
904
 
@@ -1550,8 +1587,8 @@ export function readSubTail(
1550
1587
  const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
1551
1588
  // NIT 3: capture a foreground sub-agent's actual reply text so the
1552
1589
  // turn_end path can suppress a trailing draft of it (see
1553
- // resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
1554
- if (REPLY_TOOLS.has(ev.toolName) && typeof ev.input?.text === 'string') {
1590
+ // resolvePendingSubNarrative). Only reply tools carry the answer.
1591
+ if (isReplyTool(ev.toolName) && typeof ev.input?.text === 'string') {
1555
1592
  entry.lastReplyText = ev.input.text as string
1556
1593
  }
1557
1594
  entry.toolCount++
@@ -1861,18 +1898,26 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1861
1898
  const historicalFiles = new Set<string>()
1862
1899
  /**
1863
1900
  * AgentIds that have transitioned to a terminal state and been swept
1864
- * out of `registry` by `cleanupTerminalAgent`. Issue #1116 (Bug B):
1865
- * the JSONL file outlives the registry entry — Claude Code leaves
1866
- * the file on disk after the sub-agent finishes. Without this guard,
1867
- * the next `rescanSubagentDirs` poll re-discovered the file, called
1868
- * `registerAgent`, the fresh entry read the terminal `turn_duration`
1869
- * line, and `maybySendStateTransition` fired a duplicate "Worker done"
1870
- * notification — looping forever every grace-window.
1901
+ * out of `registry` by `cleanupTerminalAgent`, mapped to the JSONL byte
1902
+ * SIZE at cleanup time. Issue #1116 (Bug B): the JSONL file outlives the
1903
+ * registry entry — Claude Code leaves the file on disk after the sub-agent
1904
+ * finishes. Without this guard, the next `rescanSubagentDirs` poll
1905
+ * re-discovered the file, called `registerAgent`, the fresh entry read the
1906
+ * terminal `turn_duration` line, and `maybySendStateTransition` fired a
1907
+ * duplicate "Worker done" notification — looping forever every grace-window.
1871
1908
  *
1872
- * `scanSubagentsDir` consults this set and treats re-discovered
1873
- * terminal JSONLs as a no-op.
1909
+ * Issue #3315 (SendMessage resume): a resumed sub-agent reuses the SAME
1910
+ * `agent-<id>.jsonl`, appending a NEW turn to it — so the file's size grows
1911
+ * PAST the size it had at terminal cleanup. `scanSubagentsDir` compares the
1912
+ * live file size against this recorded terminal size: at/below it the JSONL
1913
+ * is a static leftover and stays suppressed (#1116 Bug B); above it the
1914
+ * worker resumed and is re-registered live (#3315) so its progress card
1915
+ * updates again. Storing the size (not a bare membership flag) is what makes
1916
+ * that distinction deterministic and lifecycle-driven rather than a
1917
+ * heuristic. Map iteration order is insertion order, so the eviction ring
1918
+ * buffer (Fix #9a) still evicts the oldest id.
1874
1919
  */
1875
- const terminatedAgentIds = new Set<string>()
1920
+ const terminatedAgentIds = new Map<string, number>()
1876
1921
  /**
1877
1922
  * Issue #3023 (card resurrection). Per-worker record of a FALSE terminal
1878
1923
  * finish — a terminal state produced by silent-stall synthesis (NOT a real
@@ -1914,9 +1959,71 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1914
1959
 
1915
1960
  let stopped = false
1916
1961
 
1962
+ /**
1963
+ * Cheap boot-terminal probe. Reads only a BOUNDED TAIL of a JSONL (not the
1964
+ * whole multi-MB transcript) and runs the canonical `projectSubagentLine`
1965
+ * projector over its complete lines to decide whether the worker had already
1966
+ * reached `sub_agent_turn_end` before this boot. Used by the boot-reconcile
1967
+ * fast-path to tell a done-at-boot worker (which still needs terminal
1968
+ * cleanup — feed-row sweep, card collapse/unpin) apart from a running-at-boot
1969
+ * dead prior-session worker (which needs nothing) WITHOUT the expensive full
1970
+ * read. `done` is the only terminal boot state (`WorkerState` never flips to
1971
+ * `'failed'` at boot — see its doc), and its signal (`system/turn_duration`
1972
+ * or an `end_turn` assistant line) is always the tail of the file, so a
1973
+ * bounded window catches it. Conservative on error: an unreadable/oversized
1974
+ * final line returns `false`, so the entry falls through to the running-stale
1975
+ * skip rather than being mis-swept.
1976
+ */
1977
+ function probeReachedTurnEndAtBoot(filePath: string, size: number): boolean {
1978
+ if (size <= 0) return false
1979
+ const start = Math.max(0, size - BOOT_TERMINAL_PROBE_WINDOW_BYTES)
1980
+ let raw: string
1981
+ try {
1982
+ const len = size - start
1983
+ const buf = Buffer.alloc(len)
1984
+ const fd = fs.openSync(filePath, 'r')
1985
+ try {
1986
+ fs.readSync(fd, buf, 0, len, start)
1987
+ } finally {
1988
+ fs.closeSync(fd)
1989
+ }
1990
+ raw = buf.toString('utf-8')
1991
+ } catch {
1992
+ return false
1993
+ }
1994
+ const lines = raw.split('\n')
1995
+ // If we started mid-file, the first fragment is a partial line — drop it
1996
+ // (projectSubagentLine would JSON-parse-fail on it anyway, but be explicit).
1997
+ if (start > 0) lines.shift()
1998
+ const probeState = { hasEmittedStart: true } // suppress start events; irrelevant here
1999
+ for (const line of lines) {
2000
+ if (!line) continue
2001
+ let events
2002
+ try {
2003
+ events = projectSubagentLine(line, 'probe', probeState)
2004
+ } catch {
2005
+ continue
2006
+ }
2007
+ for (const ev of events) {
2008
+ if (ev.kind === 'sub_agent_turn_end') return true
2009
+ }
2010
+ }
2011
+ return false
2012
+ }
2013
+
1917
2014
  // ─── Per-agent registration ─────────────────────────────────────────────
1918
2015
 
1919
- function registerAgent(filePath: string, agentId: string): void {
2016
+ /**
2017
+ * @param resumeFromCursor Issue #3315: when re-registering a worker whose
2018
+ * JSONL resumed growing after a terminal cleanup (a SendMessage resume),
2019
+ * start the tail cursor at the terminal byte boundary instead of 0. The
2020
+ * already-handed-back completed turn (its `sub_agent_turn_end` and all
2021
+ * prior events) is therefore never re-read — so the fresh entry stays
2022
+ * `running` on the NEW turn's content rather than instantly re-reading the
2023
+ * old terminal line and re-finalising. Omitted (⇒ 0) for every normal
2024
+ * registration, which reads from the start.
2025
+ */
2026
+ function registerAgent(filePath: string, agentId: string, resumeFromCursor?: number): void {
1920
2027
  if (registry.has(agentId)) return
1921
2028
  const n = nowFn()
1922
2029
  const isHistorical = historicalFiles.has(filePath)
@@ -1950,6 +2057,85 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1950
2057
  }
1951
2058
  registry.set(agentId, entry)
1952
2059
 
2060
+ // Boot reconcile fast-path (perf): a historical boot entry whose file's
2061
+ // LAST WRITE already exceeds the in-flight promotion freshness window is a
2062
+ // provably-dead prior-session worker. The freshness gate below reaches the
2063
+ // exact same verdict ("stale — leaving historical, no live transition to
2064
+ // observe"), but only AFTER the full initial `readSubTail` (a whole-
2065
+ // transcript parse plus per-record sqlite liveness/backfill round-trips).
2066
+ // On a busy 24/7 agent, hundreds of these dead JSONLs accumulate, and doing
2067
+ // that O(n) work synchronously at boot delayed the gateway answering
2068
+ // Telegram by minutes after every restart — the agent looked dead. Since
2069
+ // the age check ALONE decides "dead prior-session worker" (identical
2070
+ // threshold `inflightPromoteMaxAgeMs`), compute it up front and short-
2071
+ // circuit: register a minimal historical entry, skip the read, the
2072
+ // promotion attempt, and the FSWatcher. A resume re-registration (#3315)
2073
+ // passes isHistorical=false (historicalFiles.delete precedes it) and a
2074
+ // genuinely fresh in-flight-at-boot worker fails the age check, so both
2075
+ // still take the full path below.
2076
+ if (isHistorical) {
2077
+ let mtimeMs: number | null = null
2078
+ let fileSize = 0
2079
+ try {
2080
+ const st = fs.statSync(filePath)
2081
+ if (typeof st.size === 'number') fileSize = st.size
2082
+ if (typeof st.mtimeMs === 'number') mtimeMs = st.mtimeMs
2083
+ } catch {
2084
+ /* unreadable → mtimeMs stays null → conservative full path below */
2085
+ }
2086
+ // Only fast-path when the mtime is KNOWN and already past the freshness
2087
+ // window — a confidently-dead prior-session worker. A missing/unreadable
2088
+ // mtime is NOT treated as dead here (unlike the freshness gate's
2089
+ // Infinity-is-stale rule): we conservatively fall through to the full
2090
+ // path so the read still happens, since the whole optimisation targets
2091
+ // real on-disk files (which always carry an mtime) and the boot cost only
2092
+ // bites at fleet scale where mtimes are present.
2093
+ if (mtimeMs != null && n - mtimeMs > inflightPromoteMaxAgeMs) {
2094
+ const fileAgeMs = n - mtimeMs
2095
+ // The expensive boot work was the full initial `readSubTail` (whole-
2096
+ // transcript parse + per-record sqlite liveness/backfill round-trips)
2097
+ // done for EVERY historical `running` JSONL, including the hundreds a
2098
+ // busy 24/7 agent hoards that were last written days-to-weeks ago. It
2099
+ // produced nothing actionable for a worker the freshness gate was about
2100
+ // to leave historical anyway, yet its O(filesize) disk reads (×~1177 on
2101
+ // the incident agent) delayed the gateway answering Telegram by minutes
2102
+ // after every restart. Replace it with a BOUNDED tail probe.
2103
+ //
2104
+ // The probe distinguishes the two stale-at-boot populations WITHOUT the
2105
+ // full read, so we don't over-skip: a *done*-at-boot worker (its JSONL
2106
+ // already reached `turn_end`) must still reach `scheduleTerminalCleanup`
2107
+ // — otherwise its worker-feed row leaks as a ghost (card never
2108
+ // collapsed/unpinned; #<worker-feed ghost-leak>). A *running*-at-boot
2109
+ // dead prior-session worker (never wrote a terminal `turn_end` — the
2110
+ // incident population) needs nothing: no read, no backfill, no watcher,
2111
+ // no cleanup.
2112
+ const doneAtBoot = probeReachedTurnEndAtBoot(filePath, fileSize)
2113
+ // Park the tail cursor at the boot-time EOF either way. A historical
2114
+ // entry whose cursor sits at EOF is a cheap no-op in the poll loop
2115
+ // (readSubTail statSyncs, sees `size === cursor`, returns before any
2116
+ // openSync/sqlite — the skeleton-cue path is gated on `!historical`), so
2117
+ // no poll-loop special-casing is needed; a genuine post-boot resume that
2118
+ // grows the file PAST this EOF is still read on the next poll. No
2119
+ // FSWatcher: a dead prior-session worker has no live transition to
2120
+ // observe. And a stale worker's pre-existing `turn_end` sits BEFORE the
2121
+ // parked cursor, so it is never replayed as a spurious completion (the
2122
+ // v0.14.23 stale-handback regression stays fixed).
2123
+ tails.set(agentId, { cursor: fileSize, pendingPartial: '', hasEmittedStart: false, watcher: null })
2124
+ if (doneAtBoot) {
2125
+ // Mirror the done-at-boot branch of the full path (below), minus the
2126
+ // whole-transcript parse: suppress a spurious completion notification
2127
+ // and schedule the terminal cleanup so the feed row is swept.
2128
+ entry.state = 'done'
2129
+ entry.completionNotified = true
2130
+ log?.(`subagent-watcher: ${agentId} done at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — historical; scheduling terminal cleanup (bounded tail probe, no whole-transcript parse, no watcher)`)
2131
+ scheduleTerminalCleanup(agentId)
2132
+ } else {
2133
+ log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical, skipping reconcile read (dead prior-session worker, not in-flight; no whole-transcript parse, no backfill, no watcher)`)
2134
+ }
2135
+ return
2136
+ }
2137
+ }
2138
+
1953
2139
  // Backfill jsonl_agent_id linkage. The PreToolUse hook inserts the row
1954
2140
  // keyed on tool_use_id and doesn't know the JSONL stem yet (the JSONL
1955
2141
  // doesn't exist when PreToolUse fires). We bridge that gap here: read
@@ -1976,7 +2162,12 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1976
2162
  }
1977
2163
 
1978
2164
  const tail: SubTail = {
1979
- cursor: 0, // read from start to capture description
2165
+ // Normally read from the start (to replay the full transcript); on a
2166
+ // resume re-registration (#3315) start at the terminal boundary so only
2167
+ // the new turn is read. A resumed cursor sits on a JSONL newline boundary
2168
+ // (the prior turn ended with a complete `turn_end` line), so no partial
2169
+ // line is straddled.
2170
+ cursor: resumeFromCursor ?? 0,
1980
2171
  pendingPartial: '',
1981
2172
  hasEmittedStart: false,
1982
2173
  watcher: null,
@@ -2274,24 +2465,42 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2274
2465
  try { tail.watcher.close() } catch { /* ignore */ }
2275
2466
  tail.watcher = null
2276
2467
  }
2277
- tails.delete(agentId)
2278
2468
  const entry = registry.get(agentId)
2469
+ // Issue #3315: snapshot the JSONL byte size at cleanup so a later
2470
+ // SendMessage-resume — which appends a NEW turn to the SAME
2471
+ // agent-<id>.jsonl — is detected by `scanSubagentsDir` as growth PAST this
2472
+ // size and re-registered. The on-disk stat is the source of truth (it
2473
+ // captures any bytes that landed during the terminal-cleanup grace window,
2474
+ // so only genuinely post-cleanup writes trip a resume); fall back to the
2475
+ // tail cursor, then 0, when the file is unreadable.
2476
+ let terminalSize = tail?.cursor ?? 0
2477
+ if (entry?.filePath) {
2478
+ try {
2479
+ terminalSize = fs.statSync(entry.filePath).size
2480
+ } catch { /* keep the cursor-based fallback */ }
2481
+ }
2482
+ tails.delete(agentId)
2279
2483
  if (entry?.filePath) {
2280
2484
  knownFiles.delete(entry.filePath)
2281
2485
  }
2282
2486
  registry.delete(agentId)
2283
2487
  // Issue #1116 (Bug B): record that this agent has been fully
2284
- // processed so a rescan that rediscovers the still-present JSONL
2285
- // doesn't re-register and re-notify.
2488
+ // processed so a rescan that rediscovers the still-present (unchanged)
2489
+ // JSONL doesn't re-register and re-notify. The recorded value is the
2490
+ // terminal byte size (issue #3315): `scanSubagentsDir` keeps suppressing
2491
+ // while the file stays at/below it, and re-registers once it grows past it
2492
+ // (a resume).
2286
2493
  //
2287
- // Fix #9a: bound the Set so it can't grow unboundedly over a long-lived
2288
- // gateway — evict the oldest id once the cap is hit (Set iteration
2289
- // order is insertion order, so `.values().next().value` is the oldest).
2494
+ // Fix #9a: bound the Map so it can't grow unboundedly over a long-lived
2495
+ // gateway — evict the oldest id once the cap is hit (Map iteration
2496
+ // order is insertion order, so the first key is the oldest). A re-set of
2497
+ // an existing key updates its value without moving it, so a re-completed
2498
+ // (resumed-then-finished-again) agent just refreshes its terminal size.
2290
2499
  if (!terminatedAgentIds.has(agentId) && terminatedAgentIds.size >= terminatedAgentIdsCap) {
2291
- const oldest = terminatedAgentIds.values().next().value
2500
+ const oldest = terminatedAgentIds.keys().next().value
2292
2501
  if (oldest != null) terminatedAgentIds.delete(oldest)
2293
2502
  }
2294
- terminatedAgentIds.add(agentId)
2503
+ terminatedAgentIds.set(agentId, terminalSize)
2295
2504
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
2296
2505
  // Authoritative terminal sweep → notify the worker-activity feed so its row
2297
2506
  // for this agent is removed even on the paths that never fire `onFinish`
@@ -2865,11 +3074,68 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2865
3074
  const filePath = join(subagentsPath, e)
2866
3075
  if (knownFiles.has(filePath)) continue
2867
3076
  const agentId = e.slice('agent-'.length, -'.jsonl'.length)
2868
- // Issue #1116 (Bug B): skip JSONLs whose agent already completed
2869
- // and was swept by cleanupTerminalAgent. Re-adding to knownFiles
2870
- // here would let a subsequent rescan re-register, fire a duplicate
2871
- // "Worker done", and loop forever every grace-window.
2872
- if (terminatedAgentIds.has(agentId)) continue
3077
+ // Issue #1116 (Bug B) + issue #3315 (SendMessage resume). A rediscovered
3078
+ // JSONL whose agent already went terminal + was swept is normally a
3079
+ // static leftover Claude Code left on disk — re-registering it would
3080
+ // re-read the terminal turn and re-fire a duplicate handback, looping
3081
+ // every grace-window (#1116). BUT a SendMessage resume appends a NEW turn
3082
+ // to the SAME agent-<id>.jsonl, growing the file PAST the size it had at
3083
+ // terminal cleanup. That growth is the deterministic, lifecycle-driven
3084
+ // signal (not a heuristic) that the worker resumed and must be tracked
3085
+ // again so its progress card resumes updating. Distinguish the two by the
3086
+ // recorded terminal size.
3087
+ const terminalSize = terminatedAgentIds.get(agentId)
3088
+ if (terminalSize !== undefined) {
3089
+ // A worker whose terminal state was a FALSE finish (silent-stall
3090
+ // synthesis) is owned by the issue-#3023 resurrection path
3091
+ // (`checkResurrections`), which carries the bounded-chain +
3092
+ // `onResurrect`/`onWorkerLost` semantics and re-reads from cursor 0 to
3093
+ // rebuild the still-in-progress turn. Skipping it here avoids
3094
+ // double-handling the same growth in one poll; this branch's concern is
3095
+ // GENUINE terminal completions (real `turn_end`, no false-finish record)
3096
+ // that were resumed via SendMessage (issue #3315). A false finish that
3097
+ // has since been evicted from `falseFinishTracker` (512-cap) is no
3098
+ // longer double-handled, so falling through to re-register it live here
3099
+ // is correct.
3100
+ if (falseFinishTracker.has(agentId)) continue
3101
+ let currentSize: number
3102
+ try {
3103
+ currentSize = fs.statSync(filePath).size
3104
+ } catch {
3105
+ continue // unreadable — nothing to resume; keep suppressing
3106
+ }
3107
+ // At/below the terminal snapshot ⇒ static leftover file (#1116 Bug B):
3108
+ // stay suppressed, do NOT re-register.
3109
+ if (currentSize <= terminalSize) continue
3110
+ // Grew past it ⇒ the worker resumed (#3315). Re-register as a fresh
3111
+ // LIVE worker, reading only from the terminal boundary so the
3112
+ // already-completed turn is not replayed. At-most-once per resume:
3113
+ // dropping the id from terminatedAgentIds means the next completion
3114
+ // re-adds it with the new (larger) terminal size, so one resume yields
3115
+ // exactly one re-registration.
3116
+ log?.(`subagent-watcher: agent ${agentId} resumed after terminal cleanup — JSONL grew ${terminalSize} → ${currentSize} bytes (SendMessage resume); re-registering as live so its progress card resumes (issue #3315)`)
3117
+ terminatedAgentIds.delete(agentId)
3118
+ historicalFiles.delete(filePath)
3119
+ knownFiles.add(filePath)
3120
+ // Issue #3373: the worker-activity feed latched a terminal `finalized`
3121
+ // gate for this agentId at its genuine completion, so the `onProgress`
3122
+ // cues the re-registration is about to re-fire would be SWALLOWED and no
3123
+ // card would re-surface. Fire onResume FIRST (before registerAgent, whose
3124
+ // initial read can synchronously re-emit onProgress) so the gateway clears
3125
+ // that gate; the subsequent live cue then repaints a fresh row. Fired only
3126
+ // on growth-past-terminal — never for a static leftover (the
3127
+ // `currentSize <= terminalSize` continue above), so the anti-zombie latch
3128
+ // still blocks genuinely-finished workers.
3129
+ if (config.onResume != null) {
3130
+ try {
3131
+ config.onResume(agentId, registry.get(agentId)?.description ?? 'sub-agent')
3132
+ } catch (cbErr) {
3133
+ log?.(`subagent-watcher: onResume callback error ${agentId}: ${(cbErr as Error).message}`)
3134
+ }
3135
+ }
3136
+ registerAgent(filePath, agentId, terminalSize)
3137
+ continue
3138
+ }
2873
3139
  knownFiles.add(filePath)
2874
3140
  // During the initial boot scan, mark every discovered file as
2875
3141
  // historical so stall-detection and completion notifications are
@@ -20,6 +20,11 @@ import { readFileSync } from 'node:fs'
20
20
  import { resolve } from 'node:path'
21
21
 
22
22
  const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
23
+ // #2996 P4-B: drainActivitySummary (the card-OPEN persist site) and
24
+ // clearActivitySummary (the normal-CLOSE clear site) moved VERBATIM into
25
+ // narrative-lane.ts (bodies indented +2 inside the factory — multi-line
26
+ // markers below use the lane spelling).
27
+ const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
23
28
 
24
29
  function between(src: string, startMarker: string, endMarker: string): string {
25
30
  const after = src.split(startMarker)[1] ?? ''
@@ -30,7 +35,7 @@ describe('activity-card durability wiring', () => {
30
35
  it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
31
36
  // The open branch runs when activityMessageId transitions null → set.
32
37
  const openBranch = between(
33
- gatewaySrc,
38
+ laneSrc,
34
39
  'if (turn.activityMessageId == null) {',
35
40
  'turn.activityLastSentRender = target',
36
41
  )
@@ -55,8 +60,8 @@ describe('activity-card durability wiring', () => {
55
60
 
56
61
  it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
57
62
  const closeBody = between(
58
- gatewaySrc,
59
- 'const id = turn.activityMessageId\n turn.activityMessageId = null',
63
+ laneSrc,
64
+ 'const id = turn.activityMessageId\n turn.activityMessageId = null',
60
65
  'if (CLEAR_STATUS_ON_COMPLETION)',
61
66
  )
62
67
  expect(closeBody.length).toBeGreaterThan(50)
@@ -32,6 +32,21 @@ const gatewaySrc = readFileSync(
32
32
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
33
33
  'utf-8',
34
34
  )
35
+ // #2996 P4-A: one sticky-latch set site + the per-turn object-literal init live
36
+ // in handleSessionEvent (the tool-label/answer send-success + the `enqueue`
37
+ // turn ctor), which moved VERBATIM to stream-render.ts. The population spans
38
+ // gateway.ts + that module.
39
+ const streamSrc = readFileSync(
40
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
41
+ 'utf-8',
42
+ )
43
+ // #2996 P4-B: the drain-side set-TRUE site (drainActivitySummary) moved on
44
+ // into narrative-lane.ts.
45
+ const laneSrc = readFileSync(
46
+ resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'),
47
+ 'utf-8',
48
+ )
49
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc
35
50
 
36
51
  describe('M-2: activityEverOpened sticky-true invariant', () => {
37
52
  it('activityEverOpened = true appears only at the two feed-OPEN signal sites', () => {
@@ -39,19 +54,19 @@ describe('M-2: activityEverOpened sticky-true invariant', () => {
39
54
  // handback pre-turn ADOPTION seed (an adopted turn only edits, so it stamps
40
55
  // the flag itself). Both are set-TRUE; the sticky invariant (no reset to
41
56
  // false) is enforced by the next test.
42
- const setTrueMatches = [...gatewaySrc.matchAll(/activityEverOpened\s*=\s*true/g)]
57
+ const setTrueMatches = [...gatewayAndStreamSrc.matchAll(/activityEverOpened\s*=\s*true/g)]
43
58
  expect(setTrueMatches).toHaveLength(2)
44
59
  })
45
60
 
46
61
  it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
47
62
  // The only `false` value must be in the object literal initialiser
48
63
  // (e.g. `activityEverOpened: false`), never a standalone reassignment.
49
- const resetMatches = [...gatewaySrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
64
+ const resetMatches = [...gatewayAndStreamSrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
50
65
  expect(resetMatches).toHaveLength(0)
51
66
  })
52
67
 
53
68
  it('activityEverOpened is initialised false in the turn object literal (per-turn reset)', () => {
54
69
  // The object literal form `activityEverOpened: false` must exist (per-turn init).
55
- expect(gatewaySrc).toMatch(/activityEverOpened:\s*false/)
70
+ expect(gatewayAndStreamSrc).toMatch(/activityEverOpened:\s*false/)
56
71
  })
57
72
  })
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Outcome pins for the agent-emitted inline-keyboard callback family
3
+ * (`agent:` prefix, #271) extracted from the gateway's callback_query
4
+ * dispatcher (switchroom#2996 P6). Drives handleAgentButtonCallback with a
5
+ * mock ctx + injected deps and asserts: fall-through for non-agent data, the
6
+ * allowFrom gate, the ack-toast-first contract (#710), the exact InboundMessage
7
+ * envelope routed through the injected turn-safe delivery, the restarting
8
+ * notice only on bridge-offline, and the single-use keyboard strip + meta
9
+ * cleanup.
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest'
13
+ import {
14
+ handleAgentButtonCallback,
15
+ type AgentButtonCallbackDeps,
16
+ } from '../gateway/agent-button-callback-handler.js'
17
+
18
+ const DATA = 'agent:approve_pr'
19
+
20
+ function makeCtx(over: Record<string, unknown> = {}) {
21
+ return {
22
+ from: { id: 42, username: 'ken' },
23
+ chat: { id: 42 },
24
+ callbackQuery: {
25
+ message: {
26
+ message_id: 77,
27
+ text: 'Deploy?',
28
+ reply_markup: {
29
+ inline_keyboard: [[{ text: 'Approve', callback_data: DATA }]],
30
+ },
31
+ },
32
+ },
33
+ answerCallbackQuery: vi.fn(async () => {}),
34
+ editMessageText: vi.fn(async () => {}),
35
+ editMessageReplyMarkup: vi.fn(async () => {}),
36
+ ...over,
37
+ } as any
38
+ }
39
+
40
+ function makeDeps(over: Partial<AgentButtonCallbackDeps> = {}) {
41
+ const deliverButtonTapInbound = vi.fn(async () => 'delivered')
42
+ const sendRestartingNotice = vi.fn()
43
+ const metaDelete = vi.fn()
44
+ const deps: AgentButtonCallbackDeps = {
45
+ loadAccess: () => ({ allowFrom: ['42'] }),
46
+ agentButtonMeta: { get: () => undefined, delete: metaDelete },
47
+ deliverButtonTapInbound,
48
+ sendRestartingNotice,
49
+ buttonConfirmSingleUseWarned: new Set(),
50
+ buttonConfirmParseModeWarned: new Set(),
51
+ log: vi.fn(),
52
+ ...over,
53
+ }
54
+ return { deps, deliverButtonTapInbound, sendRestartingNotice, metaDelete }
55
+ }
56
+
57
+ describe('handleAgentButtonCallback — routing + gates', () => {
58
+ it('returns false for non-agent callback data', async () => {
59
+ const { deps } = makeDeps()
60
+ const ctx = makeCtx()
61
+ expect(await handleAgentButtonCallback(ctx, 'perm:allow:abcde', deps)).toBe(false)
62
+ expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('gates non-allowlisted senders before any delivery', async () => {
66
+ const { deps, deliverButtonTapInbound } = makeDeps()
67
+ const ctx = makeCtx({ from: { id: 666 } })
68
+ expect(await handleAgentButtonCallback(ctx, DATA, deps)).toBe(true)
69
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
70
+ expect(deliverButtonTapInbound).not.toHaveBeenCalled()
71
+ })
72
+ })
73
+
74
+ describe('handleAgentButtonCallback — delivery envelope', () => {
75
+ it('acks FIRST with the default toast, then delivers the exact inbound (#710/#271)', async () => {
76
+ const { deps, deliverButtonTapInbound } = makeDeps()
77
+ const ctx = makeCtx()
78
+ process.env.SWITCHROOM_AGENT_NAME = 'klanker'
79
+ expect(await handleAgentButtonCallback(ctx, DATA, deps)).toBe(true)
80
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✓ received' })
81
+ // Ack precedes delivery.
82
+ expect(ctx.answerCallbackQuery.mock.invocationCallOrder[0]).toBeLessThan(
83
+ deliverButtonTapInbound.mock.invocationCallOrder[0],
84
+ )
85
+ const [agent, msg] = deliverButtonTapInbound.mock.calls[0] as unknown as [string, any]
86
+ expect(agent).toBe('klanker')
87
+ expect(msg).toMatchObject({
88
+ type: 'inbound',
89
+ chatId: '42',
90
+ messageId: 77,
91
+ user: 'ken',
92
+ userId: 42,
93
+ text: '[user tapped button: "Approve" → approve_pr]',
94
+ meta: expect.objectContaining({
95
+ button_callback: 'true',
96
+ button_callback_data: 'approve_pr',
97
+ button_text: 'Approve',
98
+ }),
99
+ })
100
+ })
101
+
102
+ it('uses the stashed per-button ack_text when present', async () => {
103
+ // meta is keyed by the RAW payload (prefix stripped).
104
+ const metaMap = new Map([['approve_pr', { ack_text: 'On it!' }]])
105
+ const { deps } = makeDeps({
106
+ agentButtonMeta: { get: () => metaMap as any, delete: vi.fn() },
107
+ })
108
+ const ctx = makeCtx()
109
+ await handleAgentButtonCallback(ctx, DATA, deps)
110
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'On it!' })
111
+ })
112
+
113
+ it('shows the restarting notice ONLY on bridge-offline', async () => {
114
+ const { deps, sendRestartingNotice } = makeDeps({
115
+ deliverButtonTapInbound: vi.fn(async () => 'buffered-bridge-offline'),
116
+ })
117
+ await handleAgentButtonCallback(makeCtx(), DATA, deps)
118
+ expect(sendRestartingNotice).toHaveBeenCalledWith('42', undefined)
119
+
120
+ const second = makeDeps({
121
+ deliverButtonTapInbound: vi.fn(async () => 'buffered-mid-turn'),
122
+ })
123
+ await handleAgentButtonCallback(makeCtx(), DATA, second.deps)
124
+ expect(second.sendRestartingNotice).not.toHaveBeenCalled()
125
+ })
126
+ })
127
+
128
+ describe('handleAgentButtonCallback — single-use strip', () => {
129
+ it('strips the keyboard by default (no stashed meta) after the tap', async () => {
130
+ const { deps } = makeDeps()
131
+ const ctx = makeCtx()
132
+ await handleAgentButtonCallback(ctx, DATA, deps)
133
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({
134
+ reply_markup: { inline_keyboard: [] },
135
+ })
136
+ })
137
+
138
+ it('preserves the keyboard when a button opts out via single_use:false', async () => {
139
+ const metaMap = new Map([['approve_pr', { single_use: false }]])
140
+ const metaDelete = vi.fn()
141
+ const { deps } = makeDeps({
142
+ agentButtonMeta: { get: () => metaMap as any, delete: metaDelete },
143
+ })
144
+ const ctx = makeCtx()
145
+ await handleAgentButtonCallback(ctx, DATA, deps)
146
+ expect(ctx.editMessageReplyMarkup).not.toHaveBeenCalled()
147
+ expect(metaDelete).not.toHaveBeenCalled()
148
+ })
149
+ })