switchroom 0.14.36 → 0.14.37
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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +18 -4
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +88 -3
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +11 -1
- package/telegram-plugin/registry/subagents-bugs.test.ts +12 -4
- package/telegram-plugin/subagent-watcher.ts +45 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +73 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +155 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49440,8 +49440,8 @@ var {
|
|
|
49440
49440
|
} = import__.default;
|
|
49441
49441
|
|
|
49442
49442
|
// src/build-info.ts
|
|
49443
|
-
var VERSION = "0.14.
|
|
49444
|
-
var COMMIT_SHA = "
|
|
49443
|
+
var VERSION = "0.14.37";
|
|
49444
|
+
var COMMIT_SHA = "90d0c420";
|
|
49445
49445
|
|
|
49446
49446
|
// src/cli/agent.ts
|
|
49447
49447
|
init_source();
|
package/package.json
CHANGED
|
@@ -49571,6 +49571,20 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
|
|
|
49571
49571
|
}
|
|
49572
49572
|
db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
|
|
49573
49573
|
log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
|
|
49574
|
+
try {
|
|
49575
|
+
const linkedRow = db2.prepare("SELECT started_at, parent_turn_key FROM subagents WHERE id = ?").get(candidate.id);
|
|
49576
|
+
if (linkedRow != null && linkedRow.parent_turn_key == null) {
|
|
49577
|
+
const turn = db2.prepare(`SELECT turn_key FROM turns
|
|
49578
|
+
WHERE started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)
|
|
49579
|
+
ORDER BY started_at DESC LIMIT 1`).get(linkedRow.started_at, linkedRow.started_at);
|
|
49580
|
+
if (turn?.turn_key != null) {
|
|
49581
|
+
db2.prepare("UPDATE subagents SET parent_turn_key = ? WHERE id = ?").run(turn.turn_key, candidate.id);
|
|
49582
|
+
log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} \u2192 ${turn.turn_key}`);
|
|
49583
|
+
}
|
|
49584
|
+
}
|
|
49585
|
+
} catch (err) {
|
|
49586
|
+
log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} \u2014 ${err.message}`);
|
|
49587
|
+
}
|
|
49574
49588
|
}
|
|
49575
49589
|
function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished, onProgress) {
|
|
49576
49590
|
try {
|
|
@@ -51782,10 +51796,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51782
51796
|
}
|
|
51783
51797
|
|
|
51784
51798
|
// ../src/build-info.ts
|
|
51785
|
-
var VERSION = "0.14.
|
|
51786
|
-
var COMMIT_SHA = "
|
|
51787
|
-
var COMMIT_DATE = "2026-06-
|
|
51788
|
-
var LATEST_PR =
|
|
51799
|
+
var VERSION = "0.14.37";
|
|
51800
|
+
var COMMIT_SHA = "90d0c420";
|
|
51801
|
+
var COMMIT_DATE = "2026-06-02T02:10:03Z";
|
|
51802
|
+
var LATEST_PR = 2078;
|
|
51789
51803
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51790
51804
|
|
|
51791
51805
|
// gateway/boot-version.ts
|
|
@@ -156,6 +156,53 @@ function extractResultSummary(toolResponse) {
|
|
|
156
156
|
return str.slice(0, 200) || null
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Extract the full text of a PostToolUse tool_response (untruncated).
|
|
161
|
+
* Mirrors extractResultSummary's shape handling but returns the whole
|
|
162
|
+
* string so callers can pattern-match on it.
|
|
163
|
+
*/
|
|
164
|
+
function toolResponseText(toolResponse) {
|
|
165
|
+
if (!toolResponse) return ''
|
|
166
|
+
if (Array.isArray(toolResponse.content)) {
|
|
167
|
+
return toolResponse.content
|
|
168
|
+
.filter((c) => c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string')
|
|
169
|
+
.map((c) => c.text)
|
|
170
|
+
.join('\n')
|
|
171
|
+
}
|
|
172
|
+
if (typeof toolResponse.result === 'string') return toolResponse.result
|
|
173
|
+
if (typeof toolResponse.output === 'string') return toolResponse.output
|
|
174
|
+
if (typeof toolResponse === 'string') return toolResponse
|
|
175
|
+
return ''
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Detect Claude Code's async-launch ACK in a PostToolUse tool_response.
|
|
180
|
+
*
|
|
181
|
+
* A `run_in_background` Agent/Task returns IMMEDIATELY with an
|
|
182
|
+
* acknowledgement ("Async agent launched successfully … The agent is working
|
|
183
|
+
* in the background …"), NOT the sub-agent's final result. This ACK is the
|
|
184
|
+
* authoritative, uniform signal that the dispatch was a background one — it is
|
|
185
|
+
* present even when Claude Code omits `run_in_background` from the tool_input
|
|
186
|
+
* the PREtool hook sees (observed on claude-code 2.1.159: a worker whose
|
|
187
|
+
* tool_input lacked the flag still returned this ACK and ran ~3 min past the
|
|
188
|
+
* parent turn, so the pretool recorded background=0 and the worker card never
|
|
189
|
+
* fired). We therefore trust this ACK over the pretool's input-derived flag.
|
|
190
|
+
*
|
|
191
|
+
* Anchored on the specific "async agent launched" phrase (a foreground
|
|
192
|
+
* sub-agent's final report is extremely unlikely to contain it), with a
|
|
193
|
+
* structural backstop ("working in the background" + an agentId token) in case
|
|
194
|
+
* the launch-verb wording drifts. A major wording change degrades to the
|
|
195
|
+
* pretool flag — still correct whenever the model DID pass run_in_background,
|
|
196
|
+
* never worse than before.
|
|
197
|
+
*/
|
|
198
|
+
function isAsyncLaunchAck(toolResponse) {
|
|
199
|
+
const t = toolResponseText(toolResponse).toLowerCase()
|
|
200
|
+
if (!t) return false
|
|
201
|
+
if (t.includes('async agent launched')) return true
|
|
202
|
+
if (t.includes('working in the background') && t.includes('agentid')) return true
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
159
206
|
// ---------------------------------------------------------------------------
|
|
160
207
|
// DB write
|
|
161
208
|
// ---------------------------------------------------------------------------
|
|
@@ -172,9 +219,18 @@ function extractResultSummary(toolResponse) {
|
|
|
172
219
|
* recordSubagentEnd (driven by the JSONL turn_end event) remains the
|
|
173
220
|
* authoritative end-of-life signal.
|
|
174
221
|
*
|
|
222
|
+
* Mis-recorded background (DB background = 0 but `asyncLaunch` is true):
|
|
223
|
+
* Claude Code returned the async-launch ACK even though run_in_background was
|
|
224
|
+
* absent from the tool_input the pretool saw, so the row was wrongly recorded
|
|
225
|
+
* foreground. PROMOTE it to background = 1 and take the background path — do
|
|
226
|
+
* NOT terminalize, because the worker is still running (the ACK is a launch,
|
|
227
|
+
* not a completion). This is the authoritative correction that makes the
|
|
228
|
+
* gateway's worker-feed card fire (onProgress re-reads `background` per tick)
|
|
229
|
+
* AND prevents the premature `completed` the foreground path would write.
|
|
230
|
+
*
|
|
175
231
|
* The done(err | null) callback is invoked after all DB operations complete.
|
|
176
232
|
*/
|
|
177
|
-
function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
233
|
+
function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done) {
|
|
178
234
|
// SQL to read the background flag so we can choose the right update path.
|
|
179
235
|
const SELECT_SQL = `SELECT background FROM subagents WHERE id = ?`
|
|
180
236
|
|
|
@@ -194,12 +250,22 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
|
194
250
|
AND status NOT IN ('completed', 'failed')
|
|
195
251
|
`
|
|
196
252
|
|
|
253
|
+
// Promote a mis-recorded foreground row to background (sets background = 1),
|
|
254
|
+
// bumping activity but NOT terminalizing — same shape as BACKGROUND_SQL.
|
|
255
|
+
const PROMOTE_BACKGROUND_SQL = `
|
|
256
|
+
UPDATE subagents
|
|
257
|
+
SET background = 1, result_summary = COALESCE(?, result_summary), last_activity_at = ?
|
|
258
|
+
WHERE id = ?
|
|
259
|
+
AND status NOT IN ('completed', 'failed')
|
|
260
|
+
`
|
|
261
|
+
|
|
197
262
|
// Snapshot all values used inside closures before setImmediate fires.
|
|
198
263
|
const snapDbPath = dbPath
|
|
199
264
|
const snapId = id
|
|
200
265
|
const snapStatus = status
|
|
201
266
|
const snapResultSummary = resultSummary
|
|
202
267
|
const snapNow = now
|
|
268
|
+
const snapAsyncLaunch = asyncLaunch === true
|
|
203
269
|
|
|
204
270
|
// Resolve a synchronous SQLite binding (node:sqlite under Node 22+,
|
|
205
271
|
// bun:sqlite under bun, else null → CLI fallback). See helper docs.
|
|
@@ -216,6 +282,8 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
|
216
282
|
const isBackground = row != null && row.background === 1
|
|
217
283
|
if (isBackground) {
|
|
218
284
|
db.prepare(BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
|
|
285
|
+
} else if (snapAsyncLaunch) {
|
|
286
|
+
db.prepare(PROMOTE_BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
|
|
219
287
|
} else {
|
|
220
288
|
db.prepare(FOREGROUND_SQL).run(snapNow, snapStatus, snapResultSummary, snapNow, snapId)
|
|
221
289
|
}
|
|
@@ -239,6 +307,12 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
|
|
|
239
307
|
fillPlaceholders(BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
|
|
240
308
|
done,
|
|
241
309
|
)
|
|
310
|
+
} else if (snapAsyncLaunch) {
|
|
311
|
+
spawnSql(
|
|
312
|
+
snapDbPath,
|
|
313
|
+
fillPlaceholders(PROMOTE_BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
|
|
314
|
+
done,
|
|
315
|
+
)
|
|
242
316
|
} else {
|
|
243
317
|
spawnSql(
|
|
244
318
|
snapDbPath,
|
|
@@ -345,16 +419,26 @@ function main() {
|
|
|
345
419
|
|
|
346
420
|
const toolResponse = event.tool_response ?? null
|
|
347
421
|
|
|
422
|
+
// Authoritative background signal: Claude Code's async-launch ACK. Trusted
|
|
423
|
+
// over the pretool's input-derived flag (which is missing whenever the
|
|
424
|
+
// model/runtime omits run_in_background from tool_input — see
|
|
425
|
+
// isAsyncLaunchAck). Gates both the nudge below and the promote path in
|
|
426
|
+
// updateRow.
|
|
427
|
+
const asyncLaunch = isAsyncLaunchAck(toolResponse)
|
|
428
|
+
|
|
348
429
|
// conversational-pacing beat 4 (foreground half). A foreground
|
|
349
430
|
// sub-agent's PostToolUse fires at real completion, mid-parent-turn,
|
|
350
431
|
// with its result in tool_response — nudge the parent to synthesise a
|
|
351
432
|
// user-facing handback. Background sub-agents are gated OUT: their
|
|
352
433
|
// PostToolUse fires on the launch ACK (BACKGROUND_SQL leaves status
|
|
353
434
|
// untouched for that reason), and their handback is driven by the
|
|
354
|
-
// gateway's subagent-watcher onFinish path instead.
|
|
355
|
-
//
|
|
435
|
+
// gateway's subagent-watcher onFinish path instead. A launch ACK is also
|
|
436
|
+
// gated out via `!asyncLaunch` — at this point the DB flag may still read 0
|
|
437
|
+
// (updateRow promotes it on the next tick), so the ACK is the reliable
|
|
438
|
+
// tell. Fail-silent: an unknown background flag (null) skips the nudge.
|
|
356
439
|
if (
|
|
357
440
|
process.env.SWITCHROOM_SUBAGENT_HANDBACK !== '0'
|
|
441
|
+
&& !asyncLaunch
|
|
358
442
|
&& detectStatus(toolResponse) === 'completed'
|
|
359
443
|
&& readBackgroundFlagSync(dbPath, id) === 0
|
|
360
444
|
) {
|
|
@@ -368,6 +452,7 @@ function main() {
|
|
|
368
452
|
status: detectStatus(toolResponse),
|
|
369
453
|
resultSummary: extractResultSummary(toolResponse),
|
|
370
454
|
now: Date.now(),
|
|
455
|
+
asyncLaunch,
|
|
371
456
|
},
|
|
372
457
|
(err) => {
|
|
373
458
|
if (err) {
|
|
@@ -262,7 +262,17 @@ function main() {
|
|
|
262
262
|
{
|
|
263
263
|
id: event.tool_use_id ?? null,
|
|
264
264
|
parentSessionId: event.session_id ?? null,
|
|
265
|
-
|
|
265
|
+
// parent_turn_key is intentionally NULL here. Claude Code's PreToolUse
|
|
266
|
+
// payload carries its own session id, not the gateway-minted Telegram
|
|
267
|
+
// turn_key (a chat+topic+turn key) the `turns` table is keyed on —
|
|
268
|
+
// `event.turn_id` is always undefined, and even if a future CLI
|
|
269
|
+
// populated it, it would not match a `turns.turn_key`. The gateway
|
|
270
|
+
// resolves parent_turn_key from the
|
|
271
|
+
// sub-agent's started_at at jsonl-link time (subagent-watcher.ts
|
|
272
|
+
// backfillJsonlAgentId), which works even after the parent turn ends.
|
|
273
|
+
// Writing a bogus value here would defeat that backfill's
|
|
274
|
+
// `parent_turn_key IS NULL` guard.
|
|
275
|
+
parentTurnKey: null,
|
|
266
276
|
agentType: input.subagent_type ?? null,
|
|
267
277
|
description: input.description ?? null,
|
|
268
278
|
background: input.run_in_background === true ? 1 : 0,
|
|
@@ -387,8 +387,16 @@ describe('Bug 4 — result_summary always NULL (hook integration)', () => {
|
|
|
387
387
|
|
|
388
388
|
// ─── Bug 5 — parent_turn_key always NULL ─────────────────────────────────────
|
|
389
389
|
|
|
390
|
-
describe('Bug 5 — parent_turn_key
|
|
391
|
-
it('pretool
|
|
390
|
+
describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () => {
|
|
391
|
+
it('pretool writes parent_turn_key=NULL even when event.turn_id is present', () => {
|
|
392
|
+
// Claude Code's PreToolUse payload carries its own session id, never the
|
|
393
|
+
// gateway-minted Telegram turn_key (a chat+topic+turn key) the `turns`
|
|
394
|
+
// table is keyed on. `event.turn_id` — even if a future CLI populated it —
|
|
395
|
+
// would not match a `turns.turn_key`, so the hook intentionally writes
|
|
396
|
+
// NULL and lets the gateway backfill parent_turn_key from the sub-agent's
|
|
397
|
+
// started_at at jsonl-link time (subagent-watcher.ts backfillJsonlAgentId).
|
|
398
|
+
// Writing a bogus value here would defeat that backfill's
|
|
399
|
+
// `parent_turn_key IS NULL` guard.
|
|
392
400
|
const event = {
|
|
393
401
|
session_id: 'sess-turnkey',
|
|
394
402
|
turn_id: 'turn-abc-001',
|
|
@@ -406,8 +414,8 @@ describe('Bug 5 — parent_turn_key always NULL (hook integration)', () => {
|
|
|
406
414
|
| undefined
|
|
407
415
|
|
|
408
416
|
expect(row).toBeDefined()
|
|
409
|
-
//
|
|
410
|
-
expect(row!.parent_turn_key).
|
|
417
|
+
// The hook never trusts event.turn_id — gateway backfill owns this column.
|
|
418
|
+
expect(row!.parent_turn_key).toBeNull()
|
|
411
419
|
})
|
|
412
420
|
|
|
413
421
|
it('pretool stores parent_turn_key as NULL when turn_id absent (no regression)', () => {
|
|
@@ -508,7 +508,10 @@ interface FsLike {
|
|
|
508
508
|
* - Row already linked to a different agentId: SQL `WHERE jsonl_agent_id IS
|
|
509
509
|
* NULL` skips it. Re-runs are safe.
|
|
510
510
|
*/
|
|
511
|
-
|
|
511
|
+
// Exported for unit-testing the parent_turn_key backfill (telegram-plugin/
|
|
512
|
+
// tests/subagent-watcher-parent-turn-key.test.ts). Not intended for
|
|
513
|
+
// consumption by other modules.
|
|
514
|
+
export function backfillJsonlAgentId(
|
|
512
515
|
db: SubagentLivenessDb,
|
|
513
516
|
jsonlPath: string,
|
|
514
517
|
agentId: string,
|
|
@@ -555,6 +558,47 @@ function backfillJsonlAgentId(
|
|
|
555
558
|
.prepare('UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?')
|
|
556
559
|
.run(agentId, candidate.id)
|
|
557
560
|
log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
|
|
561
|
+
|
|
562
|
+
// Backfill parent_turn_key (gateway-side). The PreToolUse hook can't know
|
|
563
|
+
// the gateway-minted Telegram turn_key (a chat+topic+turn key) — it only
|
|
564
|
+
// sees Claude Code's session id — so the row was inserted with
|
|
565
|
+
// parent_turn_key=NULL. Resolve
|
|
566
|
+
// it now from the turn whose [started_at, ended_at] window contained the
|
|
567
|
+
// sub-agent's dispatch (its started_at). Keying on the historical
|
|
568
|
+
// started_at, NOT "the turn active now", is what makes this correct for a
|
|
569
|
+
// background worker that outlives its parent turn: the turn may have already
|
|
570
|
+
// ended by link time, but the containment match still finds it. Turns are
|
|
571
|
+
// processed serially per agent, so at most one window contains a given
|
|
572
|
+
// instant; the ORDER BY ... DESC LIMIT 1 is just a defensive tie-break.
|
|
573
|
+
//
|
|
574
|
+
// Without this, resolveSubagentOriginChat() returns null and the live
|
|
575
|
+
// worker card + handback fall back to the operator DM instead of the
|
|
576
|
+
// originating group/forum-topic, and resolveCallingSubagent()'s turn-scoped
|
|
577
|
+
// heuristic (WHERE parent_turn_key = ?) can never see the row. Best-effort:
|
|
578
|
+
// any failure leaves parent_turn_key NULL (today's behaviour) and never
|
|
579
|
+
// throws out of the watcher poll loop.
|
|
580
|
+
try {
|
|
581
|
+
const linkedRow = db
|
|
582
|
+
.prepare('SELECT started_at, parent_turn_key FROM subagents WHERE id = ?')
|
|
583
|
+
.get(candidate.id) as { started_at: number; parent_turn_key: string | null } | null
|
|
584
|
+
if (linkedRow != null && linkedRow.parent_turn_key == null) {
|
|
585
|
+
const turn = db
|
|
586
|
+
.prepare(
|
|
587
|
+
`SELECT turn_key FROM turns
|
|
588
|
+
WHERE started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)
|
|
589
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
590
|
+
)
|
|
591
|
+
.get(linkedRow.started_at, linkedRow.started_at) as { turn_key: string } | null
|
|
592
|
+
if (turn?.turn_key != null) {
|
|
593
|
+
db
|
|
594
|
+
.prepare('UPDATE subagents SET parent_turn_key = ? WHERE id = ?')
|
|
595
|
+
.run(turn.turn_key, candidate.id)
|
|
596
|
+
log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} → ${turn.turn_key}`)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch (err) {
|
|
600
|
+
log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} — ${(err as Error).message}`)
|
|
601
|
+
}
|
|
558
602
|
}
|
|
559
603
|
|
|
560
604
|
// Exported for unit-testing the ENOENT/EACCES deregister path
|
|
@@ -271,6 +271,79 @@ describe('subagent-tracker-posttool', () => {
|
|
|
271
271
|
expect(postResult.status).toBe(0)
|
|
272
272
|
expect(postResult.stdout).not.toContain('additionalContext')
|
|
273
273
|
})
|
|
274
|
+
|
|
275
|
+
// The async-launch ACK is Claude Code's verbatim immediate return for a
|
|
276
|
+
// run_in_background Agent/Task dispatch. The posttool trusts it over the
|
|
277
|
+
// pretool's input-derived background flag, which is missing whenever the
|
|
278
|
+
// runtime omits run_in_background from the tool_input the pretool saw
|
|
279
|
+
// (observed on claude-code 2.1.159 — the clerk worker that never surfaced).
|
|
280
|
+
const ASYNC_LAUNCH_ACK =
|
|
281
|
+
'Async agent launched successfully.\n'
|
|
282
|
+
+ 'agentId: go-live-sync-a176dc93\n'
|
|
283
|
+
+ 'The agent is working in the background. You will be notified '
|
|
284
|
+
+ 'automatically when it completes.'
|
|
285
|
+
|
|
286
|
+
it('promotes a mis-recorded foreground row to background from the launch ACK', () => {
|
|
287
|
+
// Pretool sees NO run_in_background key (the production bug) → records
|
|
288
|
+
// background=0, status=running.
|
|
289
|
+
const preResult = runHook(PRETOOL_SCRIPT, {
|
|
290
|
+
session_id: 's-promote',
|
|
291
|
+
tool_name: 'Agent',
|
|
292
|
+
tool_use_id: 'toolu_promote1',
|
|
293
|
+
tool_input: { subagent_type: 'worker', description: 'Go-live sync' },
|
|
294
|
+
})
|
|
295
|
+
expect(preResult.status).toBe(0)
|
|
296
|
+
|
|
297
|
+
const db = openDb()
|
|
298
|
+
const before = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_promote1') as
|
|
299
|
+
| { background: number; status: string }
|
|
300
|
+
| undefined
|
|
301
|
+
expect(before?.background).toBe(0)
|
|
302
|
+
expect(before?.status).toBe('running')
|
|
303
|
+
|
|
304
|
+
// Posttool receives the async-launch ACK → promote to background, do NOT
|
|
305
|
+
// terminalize, and do NOT emit a foreground handback nudge.
|
|
306
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
307
|
+
tool_name: 'Agent',
|
|
308
|
+
tool_use_id: 'toolu_promote1',
|
|
309
|
+
tool_response: { content: [{ type: 'text', text: ASYNC_LAUNCH_ACK }] },
|
|
310
|
+
})
|
|
311
|
+
expect(postResult.status).toBe(0)
|
|
312
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
313
|
+
|
|
314
|
+
const after = db.prepare('SELECT background, status, ended_at FROM subagents WHERE id = ?').get('toolu_promote1') as
|
|
315
|
+
| { background: number; status: string; ended_at: number | null }
|
|
316
|
+
| undefined
|
|
317
|
+
expect(after?.background).toBe(1)
|
|
318
|
+
expect(after?.status).toBe('running')
|
|
319
|
+
expect(after?.ended_at == null).toBe(true)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('still terminalizes a genuine foreground completion (no false promote)', () => {
|
|
323
|
+
// A real foreground sub-agent whose final report happens to mention
|
|
324
|
+
// "background" must NOT be mistaken for a launch ACK — the promote path
|
|
325
|
+
// only fires on the specific async-launch phrasing.
|
|
326
|
+
runHook(PRETOOL_SCRIPT, {
|
|
327
|
+
session_id: 's-noflip',
|
|
328
|
+
tool_name: 'Agent',
|
|
329
|
+
tool_use_id: 'toolu_noflip1',
|
|
330
|
+
tool_input: { subagent_type: 'worker', description: 'Real foreground task', run_in_background: false },
|
|
331
|
+
})
|
|
332
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
333
|
+
tool_name: 'Agent',
|
|
334
|
+
tool_use_id: 'toolu_noflip1',
|
|
335
|
+
tool_response: { result: 'Done. The feature now runs as a background job.', is_error: false },
|
|
336
|
+
})
|
|
337
|
+
expect(postResult.status).toBe(0)
|
|
338
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
339
|
+
|
|
340
|
+
const db = openDb()
|
|
341
|
+
const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_noflip1') as
|
|
342
|
+
| { background: number; status: string }
|
|
343
|
+
| undefined
|
|
344
|
+
expect(row?.background).toBe(0)
|
|
345
|
+
expect(row?.status).toBe('completed')
|
|
346
|
+
})
|
|
274
347
|
})
|
|
275
348
|
|
|
276
349
|
describe('agent-dir resolution (RFC §Bug 2)', () => {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the parent_turn_key backfill in subagent-watcher.ts
|
|
3
|
+
* (backfillJsonlAgentId).
|
|
4
|
+
*
|
|
5
|
+
* The PreToolUse hook records a sub-agent row with parent_turn_key=NULL — it
|
|
6
|
+
* only sees Claude Code's session id, never the Telegram turn_key
|
|
7
|
+
* (chat_id:msg_id) the gateway keys turns on. The gateway backfills
|
|
8
|
+
* parent_turn_key when it links the JSONL stem to the row, resolving it from
|
|
9
|
+
* the turn whose [started_at, ended_at] window contained the sub-agent's
|
|
10
|
+
* dispatch (its started_at). These tests pin that resolution — in particular
|
|
11
|
+
* that it stays correct for a background worker that outlives its parent turn.
|
|
12
|
+
*
|
|
13
|
+
* bun:sqlite — run under Bun:
|
|
14
|
+
* bun test telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
19
|
+
import { tmpdir } from 'os'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { openTurnsDbInMemory } from '../registry/turns-schema.js'
|
|
22
|
+
import { applySubagentsSchema } from '../registry/subagents-schema.js'
|
|
23
|
+
import { backfillJsonlAgentId } from '../subagent-watcher.js'
|
|
24
|
+
|
|
25
|
+
type Db = ReturnType<typeof openTurnsDbInMemory>
|
|
26
|
+
|
|
27
|
+
let tempDir: string
|
|
28
|
+
let db: Db
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tempDir = mkdtempSync(join(tmpdir(), 'sub-parent-turn-'))
|
|
32
|
+
db = openTurnsDbInMemory()
|
|
33
|
+
applySubagentsSchema(db)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
try { db.close() } catch { /* ignore */ }
|
|
38
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function insertTurn(args: {
|
|
42
|
+
turnKey: string
|
|
43
|
+
chatId: string
|
|
44
|
+
threadId?: string | null
|
|
45
|
+
startedAt: number
|
|
46
|
+
endedAt?: number | null
|
|
47
|
+
}) {
|
|
48
|
+
db.prepare(`
|
|
49
|
+
INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
51
|
+
`).run(
|
|
52
|
+
args.turnKey,
|
|
53
|
+
args.chatId,
|
|
54
|
+
args.threadId ?? null,
|
|
55
|
+
args.startedAt,
|
|
56
|
+
args.endedAt ?? null,
|
|
57
|
+
args.startedAt,
|
|
58
|
+
args.startedAt,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function insertSub(args: {
|
|
63
|
+
id: string
|
|
64
|
+
agentType: string
|
|
65
|
+
description: string
|
|
66
|
+
startedAt: number
|
|
67
|
+
parentTurnKey?: string | null
|
|
68
|
+
}) {
|
|
69
|
+
db.prepare(`
|
|
70
|
+
INSERT INTO subagents
|
|
71
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
72
|
+
background, started_at, last_activity_at, status, jsonl_agent_id)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL)
|
|
74
|
+
`).run(
|
|
75
|
+
args.id,
|
|
76
|
+
'sess-1',
|
|
77
|
+
args.parentTurnKey ?? null,
|
|
78
|
+
args.agentType,
|
|
79
|
+
args.description,
|
|
80
|
+
1,
|
|
81
|
+
args.startedAt,
|
|
82
|
+
args.startedAt,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Write the meta.json the backfill reads to match a row by (agentType, description). */
|
|
87
|
+
function writeMeta(agentType: string, description: string): string {
|
|
88
|
+
const jsonlPath = join(tempDir, 'worker.jsonl')
|
|
89
|
+
writeFileSync(join(tempDir, 'worker.meta.json'), JSON.stringify({ agentType, description }))
|
|
90
|
+
return jsonlPath
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readSub(id: string) {
|
|
94
|
+
return db.prepare('SELECT jsonl_agent_id, parent_turn_key FROM subagents WHERE id = ?').get(id) as
|
|
95
|
+
| { jsonl_agent_id: string | null; parent_turn_key: string | null }
|
|
96
|
+
| undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('backfillJsonlAgentId — parent_turn_key resolution', () => {
|
|
100
|
+
it('resolves parent_turn_key from the turn whose window contains the sub-agent started_at', () => {
|
|
101
|
+
insertTurn({ turnKey: '555:10', chatId: '555', threadId: '42', startedAt: 1000, endedAt: 2000 })
|
|
102
|
+
insertSub({ id: 'toolu_a', agentType: 'worker', description: 'Go-live sync', startedAt: 1500 })
|
|
103
|
+
|
|
104
|
+
const jsonlPath = writeMeta('worker', 'Go-live sync')
|
|
105
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_a')
|
|
106
|
+
|
|
107
|
+
const row = readSub('toolu_a')
|
|
108
|
+
expect(row?.jsonl_agent_id).toBe('agentstem_a')
|
|
109
|
+
expect(row?.parent_turn_key).toBe('555:10')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('resolves to the parent turn even after it has ended (background worker outlives the turn)', () => {
|
|
113
|
+
// Parent turn already ended at 1600; a later turn is active "now".
|
|
114
|
+
insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 1600 })
|
|
115
|
+
insertTurn({ turnKey: '555:20', chatId: '555', startedAt: 1700, endedAt: null })
|
|
116
|
+
// The worker was dispatched at 1500 — inside the FIRST (now-ended) turn.
|
|
117
|
+
insertSub({ id: 'toolu_b', agentType: 'worker', description: 'Long task', startedAt: 1500 })
|
|
118
|
+
|
|
119
|
+
const jsonlPath = writeMeta('worker', 'Long task')
|
|
120
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_b')
|
|
121
|
+
|
|
122
|
+
// Must pick the containing (ended) turn, NOT the turn active at link time.
|
|
123
|
+
expect(readSub('toolu_b')?.parent_turn_key).toBe('555:10')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does NOT overwrite an already-populated parent_turn_key', () => {
|
|
127
|
+
insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 2000 })
|
|
128
|
+
insertSub({
|
|
129
|
+
id: 'toolu_c',
|
|
130
|
+
agentType: 'worker',
|
|
131
|
+
description: 'Preset',
|
|
132
|
+
startedAt: 1500,
|
|
133
|
+
parentTurnKey: '999:9',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const jsonlPath = writeMeta('worker', 'Preset')
|
|
137
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_c')
|
|
138
|
+
|
|
139
|
+
expect(readSub('toolu_c')?.parent_turn_key).toBe('999:9')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('leaves parent_turn_key NULL when no turn window contains the dispatch', () => {
|
|
143
|
+
insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 2000 })
|
|
144
|
+
// Dispatched at 50 — before any turn started.
|
|
145
|
+
insertSub({ id: 'toolu_d', agentType: 'worker', description: 'Orphan', startedAt: 50 })
|
|
146
|
+
|
|
147
|
+
const jsonlPath = writeMeta('worker', 'Orphan')
|
|
148
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_d')
|
|
149
|
+
|
|
150
|
+
const row = readSub('toolu_d')
|
|
151
|
+
// Still linked, but no turn to attribute it to.
|
|
152
|
+
expect(row?.jsonl_agent_id).toBe('agentstem_d')
|
|
153
|
+
expect(row?.parent_turn_key == null).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
})
|