switchroom 0.19.15 → 0.19.16

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 (27) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +30 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +693 -433
  6. package/telegram-plugin/dist/server.js +30 -1
  7. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  8. package/telegram-plugin/gateway/gateway.ts +7 -58
  9. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  10. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  11. package/telegram-plugin/gateway/outbox-sweep.ts +92 -18
  12. package/telegram-plugin/gateway/rich-message-handler.ts +10 -4
  13. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  14. package/telegram-plugin/session-tail.ts +88 -1
  15. package/telegram-plugin/silence-poke.ts +118 -1
  16. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  17. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  18. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  19. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  20. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  21. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  22. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  23. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  24. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  25. package/telegram-plugin/tts-normalize.ts +12 -0
  26. package/telegram-plugin/voice-normalize-text.ts +100 -0
  27. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -134,7 +134,23 @@ export type SessionEvent =
134
134
  // (naive summing across lines over-counts). Null messageId → un-dedupable,
135
135
  // counted as-is. Mirrors `sub_agent_usage` but for the parent's OWN tokens.
136
136
  | { kind: 'usage'; messageId: string | null; totalTokens: number }
137
- | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
137
+ | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string
138
+ /**
139
+ * #3519 sharpen: the claude-CLI background-task id when THIS tool_result
140
+ * is the launch acknowledgement of a shell moved to the background (a
141
+ * foreground Bash that exceeded the CLI foreground window, or an explicit
142
+ * run_in_background:true). Sourced PRIMARILY from the structured
143
+ * top-level `toolUseResult.backgroundTaskId` field (version-robust), with
144
+ * a regex on the `content` string as a secondary. Absent on ordinary
145
+ * (completed-in-foreground) tool_results. Marks the shell ALIVE. */
146
+ backgroundTaskId?: string }
147
+ /**
148
+ * #3519 sharpen: a claude-CLI `<task-notification>` — the proactive
149
+ * completion signal the CLI enqueues when a backgrounded shell finishes
150
+ * (`<status>completed</status>`) or errors. Marks the shell DEAD, restoring
151
+ * ~300s wedge recovery once the launching bash is no longer running.
152
+ */
153
+ | { kind: 'task_notification'; taskId: string; status: string }
138
154
  // `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
139
155
  // the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
140
156
  // deterministic quiescence-flush signal, which — unlike the orphaned-reply
@@ -259,6 +275,60 @@ function extractToolResultErrorText(content: unknown): string {
259
275
  return ''
260
276
  }
261
277
 
278
+ /**
279
+ * #3519 sharpen — ALIVE marker (primary): read the claude-CLI background-task
280
+ * id off the structured, sibling top-level `toolUseResult.backgroundTaskId`
281
+ * field of a `type:"user"` transcript line. This is the version-robust source
282
+ * (a named JSON field, not prose). Real shape (carrie session
283
+ * a6d2d33a-…, v2.1.197, line 109):
284
+ * "toolUseResult":{…,"backgroundTaskId":"bxa4sv3dq"}
285
+ * Returns the id, or null when the line carries no backgrounded shell.
286
+ */
287
+ export function parseBackgroundTaskId(obj: Record<string, unknown>): string | null {
288
+ const tur = obj.toolUseResult
289
+ if (typeof tur === 'object' && tur != null) {
290
+ const id = (tur as Record<string, unknown>).backgroundTaskId
291
+ if (typeof id === 'string' && id.length > 0) return id
292
+ }
293
+ return null
294
+ }
295
+
296
+ /**
297
+ * #3519 sharpen — ALIVE marker (secondary): match the launch STRING in the
298
+ * tool_result `content` when the structured field is absent (older CLI, or a
299
+ * shape change that keeps the human string). Real bytes (same line 109):
300
+ * "Command running in background with ID: bxa4sv3dq. Output is being written…"
301
+ * DELIBERATELY the fallback, not the primary — if BOTH miss (CLI changed the
302
+ * string too) the caller degrades to the 900s-bounded sawBash guard. Accepts
303
+ * the same string|content-block shapes as extractToolResultErrorText.
304
+ */
305
+ export function parseBackgroundLaunchString(content: unknown): string | null {
306
+ const text = typeof content === 'string'
307
+ ? content
308
+ : extractToolResultErrorText(content)
309
+ const m = text.match(/Command running in background with ID: (\w+)/)
310
+ return m != null ? m[1] : null
311
+ }
312
+
313
+ /**
314
+ * #3519 sharpen — DEAD marker: parse a claude-CLI `<task-notification>` block.
315
+ * The CLI enqueues this proactively when a backgrounded shell finishes. Real
316
+ * bytes (carrie session a6d2d33a-…, v2.1.197, line 175 queue-operation enqueue
317
+ * content, and mirrored line 180 attachment):
318
+ * "<task-notification>\n<task-id>bxa4sv3dq</task-id>\n…\n<status>completed</status>\n…"
319
+ * Returns {taskId,status} when both tags are present, else null (so an
320
+ * ordinary inbound enqueue falls through to the normal user-turn path).
321
+ */
322
+ export function parseTaskNotification(
323
+ content: string,
324
+ ): { taskId: string; status: string } | null {
325
+ if (!content.includes('<task-notification>')) return null
326
+ const idM = content.match(/<task-id>([^<]+)<\/task-id>/)
327
+ const stM = content.match(/<status>([^<]+)<\/status>/)
328
+ if (idM == null || stM == null) return null
329
+ return { taskId: idM[1].trim(), status: stM[1].trim() }
330
+ }
331
+
262
332
  /**
263
333
  * THE single text→narrative projection primitive. Both projectTranscriptLine
264
334
  * and projectSubagentLine derive their text events through this helper so
@@ -481,6 +551,15 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
481
551
  const op = obj.operation as string | undefined
482
552
  if (op === 'enqueue') {
483
553
  const content = (obj.content as string | undefined) ?? ''
554
+ // #3519 sharpen: a `<task-notification>` is NOT a real inbound user
555
+ // turn — it is the claude CLI's proactive background-shell completion
556
+ // signal, enqueued as a synthetic command. Project it as the DEAD
557
+ // marker so the liveness registry can drop the shell (restoring ~300s
558
+ // wedge recovery) rather than mis-reading it as a user message.
559
+ const notif = parseTaskNotification(content)
560
+ if (notif != null) {
561
+ return [{ kind: 'task_notification', taskId: notif.taskId, status: notif.status }]
562
+ }
484
563
  const { chatId, messageId, threadId } = parseChannelMeta(content)
485
564
  return [{ kind: 'enqueue', chatId, messageId, threadId, rawContent: content }]
486
565
  }
@@ -578,6 +657,11 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
578
657
  const message = obj.message as Record<string, unknown> | undefined
579
658
  const content = message?.content as Array<Record<string, unknown>> | undefined
580
659
  if (!Array.isArray(content)) return []
660
+ // #3519 sharpen: the background-launch id is a per-LINE fact carried on
661
+ // the sibling top-level `toolUseResult.backgroundTaskId` (version-robust
662
+ // structured field), with the launch STRING as a secondary. Parsed once
663
+ // and attached to this line's tool_result event to mark the shell ALIVE.
664
+ const backgroundTaskId = parseBackgroundTaskId(obj)
581
665
  const events: SessionEvent[] = []
582
666
  for (const c of content) {
583
667
  if (c.type === 'tool_result') {
@@ -588,6 +672,9 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
588
672
  toolName: null,
589
673
  isError,
590
674
  errorText: isError ? extractToolResultErrorText(c.content) : undefined,
675
+ backgroundTaskId: backgroundTaskId
676
+ ?? parseBackgroundLaunchString(c.content)
677
+ ?? undefined,
591
678
  })
592
679
  }
593
680
  }
@@ -93,6 +93,34 @@ export interface SilencePokeState {
93
93
  * clock — the design choice in this module's header is preserved.
94
94
  * We only enrich the fallback TEXT, not the timing. */
95
95
  inFlightTools: Map<string, { name: string; startedAt: number; label: string | null }>
96
+ /**
97
+ * #3519: true once ANY `Bash` tool_use has been observed in this turn.
98
+ * A foreground `Bash` that exceeds the claude-CLI foreground window is
99
+ * auto-moved to the background: its `tool_result` returns to the model
100
+ * (so `inFlightTools` empties and `isLegitimatelyWorking()`'s foreground
101
+ * / async-dispatch checks all go false), yet the process keeps running
102
+ * and the model sits silent waiting on it. That silent gap is invisible
103
+ * to every existing "still working" signal, so the 300s fallback fired
104
+ * mid-work — nulling `currentTurn`, tearing down the pinned progress
105
+ * card, and letting the next tool burst mint a BRAND-NEW card (the
106
+ * stacked-cards bug). Arming this flag on the first Bash of the turn lets
107
+ * the fallback defer such gaps. Turn-scoped (set here, only cleared by a
108
+ * fresh `startTurn`); bounded by `fallbackHardCeiling` so a genuinely
109
+ * wedged bash-turn still unwedges at the ceiling. Does NOT reset the
110
+ * silence clock — a real reply / feed edit still does that; this only
111
+ * gates the terminal teardown. */
112
+ sawBashThisTurn: boolean
113
+ /**
114
+ * #3519 sharpen: claude-CLI background shells PROVEN alive right now. A
115
+ * shell is added on its launch marker (structured `backgroundTaskId`, via
116
+ * `noteBackgroundShellAlive`) and removed when the CLI proactively reports
117
+ * it done (`<task-notification>` completed/failed) or the model `KillShell`s
118
+ * it (via `noteBackgroundShellDead`). Non-empty ⇒ a process is running, so
119
+ * defer the 300s teardown. Empty ⇒ nothing running, so a FINISHED bash no
120
+ * longer defers — restoring ~300s wedge recovery that the coarse
121
+ * `sawBashThisTurn` guard held to 900s. Turn-scoped (cleared by startTurn),
122
+ * bounded by `fallbackHardCeiling` like every other defer. */
123
+ aliveShells: Set<string>
96
124
  }
97
125
 
98
126
  export interface ThresholdsMs {
@@ -217,6 +245,21 @@ const state = new Map<string, SilencePokeState>()
217
245
  let timer: ReturnType<typeof setInterval> | null = null
218
246
  let activeDeps: SilencePokeDeps | null = null
219
247
 
248
+ /**
249
+ * #3519 sharpen — deterministic SAFE-DEGRADATION latch (process-scoped).
250
+ * Flipped true the first time `noteBackgroundShellAlive` fires, i.e. the
251
+ * moment the session-tail marker parser resolves a real `backgroundTaskId`
252
+ * against the live claude CLI. Its purpose is to distinguish two look-alike
253
+ * states that both present as "a Bash ran but no shell is registered alive":
254
+ * • CLI markers work, the bash simply FINISHED → trust the empty alive-set,
255
+ * let the 300s fallback fire (fast wedge recovery restored); and
256
+ * • CLI changed its markers so the parser never matches → we CANNOT tell a
257
+ * live auto-backgrounded bash from a finished one, so fall back to the
258
+ * coarse 900s-bounded `sawBashThisTurn` guard (never stack, never hang).
259
+ * Once ANY marker has parsed, the CLI is proven compatible and the alive-set
260
+ * is authoritative. Never seen ⇒ stay conservative. Reset by tests only. */
261
+ let bgMarkerParserConfirmed = false
262
+
220
263
  /**
221
264
  * True iff the kill switch is OFF. Re-read every call so tests can
222
265
  * toggle process.env without reloading the module.
@@ -238,9 +281,40 @@ export function startTurn(key: string, now: number): void {
238
281
  fallbackFired: false,
239
282
  floorFired: false,
240
283
  inFlightTools: new Map(),
284
+ sawBashThisTurn: false,
285
+ aliveShells: new Set(),
241
286
  })
242
287
  }
243
288
 
289
+ /**
290
+ * #3519 sharpen: register a claude-CLI background shell as ALIVE for `key`.
291
+ * Called by the gateway when session-tail resolves a `backgroundTaskId` on a
292
+ * tool_result (a foreground Bash auto-moved to the background, or an explicit
293
+ * run_in_background:true). Flips the process-scoped parser-confirmed latch so
294
+ * the safe-degradation path knows the CLI markers are compatible. No-op when
295
+ * the key has no live turn (the launch outlived its turn — the cross-turn
296
+ * ambient owns that case, not the 300s teardown).
297
+ */
298
+ export function noteBackgroundShellAlive(key: string, shellId: string): void {
299
+ bgMarkerParserConfirmed = true
300
+ const s = state.get(key)
301
+ if (s == null) return
302
+ s.aliveShells.add(shellId)
303
+ }
304
+
305
+ /**
306
+ * #3519 sharpen: mark a background shell DEAD for `key`. Called by the gateway
307
+ * on a `<task-notification>` (completed/failed) or a `KillShell`. Idempotent —
308
+ * removing an unknown id (already cleared, or launched in a prior turn) is a
309
+ * no-op. Once the set empties, a subsequent >300s silence is a real wedge and
310
+ * the fallback fires at ~300s.
311
+ */
312
+ export function noteBackgroundShellDead(key: string, shellId: string): void {
313
+ const s = state.get(key)
314
+ if (s == null) return
315
+ s.aliveShells.delete(shellId)
316
+ }
317
+
244
318
  /**
245
319
  * Record a fresh user-visible outbound message (reply or stream_reply
246
320
  * first-emit). Resets the silence clock so the 300s fallback is measured
@@ -313,6 +387,14 @@ export function noteToolStart(
313
387
  const s = state.get(key)
314
388
  if (s == null) return
315
389
  s.inFlightTools.set(toolUseId, { name, startedAt: now, label })
390
+ // #3519: arm the background-bash defer on the first Bash of the turn.
391
+ // A foreground Bash can be auto-moved to the background by the claude CLI
392
+ // once it crosses the foreground window; its tool_result then returns
393
+ // (draining inFlightTools) while the process keeps running and the model
394
+ // goes silent waiting on it. That gap is invisible to every other "still
395
+ // working" signal, so without this the 300s fallback tore down the pinned
396
+ // card mid-work and the next burst minted a fresh one (stacked cards).
397
+ if (name === 'Bash') s.sawBashThisTurn = true
316
398
  }
317
399
 
318
400
  /**
@@ -541,8 +623,13 @@ function tick(now: number): void {
541
623
  // 2. Legacy `deferFallbackWhileToolInFlight` boolean — covers only
542
624
  // `inFlightTools.size > 0`; kept for test fixtures that set it
543
625
  // directly without wiring the callback.
626
+ // 3. #3519 `sawBashThisTurn` — covers the claude-CLI-side background
627
+ // bash gap the two paths above are blind to (foreground Bash moved
628
+ // to background: tool_result returned, process still running, model
629
+ // silent). Independent of the callback so it holds even when
630
+ // `isLegitimatelyWorking()` returns false.
544
631
  //
545
- // In both cases: `continue` WITHOUT setting fallbackFired so the next
632
+ // In all cases: `continue` WITHOUT setting fallbackFired so the next
546
633
  // tick re-checks. Once the work signal clears and the turn stays silent
547
634
  // past the base threshold, or the ceiling is crossed, the fallback fires.
548
635
  const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
@@ -551,6 +638,30 @@ function tick(now: number): void {
551
638
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
552
639
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
553
640
  if (activeDeps.isLegitimatelyWorking(key)) continue
641
+ // #3519 sharpen: even when the callback reports "not working", a
642
+ // `Bash` earlier this turn may have been auto-moved to the CLI-side
643
+ // background (its tool_result returned, so every foreground /
644
+ // async-dispatch signal is false, yet the process is alive and the
645
+ // model sits silent on it) — the gap `isLegitimatelyWorking` is
646
+ // blind to by construction. Two-layer defer, sharp then safe:
647
+ //
648
+ // (1) PROVEN-alive — a background shell registered from its
649
+ // structured `backgroundTaskId` launch marker and not yet
650
+ // reported dead (`<task-notification>` completed / KillShell).
651
+ // A process is running RIGHT NOW, so defer. When it finishes,
652
+ // the alive-set empties and the fallback fires at ~300s —
653
+ // restoring fast wedge recovery the coarse guard held to 900s.
654
+ if (s.aliveShells.size > 0) continue
655
+ // (2) SAFE DEGRADATION — only while the CLI markers have NEVER
656
+ // parsed (`!bgMarkerParserConfirmed`): we cannot then tell a
657
+ // live auto-backgrounded bash from a finished one, so fall
658
+ // back to the coarse turn-scoped `sawBashThisTurn` guard —
659
+ // 900s-bounded by `fallbackHardCeiling` (never stacks, never
660
+ // hangs). Once ANY marker has parsed, the CLI is proven
661
+ // compatible, this layer switches off, and layer (1) alone
662
+ // governs. Scoped to the modern callback-wired path so the
663
+ // legacy defer-off / defer-bool fixtures keep their semantics.
664
+ if (!bgMarkerParserConfirmed && s.sawBashThisTurn) continue
554
665
  } else if (!forceDisable && activeDeps.deferFallbackWhileToolInFlight === true && s.inFlightTools.size > 0) {
555
666
  continue
556
667
  }
@@ -657,4 +768,10 @@ export function __getStateForTests(key: string): SilencePokeState | undefined {
657
768
  export function __resetAllForTests(): void {
658
769
  state.clear()
659
770
  stopTimer()
771
+ bgMarkerParserConfirmed = false
772
+ }
773
+
774
+ /** Test-only: peek at the process-scoped #3519 safe-degradation latch. */
775
+ export function __bgMarkerParserConfirmedForTests(): boolean {
776
+ return bgMarkerParserConfirmed
660
777
  }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { applyBackgroundShellLiveness } from '../gateway/background-shell-liveness.js'
5
+ import { projectTranscriptLine, type SessionEvent } from '../session-tail.js'
6
+
7
+ // #3519 sharpen — the glue that maps parsed session events to the silence-poke
8
+ // background-shell registry. Driven by the REAL fixtures (carrie session
9
+ // a6d2d33a-…, claude v2.1.197; see tests/fixtures/bg-shell-liveness-3519.jsonl)
10
+ // parsed through the production projectTranscriptLine path, so the event shapes
11
+ // here are exactly what the gateway sees at runtime.
12
+ describe('applyBackgroundShellLiveness (real markers)', () => {
13
+ const FIXTURE = join(__dirname, 'fixtures', 'bg-shell-liveness-3519.jsonl')
14
+ const lines = readFileSync(FIXTURE, 'utf8').split('\n').filter(l => l.length > 0)
15
+ const aliveEv = projectTranscriptLine(lines[0]).find(e => e.kind === 'tool_result')!
16
+ const deadEv = projectTranscriptLine(lines[1])[0]
17
+ // The real shell id carried by both the ALIVE and DEAD fixture lines.
18
+ const REAL_ID = aliveEv.kind === 'tool_result' ? aliveEv.backgroundTaskId : undefined
19
+
20
+ function spyRegistry() {
21
+ const calls: Array<{ fn: 'alive' | 'dead'; key: string; id: string }> = []
22
+ return {
23
+ calls,
24
+ noteBackgroundShellAlive: (key: string, id: string) => calls.push({ fn: 'alive', key, id }),
25
+ noteBackgroundShellDead: (key: string, id: string) => calls.push({ fn: 'dead', key, id }),
26
+ }
27
+ }
28
+
29
+ it('ALIVE: a real tool_result with backgroundTaskId → noteBackgroundShellAlive', () => {
30
+ const r = spyRegistry()
31
+ applyBackgroundShellLiveness(r, 'c:0', aliveEv)
32
+ expect(r.calls).toEqual([{ fn: 'alive', key: 'c:0', id: REAL_ID! }])
33
+ })
34
+
35
+ it('DEAD: a real <task-notification> completion → noteBackgroundShellDead', () => {
36
+ const r = spyRegistry()
37
+ applyBackgroundShellLiveness(r, 'c:0', deadEv)
38
+ expect(r.calls).toEqual([{ fn: 'dead', key: 'c:0', id: REAL_ID! }])
39
+ })
40
+
41
+ it('DEAD: a KillShell tool_use → noteBackgroundShellDead by shell_id', () => {
42
+ const r = spyRegistry()
43
+ const ev: SessionEvent = { kind: 'tool_use', toolName: 'KillShell', toolUseId: 't9', input: { shell_id: REAL_ID! } }
44
+ applyBackgroundShellLiveness(r, 'c:0', ev)
45
+ expect(r.calls).toEqual([{ fn: 'dead', key: 'c:0', id: REAL_ID! }])
46
+ })
47
+
48
+ it('no-op: an ordinary (foreground-completed) tool_result carries no marker', () => {
49
+ const r = spyRegistry()
50
+ const ev: SessionEvent = { kind: 'tool_result', toolUseId: 't1', toolName: null }
51
+ applyBackgroundShellLiveness(r, 'c:0', ev)
52
+ expect(r.calls).toEqual([])
53
+ })
54
+
55
+ it('DEFENSIVE: a hypothetical non-terminal <task-notification> does NOT mark the shell dead', () => {
56
+ // Guards a future CLI that emits an interim (still-running) notification.
57
+ // The terminal-status gate must keep a live shell in the alive-set — only
58
+ // completed/failed/killed drop it. Built from the SAME real shell id so the
59
+ // scenario is a faithful "what if this id got an interim update" case.
60
+ const r = spyRegistry()
61
+ const interim: SessionEvent = { kind: 'task_notification', taskId: REAL_ID!, status: 'running' }
62
+ applyBackgroundShellLiveness(r, 'c:0', interim)
63
+ expect(r.calls).toEqual([])
64
+ })
65
+
66
+ it('no-op: a non-KillShell tool_use is ignored', () => {
67
+ const r = spyRegistry()
68
+ const ev: SessionEvent = { kind: 'tool_use', toolName: 'Bash', toolUseId: 't1', input: { command: 'ls' } }
69
+ applyBackgroundShellLiveness(r, 'c:0', ev)
70
+ expect(r.calls).toEqual([])
71
+ })
72
+ })
@@ -191,12 +191,18 @@ describe('silence-poke — isLegitimatelyWorking callback (default-on defer)', (
191
191
  // When isLegitimatelyWorking is wired, it is consulted; the legacy flag
192
192
  // is not consulted for the new path. Verify by having callback=false and
193
193
  // inFlightTools non-empty — the fallback fires because the callback says "no".
194
+ // NOTE (#3519): the in-flight tool here must be a NON-Bash tool. A `Bash`
195
+ // arms the CLI-side background-bash defer (a foreground Bash the callback
196
+ // is blind to once it moves to the background), which intentionally holds
197
+ // the fallback back even when the callback returns false — so using Bash
198
+ // would exercise that new defer rather than the callback-supersedes-legacy
199
+ // path this test pins. `Grep` can never be a detached background process.
194
200
  const f = setupSilenceDeps({
195
201
  thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
196
202
  isLegitimatelyWorking: () => false,
197
203
  })
198
204
  startTurn('chat:0', 0)
199
- noteToolStart('chat:0', 't1', 'Bash', 'audit', 10_000)
205
+ noteToolStart('chat:0', 't1', 'Grep', 'audit', 10_000)
200
206
  __tickForTests(300_000)
201
207
  // callback says false → no defer, fallback fires
202
208
  expect(f.fallbacks).toHaveLength(1)
@@ -0,0 +1,3 @@
1
+ {"parentUuid":"69f789b8-6f8a-46cd-baf0-58fb2114b866","isSidechain":false,"promptId":"4a2992ca-82ba-49cd-bde6-c4c9ac2e67eb","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B7T3y1t95oHDEqYKwSmqaW","type":"tool_result","content":"Command running in background with ID: bxa4sv3dq. Output is being written to: /tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output. You will be notified when it completes. To check interim output, use Read on that file path.","is_error":false}]},"uuid":"36ad560b-fe0c-4a1a-aab5-215de2ceab8a","timestamp":"2026-07-03T00:35:56.966Z","toolUseResult":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bxa4sv3dq"},"sourceToolAssistantUUID":"69f789b8-6f8a-46cd-baf0-58fb2114b866","userType":"external","entrypoint":"cli","cwd":"~/.switchroom/agents/carrie","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","version":"2.1.197","gitBranch":"HEAD"}
2
+ {"type":"queue-operation","operation":"enqueue","timestamp":"2026-07-03T00:42:51.932Z","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","content":"<task-notification>\n<task-id>bxa4sv3dq</task-id>\n<tool-use-id>toolu_01B7T3y1t95oHDEqYKwSmqaW</tool-use-id>\n<output-file>/tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output</output-file>\n<status>completed</status>\n<summary>Background command \"Search for any Buildkite product spec material on disk\" completed (exit code 0)</summary>\n</task-notification>"}
3
+ {"parentUuid":"62dd16ed-917b-4092-861e-81437bf138fb","isSidechain":false,"attachment":{"type":"queued_command","prompt":"<task-notification>\n<task-id>bxa4sv3dq</task-id>\n<tool-use-id>toolu_01B7T3y1t95oHDEqYKwSmqaW</tool-use-id>\n<output-file>/tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output</output-file>\n<status>completed</status>\n<summary>Background command \"Search for any Buildkite product spec material on disk\" completed (exit code 0)</summary>\n</task-notification>","commandMode":"task-notification","timestamp":"2026-07-03T00:42:51.932Z"},"type":"attachment","uuid":"3702ef15-3c4e-4c5d-84c1-e5c2c59b038e","timestamp":"2026-07-03T00:42:51.932Z","userType":"external","entrypoint":"cli","cwd":"/share/code/product-playbook/playbook","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","version":"2.1.197","gitBranch":"HEAD"}