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.
- package/dist/agent-scheduler/index.js +0 -1
- package/dist/auth-broker/index.js +0 -1
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +14 -6
- package/dist/host-control/main.js +0 -1
- package/dist/vault/approvals/kernel-server.js +0 -1
- package/dist/vault/broker/server.js +0 -1
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +11 -24
- package/profiles/_shared/telegram-style.md.hbs +2 -2
- package/profiles/default/CLAUDE.md.hbs +4 -1
- package/skills/switchroom-runtime/SKILL.md +6 -16
- package/telegram-plugin/agent-dir.ts +15 -0
- package/telegram-plugin/dist/gateway/gateway.js +788 -513
- package/telegram-plugin/gateway/gateway.ts +216 -61
- package/telegram-plugin/gateway/inbound-spool.ts +15 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
- package/telegram-plugin/registry/turns-schema.ts +138 -33
- package/telegram-plugin/stream-reply-handler.ts +1 -11
- package/telegram-plugin/subagent-watcher.ts +79 -5
- package/telegram-plugin/tests/agent-dir.test.ts +25 -0
- package/telegram-plugin/tests/e2e.test.ts +2 -77
- package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/races.test.ts +0 -26
- package/telegram-plugin/tests/registry-turns.test.ts +106 -29
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +7 -3
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +293 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +23 -15
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tests/turns-writer.test.ts +16 -6
- package/telegram-plugin/tool-activity-summary.ts +55 -0
- package/telegram-plugin/uat/driver.ts +3 -1
- package/telegram-plugin/handoff-continuity.ts +0 -206
- 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
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
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
|
|
813
|
-
* Stalls and completion notifications are gated on
|
|
814
|
-
* restart with months of session history doesn't
|
|
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
|
|
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
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
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 <a> & <b></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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
669
|
+
InputMedia.photo(`file:${p}`, i === 0 && caption ? { caption } : undefined),
|
|
668
670
|
);
|
|
669
671
|
const sent = await c.sendMediaGroup(
|
|
670
672
|
chatId,
|