switchroom 0.14.21 → 0.14.23

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 (43) hide show
  1. package/dist/agent-scheduler/index.js +0 -1
  2. package/dist/auth-broker/index.js +0 -1
  3. package/dist/cli/notion-write-pretool.mjs +0 -1
  4. package/dist/cli/switchroom.js +14 -6
  5. package/dist/host-control/main.js +0 -1
  6. package/dist/vault/approvals/kernel-server.js +0 -1
  7. package/dist/vault/broker/server.js +0 -1
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +11 -24
  10. package/profiles/_shared/telegram-style.md.hbs +2 -2
  11. package/profiles/default/CLAUDE.md.hbs +4 -1
  12. package/skills/switchroom-runtime/SKILL.md +6 -16
  13. package/telegram-plugin/agent-dir.ts +15 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +788 -513
  15. package/telegram-plugin/gateway/gateway.ts +216 -61
  16. package/telegram-plugin/gateway/inbound-spool.ts +15 -0
  17. package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
  18. package/telegram-plugin/registry/turns-schema.ts +138 -33
  19. package/telegram-plugin/stream-reply-handler.ts +1 -11
  20. package/telegram-plugin/subagent-watcher.ts +79 -5
  21. package/telegram-plugin/tests/agent-dir.test.ts +25 -0
  22. package/telegram-plugin/tests/e2e.test.ts +2 -77
  23. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  24. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  25. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  26. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  27. package/telegram-plugin/tests/races.test.ts +0 -26
  28. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  29. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  30. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  31. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  32. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  33. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  34. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  35. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +7 -3
  36. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +293 -0
  37. package/telegram-plugin/tests/subagent-watcher.test.ts +23 -15
  38. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  39. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  40. package/telegram-plugin/tool-activity-summary.ts +55 -0
  41. package/telegram-plugin/uat/driver.ts +3 -1
  42. package/telegram-plugin/handoff-continuity.ts +0 -206
  43. package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Tests for the two background-worker handback gaps closed in
3
+ * `fix/subagent-handback-restart-and-failure`:
4
+ *
5
+ * Gap 1 — restart survival. A background worker that is in-flight when
6
+ * the gateway restarts is discovered by the boot scan and tagged
7
+ * `historical`. That flag is meant to suppress replay for workers that
8
+ * ALREADY finished before boot — but it was also applied to workers
9
+ * still running, which then completed with outcome `orphan`, and the
10
+ * handback gate drops `orphan`. Net: dispatched worker + any gateway
11
+ * bounce (incl. a fleet rollout) + worker finishes = user never told.
12
+ * Fix: a file still `running` at boot is promoted to a LIVE entry, so
13
+ * it gets the stall-synthesis safety net and a real `completed`/`failed`
14
+ * handback. A file already `done` at boot stays suppressed.
15
+ *
16
+ * Gap 2 — failure honesty. The `failed` outcome was dead code (no caller
17
+ * set it), so every dead worker was reported `completed`. Fix: a
18
+ * TERMINAL error line in the worker's own transcript (model API failure
19
+ * / quota exhaustion / crash — not an in-flight retry, not a routine
20
+ * tool-level is_error) flips the terminal outcome to `failed` and
21
+ * carries the error detail into the handback result.
22
+ */
23
+
24
+ import { describe, it, expect, vi } from 'vitest'
25
+ import { startSubagentWatcher } from '../subagent-watcher.js'
26
+ import * as fs from 'fs'
27
+
28
+ function buildJSONL(...lines: object[]): string {
29
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
30
+ }
31
+ function subAgentUserMsg(promptText: string) {
32
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
33
+ }
34
+ function subAgentText(text: string) {
35
+ return { type: 'assistant', message: { content: [{ type: 'text', text }] } }
36
+ }
37
+ function subAgentTurnEnd() {
38
+ return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
39
+ }
40
+ // A terminal error line in the worker's OWN transcript — the model call
41
+ // itself failed (here an invalid_request_error). `detectErrorInTranscriptLine`
42
+ // classifies an explicit `type:"error"` line with a non-rate-limit kind as
43
+ // terminal:true.
44
+ function subAgentTerminalError(message: string) {
45
+ return { type: 'error', error: { type: 'invalid_request_error', message } }
46
+ }
47
+ // A routine mid-run tool failure (e.g. a grep that found nothing). This is a
48
+ // `sub_agent_tool_result` with is_error — NOT a worker death. Must NOT trip
49
+ // the failed classification.
50
+ function subAgentToolResultError() {
51
+ return {
52
+ type: 'user',
53
+ message: {
54
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_x', is_error: true, content: 'no matches found' }],
55
+ },
56
+ }
57
+ }
58
+
59
+ interface FinishCall {
60
+ agentId: string
61
+ outcome: string
62
+ resultText: string
63
+ }
64
+
65
+ interface Harness {
66
+ stallTerminalCalls: Array<{ agentId: string }>
67
+ finishCalls: FinishCall[]
68
+ logs: string[]
69
+ advance: (ms: number) => void
70
+ watcher: ReturnType<typeof startSubagentWatcher>
71
+ fileContents: Map<string, Buffer>
72
+ jsonlPath: string
73
+ append: (...lines: object[]) => void
74
+ }
75
+
76
+ function makeHarness(opts: {
77
+ agentId?: string
78
+ /** Lines present in the JSONL at boot (before the watcher starts). */
79
+ bootLines: object[]
80
+ stallThresholdMs?: number
81
+ silentStallTerminalMs?: number
82
+ rescanMs?: number
83
+ }): Harness {
84
+ const {
85
+ agentId = 'gap-agent',
86
+ bootLines,
87
+ stallThresholdMs = 60_000,
88
+ silentStallTerminalMs = 300_000,
89
+ rescanMs = 500,
90
+ } = opts
91
+
92
+ let currentTime = 1000
93
+ const stallTerminalCalls: Array<{ agentId: string }> = []
94
+ const finishCalls: FinishCall[] = []
95
+ const logs: string[] = []
96
+
97
+ const agentDir = '/home/user/.switchroom/agents/myagent'
98
+ const sessionId = 'mock-session'
99
+ const projectsRoot = `${agentDir}/.claude/projects`
100
+ const projectDir = `${projectsRoot}/mock-cwd`
101
+ const sessionDir = `${projectDir}/${sessionId}`
102
+ const subagentsDir = `${sessionDir}/subagents`
103
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
104
+
105
+ const fileContents = new Map<string, Buffer>()
106
+ fileContents.set(jsonlPath, Buffer.from(buildJSONL(...bootLines), 'utf-8'))
107
+
108
+ let lastOpenedPath: string | null = null
109
+ const mockFs = {
110
+ existsSync: ((p: fs.PathLike) => {
111
+ const ps = String(p)
112
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
113
+ if (fileContents.has(ps)) return true
114
+ return false
115
+ }) as typeof fs.existsSync,
116
+ readdirSync: ((p: fs.PathLike) => {
117
+ const ps = String(p)
118
+ if (ps === projectsRoot) return ['mock-cwd']
119
+ if (ps === projectDir) return [sessionId]
120
+ if (ps === sessionDir) return ['subagents']
121
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
122
+ return []
123
+ }) as unknown as typeof fs.readdirSync,
124
+ statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
125
+ openSync: ((p: fs.PathLike) => {
126
+ lastOpenedPath = String(p)
127
+ return 42
128
+ }) as unknown as typeof fs.openSync,
129
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
130
+ readSync: ((
131
+ _fd: number,
132
+ buf: NodeJS.ArrayBufferView,
133
+ offset: number,
134
+ length: number,
135
+ position: number | null,
136
+ ): number => {
137
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
138
+ if (!content) return 0
139
+ const pos = position ?? 0
140
+ const src = content.slice(pos, pos + length)
141
+ ;(src as Buffer).copy(buf as Buffer, offset)
142
+ return src.length
143
+ }) as unknown as typeof fs.readSync,
144
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
145
+ }
146
+
147
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
148
+ let nextRef = 1
149
+
150
+ const watcher = startSubagentWatcher({
151
+ agentDir,
152
+ stallThresholdMs,
153
+ silentSynthesisStallThresholdMs: stallThresholdMs,
154
+ silentStallTerminalMs,
155
+ rescanMs,
156
+ onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
157
+ onFinish: ({ agentId: id, outcome, resultText }) =>
158
+ finishCalls.push({ agentId: id, outcome, resultText }),
159
+ now: () => currentTime,
160
+ setInterval: (fn, ms) => {
161
+ const ref = nextRef++
162
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
163
+ return { ref }
164
+ },
165
+ clearInterval: (handle) => {
166
+ const { ref } = handle as { ref: number }
167
+ const idx = intervals.findIndex((i) => i.ref === ref)
168
+ if (idx !== -1) intervals.splice(idx, 1)
169
+ },
170
+ fs: mockFs,
171
+ log: (msg) => logs.push(msg),
172
+ })
173
+
174
+ const advance = (ms: number): void => {
175
+ currentTime += ms
176
+ for (;;) {
177
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
178
+ const next = intervals[0]
179
+ if (!next || next.fireAt > currentTime) break
180
+ next.fireAt += next.ms
181
+ next.fn()
182
+ }
183
+ }
184
+
185
+ const append = (...lines: object[]): void => {
186
+ const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
187
+ const more = buildJSONL(...lines)
188
+ fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(more, 'utf-8')]))
189
+ }
190
+
191
+ return { stallTerminalCalls, finishCalls, logs, advance, watcher, fileContents, jsonlPath, append }
192
+ }
193
+
194
+ describe('Gap 1 — background worker in-flight across a gateway restart', () => {
195
+ it('an in-flight-at-boot worker that completes hands back as completed (not orphan)', () => {
196
+ // Boot scan finds a running worker (prompt, no turn_end yet) → tagged
197
+ // historical. The fix promotes it to live. When it finishes under our
198
+ // watch, the outcome must be `completed` so the handback delivers.
199
+ const h = makeHarness({ agentId: 'gap1-complete', bootLines: [subAgentUserMsg('bg task')] })
200
+
201
+ // The worker finishes after the restart.
202
+ h.append(subAgentText('Found the root cause in auth.ts'), subAgentTurnEnd())
203
+ h.advance(600) // one poll reads the new bytes
204
+
205
+ expect(h.finishCalls).toHaveLength(1)
206
+ expect(h.finishCalls[0].agentId).toBe('gap1-complete')
207
+ expect(h.finishCalls[0].outcome).toBe('completed') // pre-fix: 'orphan' → dropped
208
+ expect(h.finishCalls[0].resultText).toContain('root cause')
209
+ // The promotion is logged so the path is observable in prod.
210
+ expect(h.logs.some((l) => l.includes('in-flight at boot — promoting to live'))).toBe(true)
211
+ })
212
+
213
+ it('an in-flight-at-boot worker that dies silently is rescued by stall synthesis', () => {
214
+ // Pre-fix, historical entries were skipped by stall detection, so a
215
+ // worker that crossed a restart and then went silent sat running
216
+ // forever — no handback ever. After promotion it gets the safety net.
217
+ const h = makeHarness({
218
+ agentId: 'gap1-silent',
219
+ bootLines: [subAgentUserMsg('bg task')],
220
+ stallThresholdMs: 60_000,
221
+ silentStallTerminalMs: 120_000,
222
+ })
223
+
224
+ h.advance(62_000) // stall threshold crossed
225
+ expect(h.stallTerminalCalls).toHaveLength(0)
226
+ h.advance(121_000) // silent-stall terminal window elapses → synthesis
227
+ expect(h.stallTerminalCalls).toHaveLength(1)
228
+ expect(h.finishCalls).toHaveLength(1)
229
+ expect(h.finishCalls[0].outcome).toBe('completed')
230
+ })
231
+
232
+ it('a worker already DONE at boot stays suppressed (no spurious replay)', () => {
233
+ // The legitimate use of `historical`: a worker that finished in a prior
234
+ // session must NOT re-fire a handback on every restart. This is the
235
+ // regression guard for the fix.
236
+ const h = makeHarness({
237
+ agentId: 'gap1-stale',
238
+ bootLines: [subAgentUserMsg('bg task'), subAgentText('done long ago'), subAgentTurnEnd()],
239
+ })
240
+
241
+ h.advance(600)
242
+ h.advance(600_000) // well past any stall window
243
+ expect(h.finishCalls).toHaveLength(0)
244
+ expect(h.stallTerminalCalls).toHaveLength(0)
245
+ })
246
+ })
247
+
248
+ describe('Gap 2 — failure honesty', () => {
249
+ it('a terminal error line flips the outcome to failed and carries the detail', () => {
250
+ const h = makeHarness({ agentId: 'gap2-failed', bootLines: [subAgentUserMsg('bg task')] })
251
+
252
+ // The worker's model call errors out, then the transcript ends.
253
+ h.append(subAgentTerminalError('tool input rejected by the API'), subAgentTurnEnd())
254
+ h.advance(600)
255
+
256
+ expect(h.finishCalls).toHaveLength(1)
257
+ expect(h.finishCalls[0].outcome).toBe('failed')
258
+ // No narrative was emitted, so the detail backfills the result slot.
259
+ expect(h.finishCalls[0].resultText).toContain('tool input rejected')
260
+ })
261
+
262
+ it('a failed worker that went silent still synthesises terminal as failed', () => {
263
+ const h = makeHarness({
264
+ agentId: 'gap2-failed-silent',
265
+ bootLines: [subAgentUserMsg('bg task')],
266
+ stallThresholdMs: 60_000,
267
+ silentStallTerminalMs: 120_000,
268
+ })
269
+
270
+ // Error line, then the worker goes silent (no turn_end).
271
+ h.append(subAgentTerminalError('worker process crashed'))
272
+ h.advance(600) // read the error line
273
+ h.advance(62_000) // stall
274
+ h.advance(121_000) // synthesis
275
+ expect(h.stallTerminalCalls).toHaveLength(1)
276
+ expect(h.finishCalls).toHaveLength(1)
277
+ expect(h.finishCalls[0].outcome).toBe('failed')
278
+ expect(h.finishCalls[0].resultText).toContain('crashed')
279
+ })
280
+
281
+ it('a routine mid-run tool error does NOT cause a false failure', () => {
282
+ const h = makeHarness({ agentId: 'gap2-toolerr', bootLines: [subAgentUserMsg('bg task')] })
283
+
284
+ // A tool_result with is_error (e.g. grep found nothing) mid-run, then
285
+ // the worker recovers and completes normally.
286
+ h.append(subAgentToolResultError(), subAgentText('Completed after a retry'), subAgentTurnEnd())
287
+ h.advance(600)
288
+
289
+ expect(h.finishCalls).toHaveLength(1)
290
+ expect(h.finishCalls[0].outcome).toBe('completed') // NOT failed
291
+ expect(h.finishCalls[0].resultText).toContain('Completed after a retry')
292
+ })
293
+ })
@@ -693,18 +693,21 @@ describe('startSubagentWatcher', () => {
693
693
  h.watcher.stop()
694
694
  })
695
695
 
696
- it('suppresses stall notifications for historical entries', () => {
697
- // Historical entries (file existed at watcher boot) must NOT fire
698
- // stall notifications. The sub-agent process is long dead; the file
699
- // is just left over from a prior session. With many historicals
700
- // present at restart, firing stalls for each would flood the chat.
696
+ it('suppresses stall notifications for historical (done-at-boot) entries', () => {
697
+ // A worker that already FINISHED before the watcher booted (turn_end
698
+ // present in the file) stays historical and must NOT fire stall
699
+ // notifications. With months of finished session history present at
700
+ // restart, firing stalls for each would flood the chat. NOTE: a worker
701
+ // still RUNNING at boot is a different case — Gap 1 promotes it to live
702
+ // so it DOES get the stall safety net (it's an in-flight worker the
703
+ // user is still awaiting), covered in subagent-watcher-handback-gaps.
701
704
  const agentDir = '/home/user/.switchroom/agents/myagent'
702
705
  const projectsRoot = `${agentDir}/.claude/projects`
703
706
  const projectDir = `${projectsRoot}/myproject`
704
707
  const sessionDir = `${projectDir}/session-abc123`
705
708
  const subagentsDir = `${sessionDir}/subagents`
706
709
  const jsonlPath = `${subagentsDir}/agent-deadbeef.jsonl`
707
- const content = buildJSONL(subAgentUserMsg('Old task'))
710
+ const content = buildJSONL(subAgentUserMsg('Old task'), subAgentTurnDuration())
708
711
 
709
712
  const h = makeHarness({
710
713
  agentDir,
@@ -809,12 +812,15 @@ describe('startSubagentWatcher', () => {
809
812
 
810
813
  describe('historical-vs-active filter', () => {
811
814
  /**
812
- * Pre-existing JSONL files at watcher boot are tagged historical=true.
813
- * Stalls and completion notifications are gated on !historical so a
814
- * restart with months of session history doesn't flood the chat.
815
+ * Pre-existing FINISHED (done-at-boot) JSONL files are tagged
816
+ * historical=true. Stalls and completion notifications are gated on
817
+ * !historical so a restart with months of session history doesn't
818
+ * flood the chat. (A still-RUNNING file at boot is promoted to live by
819
+ * Gap 1 — see subagent-watcher-handback-gaps — so it must carry a
820
+ * turn_end here to stay historical.)
815
821
  */
816
822
 
817
- it('pre-existing JSONL files at startup are tagged historical', () => {
823
+ it('pre-existing done-at-boot JSONL files are tagged historical', () => {
818
824
  const agentDir = '/home/user/.switchroom/agents/myagent'
819
825
  const projectsRoot = `${agentDir}/.claude/projects`
820
826
  const projectDir = `${projectsRoot}/myproject`
@@ -823,7 +829,7 @@ describe('startSubagentWatcher', () => {
823
829
  const jsonlA = `${subagentsDir}/agent-hist-aaaa.jsonl`
824
830
  const jsonlB = `${subagentsDir}/agent-hist-bbbb.jsonl`
825
831
 
826
- const content = buildJSONL(subAgentUserMsg('Old task'))
832
+ const content = buildJSONL(subAgentUserMsg('Old task'), subAgentTurnDuration())
827
833
 
828
834
  const h = makeHarness({
829
835
  agentDir,
@@ -895,10 +901,12 @@ describe('startSubagentWatcher', () => {
895
901
  })
896
902
 
897
903
  it('pre-existing in-flight agent that finishes after restart fires completion', () => {
898
- // Historical at boot. Then writes turn_end. Completion notification
899
- // still fires for the state transition (the file was in-flight at
900
- // boot, so the transition is meaningful even if the entry is tagged
901
- // historical for stall-suppression purposes).
904
+ // Running at boot Gap 1 promotes it to live (historical=false),
905
+ // because it's an in-flight worker the user is still awaiting across
906
+ // the restart. When it then writes turn_end, the completion
907
+ // notification fires for the state transition. (The deeper handback
908
+ // outcome — completed, not the dropped `orphan` — is covered in
909
+ // subagent-watcher-handback-gaps.)
902
910
  const agentDir = '/home/user/.switchroom/agents/myagent'
903
911
  const projectsRoot = `${agentDir}/.claude/projects`
904
912
  const projectDir = `${projectsRoot}/myproject`
@@ -4,7 +4,9 @@ import {
4
4
  appendActivityLine,
5
5
  appendActivityLabel,
6
6
  renderActivityFeed,
7
+ renderActivityFeedWithNested,
7
8
  MIRROR_MAX_LINES,
9
+ NESTED_MAX_LINES,
8
10
  } from "../tool-activity-summary.js";
9
11
 
10
12
  describe("describeToolUse — friendly per-tool rendering (draft-mirror)", () => {
@@ -143,3 +145,45 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
143
145
  expect(lines.length).toBe(2);
144
146
  });
145
147
  });
148
+
149
+ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A)", () => {
150
+ it("with no child lines, is identical to the flat feed", () => {
151
+ const lines = ["Searching memory", "Delegating: review the migration"];
152
+ expect(renderActivityFeedWithNested(lines, [])).toBe(renderActivityFeed(lines));
153
+ // whitespace-only children also collapse to the flat feed
154
+ expect(renderActivityFeedWithNested(lines, [" ", ""])).toBe(renderActivityFeed(lines));
155
+ });
156
+
157
+ it("done-styles ALL parent lines and nests the child block (newest = bold →)", () => {
158
+ const parent = ["Searching memory", "Delegating: review the migration"];
159
+ const child = ["Reading schema.ts", "Looking for foreign keys"];
160
+ const out = renderActivityFeedWithNested(parent, child)!;
161
+ // Parent is blocked at the Task tool → none of its lines is the live step.
162
+ expect(out).toContain("<i>✓ Searching memory</i>");
163
+ expect(out).toContain("<i>✓ Delegating: review the migration</i>");
164
+ expect(out).not.toContain("<b>→ Delegating");
165
+ // The live → step is the newest nested child line; earlier child = italic.
166
+ expect(out).toContain(" ↳ <i>Reading schema.ts</i>");
167
+ expect(out).toContain(" ↳ <b>→ Looking for foreign keys</b>");
168
+ });
169
+
170
+ it("caps the nested block to NESTED_MAX_LINES with a '↳ +N earlier…' header", () => {
171
+ const child = Array.from({ length: NESTED_MAX_LINES + 3 }, (_, i) => `step ${i + 1}`);
172
+ const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
173
+ expect(out).toContain(" ↳ <i>+3 earlier…</i>");
174
+ // newest nested line is the live → step
175
+ expect(out).toContain(` ↳ <b>→ step ${NESTED_MAX_LINES + 3}</b>`);
176
+ // the oldest (collapsed) lines are not rendered verbatim
177
+ expect(out).not.toContain("step 1<");
178
+ });
179
+
180
+ it("renders the child block even when the parent feed is empty", () => {
181
+ const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
182
+ expect(out).toBe(" ↳ <b>→ Reading a.ts</b>");
183
+ });
184
+
185
+ it("HTML-escapes nested child text", () => {
186
+ const out = renderActivityFeedWithNested(["Delegating: x"], ["touch <a> & <b>"])!;
187
+ expect(out).toContain(" ↳ <b>→ touch &lt;a&gt; &amp; &lt;b&gt;</b>");
188
+ });
189
+ });
@@ -5,7 +5,7 @@
5
5
  * 1. Clean turn: insert + finalize → row has ended_via='stop', non-null
6
6
  * ended_at, correct previews.
7
7
  * 2. Mid-turn restart: insert without finalize, simulate gateway boot via
8
- * markOrphanedAsRestarted → row has ended_via='restart'.
8
+ * markOrphanedWithTimeoutClassification → row has ended_via='restart'.
9
9
  * 3. Multiple concurrent turns same chat: each row has a unique turn_key,
10
10
  * no cross-contamination.
11
11
  * 4. tool_call_count increments correctly for N tool_use events.
@@ -20,9 +20,19 @@ import {
20
20
  openTurnsDbInMemory,
21
21
  recordTurnStart,
22
22
  recordTurnEnd,
23
- markOrphanedAsRestarted,
23
+ markOrphanedWithTimeoutClassification,
24
24
  } from '../registry/turns-schema.js'
25
25
 
26
+ // The boot reaper as the gateway calls it between turns (no live hang
27
+ // marker) — every open turn is a clean 'restart' interrupt.
28
+ function reapAsRestart(db: Parameters<typeof recordTurnEnd>[0]) {
29
+ return markOrphanedWithTimeoutClassification(db, {
30
+ markerTurnKey: null,
31
+ markerAgeMs: null,
32
+ hangThresholdMs: 300_000,
33
+ })
34
+ }
35
+
26
36
  // ---------------------------------------------------------------------------
27
37
  // 1. Clean turn
28
38
  // ---------------------------------------------------------------------------
@@ -110,7 +120,7 @@ describe('clean turn (Phase 1 #332)', () => {
110
120
  // ---------------------------------------------------------------------------
111
121
 
112
122
  describe('mid-turn restart (Phase 1 #332)', () => {
113
- it('insert without finalize, then markOrphanedAsRestarted → ended_via=restart', () => {
123
+ it('insert without finalize, then reaper → ended_via=restart', () => {
114
124
  const db = openTurnsDbInMemory()
115
125
 
116
126
  recordTurnStart(db, {
@@ -120,8 +130,8 @@ describe('mid-turn restart (Phase 1 #332)', () => {
120
130
  })
121
131
 
122
132
  // Simulate gateway boot reaper (same path as the real gateway boot).
123
- const swept = markOrphanedAsRestarted(db)
124
- expect(swept).toBe(1)
133
+ const swept = reapAsRestart(db)
134
+ expect(swept.reaped).toBe(1)
125
135
 
126
136
  const row = db
127
137
  .prepare('SELECT ended_via, ended_at FROM turns WHERE turn_key = ?')
@@ -141,7 +151,7 @@ describe('mid-turn restart (Phase 1 #332)', () => {
141
151
  recordTurnEnd(db, { turnKey: 'chat2:_:2001', endedVia: 'stop' })
142
152
  recordTurnStart(db, { turnKey: 'chat2:_:2002', chatId: 'chat2' })
143
153
 
144
- markOrphanedAsRestarted(db)
154
+ reapAsRestart(db)
145
155
 
146
156
  const clean = db
147
157
  .prepare('SELECT ended_via FROM turns WHERE turn_key = ?')
@@ -216,6 +216,61 @@ export function renderActivityFeed(lines: string[]): string | null {
216
216
  return out.join("\n");
217
217
  }
218
218
 
219
+ // ─── Foreground sub-agent nesting (Model A) ─────────────────────────────────
220
+ //
221
+ // A foreground sub-agent (Task/Agent with no `run_in_background`) runs INSIDE
222
+ // the parent's turn — the parent is blocked at the Task tool until it returns.
223
+ // Rather than a separate message, its live steps nest under the parent's own
224
+ // activity feed: the gold-standard main-turn visibility applied one level
225
+ // down. The parent's lines render as done (the parent handed off; it isn't
226
+ // the active worker), and the sub-agent's recent narrative lines render as an
227
+ // indented `↳` block with the newest as the in-progress `→` step.
228
+
229
+ /** Trailing nested child lines kept visible (Telegram length + readability). */
230
+ export const NESTED_MAX_LINES = 4;
231
+ /** Hard cap on a single nested narrative line. */
232
+ const NESTED_LINE_MAX = 90;
233
+ /** Indent marker for a nested sub-agent step. */
234
+ const NESTED_PREFIX = " ↳ ";
235
+
236
+ /**
237
+ * Render the parent activity feed with an active foreground sub-agent's steps
238
+ * nested beneath it. When `childLines` is empty this is identical to
239
+ * `renderActivityFeed(lines)`. Otherwise the parent's own lines are all
240
+ * done-styled (`✓` italic) — the live `→` step lives in the nested block —
241
+ * and the child block is indented, newest = bold `→`, earlier = italic, with
242
+ * a `↳ +N earlier…` header when it overflows. Returns ready Telegram HTML
243
+ * (callers must NOT re-escape) or null when there is nothing to show.
244
+ */
245
+ export function renderActivityFeedWithNested(
246
+ lines: string[],
247
+ childLines: string[],
248
+ ): string | null {
249
+ const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
250
+ if (children.length === 0) return renderActivityFeed(lines);
251
+
252
+ const out: string[] = [];
253
+ const shownParent = lines.slice(-MIRROR_MAX_LINES);
254
+ const hiddenParent = lines.length - shownParent.length;
255
+ if (hiddenParent > 0) out.push(`<i>✓ +${hiddenParent} earlier…</i>`);
256
+ for (const l of shownParent) out.push(`<i>✓ ${escapeFeedHtml(l)}</i>`);
257
+
258
+ const shownChild = children.slice(-NESTED_MAX_LINES);
259
+ const hiddenChild = children.length - shownChild.length;
260
+ if (hiddenChild > 0) out.push(`${NESTED_PREFIX}<i>+${hiddenChild} earlier…</i>`);
261
+ const lastChildIdx = shownChild.length - 1;
262
+ shownChild.forEach((l, i) => {
263
+ const t = l.length > NESTED_LINE_MAX ? l.slice(0, NESTED_LINE_MAX - 1) + "…" : l;
264
+ const esc = escapeFeedHtml(t);
265
+ out.push(
266
+ i === lastChildIdx
267
+ ? `${NESTED_PREFIX}<b>→ ${esc}</b>`
268
+ : `${NESTED_PREFIX}<i>${esc}</i>`,
269
+ );
270
+ });
271
+ return out.length > 0 ? out.join("\n") : null;
272
+ }
273
+
219
274
  /**
220
275
  * Like appendActivityLine, but for a pre-computed label (from the
221
276
  * real-time PreToolUse sidecar / `tool_label` event) — the hook already
@@ -663,8 +663,10 @@ export class Driver {
663
663
  ): Promise<{ messageIds: number[] }> {
664
664
  const c = this.requireClient();
665
665
  const replyTo = opts?.replyTo ?? opts?.messageThreadId;
666
+ // mtcute reads a bare string as a file_id/URL; the `file:` scheme is
667
+ // what forces an upload from local disk (see normalize-input-media).
666
668
  const medias = photoPaths.map((p, i) =>
667
- InputMedia.photo(p, i === 0 && caption ? { caption } : undefined),
669
+ InputMedia.photo(`file:${p}`, i === 0 && caption ? { caption } : undefined),
668
670
  );
669
671
  const sent = await c.sendMediaGroup(
670
672
  chatId,