switchroom 0.19.18 → 0.19.22
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 +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
13
|
-
import { mkdtempSync, mkdirSync, rmSync } from 'fs'
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'fs'
|
|
14
14
|
import { tmpdir } from 'os'
|
|
15
15
|
import { join } from 'path'
|
|
16
16
|
import { spawnSync } from 'child_process'
|
|
17
|
+
import { recordSubagentEnd, reapStuckRunningRows } from '../registry/subagents-schema.js'
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Helpers
|
|
@@ -546,6 +547,635 @@ describe('subagent-tracker-posttool', () => {
|
|
|
546
547
|
})
|
|
547
548
|
})
|
|
548
549
|
|
|
550
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
551
|
+
// #3667 — the PRODUCTION async-launch payload
|
|
552
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
553
|
+
//
|
|
554
|
+
// Every posttool test above feeds a hand-written `{ content: [...] }` or
|
|
555
|
+
// `{ result: '...' }` shape. Claude Code's Agent tool has never returned
|
|
556
|
+
// either of those for a dispatch: it returns a STRUCTURED object with no text
|
|
557
|
+
// anywhere on it. Because the prose ACK tiers read '' from that object, the
|
|
558
|
+
// hook classified 314/318 real dispatches as foreground completions and
|
|
559
|
+
// stamped `status='completed', ended_at≈started_at` ~0.2s after launch, while
|
|
560
|
+
// `last_activity_at` kept climbing for the worker's real lifetime.
|
|
561
|
+
//
|
|
562
|
+
// So these tests pin the REAL payload, captured verbatim from a live parent
|
|
563
|
+
// transcript (`toolUseResult`, claude-code 2.1.219). Update it only against a
|
|
564
|
+
// freshly captured transcript, never by hand.
|
|
565
|
+
const PROD_ASYNC_LAUNCH_RESPONSE = {
|
|
566
|
+
isAsync: true,
|
|
567
|
+
status: 'async_launched',
|
|
568
|
+
agentId: 'aec3ddbae614f85f5',
|
|
569
|
+
description: 'Fix subagent rows terminalizing early',
|
|
570
|
+
resolvedModel: 'claude-opus-5',
|
|
571
|
+
prompt: 'Work in /share/code/switchroom. Read the repo conventions first...',
|
|
572
|
+
outputFile: '/tmp/claude-x/tasks/aec3ddbae614f85f5.output',
|
|
573
|
+
canReadOutputFile: true,
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
describe('subagent-tracker-posttool — async launch is not a completion (#3667)', () => {
|
|
577
|
+
it('leaves a still-running worker non-terminal on the production async-launch payload', () => {
|
|
578
|
+
// Pretool sees the production tool_input: NO run_in_background key (the
|
|
579
|
+
// runtime auto-backgrounds and does not echo the flag) → background=0.
|
|
580
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
581
|
+
session_id: 's-3667',
|
|
582
|
+
tool_name: 'Agent',
|
|
583
|
+
tool_use_id: 'toolu_3667a',
|
|
584
|
+
tool_input: { subagent_type: 'worker', description: 'Fix subagent rows terminalizing early' },
|
|
585
|
+
}).status).toBe(0)
|
|
586
|
+
|
|
587
|
+
const db = openDb()
|
|
588
|
+
const before = db
|
|
589
|
+
.prepare('SELECT background, status, started_at FROM subagents WHERE id = ?')
|
|
590
|
+
.get('toolu_3667a') as { background: number; status: string; started_at: number }
|
|
591
|
+
expect(before.background).toBe(0)
|
|
592
|
+
expect(before.status).toBe('running')
|
|
593
|
+
|
|
594
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
595
|
+
tool_name: 'Agent',
|
|
596
|
+
tool_use_id: 'toolu_3667a',
|
|
597
|
+
tool_response: PROD_ASYNC_LAUNCH_RESPONSE,
|
|
598
|
+
})
|
|
599
|
+
expect(postResult.status).toBe(0)
|
|
600
|
+
|
|
601
|
+
const after = db
|
|
602
|
+
.prepare('SELECT background, status, ended_at, last_activity_at, result_summary FROM subagents WHERE id = ?')
|
|
603
|
+
.get('toolu_3667a') as {
|
|
604
|
+
background: number
|
|
605
|
+
status: string
|
|
606
|
+
ended_at: number | null
|
|
607
|
+
last_activity_at: number
|
|
608
|
+
result_summary: string | null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// THE failure shape: a launched-but-still-running worker must not read as
|
|
612
|
+
// terminal, and must carry no end timestamp at all.
|
|
613
|
+
expect(after.status).toBe('running')
|
|
614
|
+
expect(after.ended_at).toBeNull()
|
|
615
|
+
// Promoted off the mis-recorded foreground flag, so the watcher/card path
|
|
616
|
+
// treats it as the background worker it actually is.
|
|
617
|
+
expect(after.background).toBe(1)
|
|
618
|
+
// Liveness still recorded.
|
|
619
|
+
expect(after.last_activity_at).toBeGreaterThanOrEqual(before.started_at)
|
|
620
|
+
// The launch payload embeds the whole dispatch prompt — it must never be
|
|
621
|
+
// mistaken for the worker's result.
|
|
622
|
+
expect(after.result_summary).toBeNull()
|
|
623
|
+
|
|
624
|
+
// No handback nudge: nothing has been handed back yet.
|
|
625
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('records a truthful duration when the worker actually finishes later', () => {
|
|
629
|
+
// The other half of the failure shape: `ended_at` must reflect REAL
|
|
630
|
+
// completion, not the launch ACK. After the ACK the watcher's
|
|
631
|
+
// JSONL-driven turn_end write is what terminalizes the row.
|
|
632
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
633
|
+
session_id: 's-3667b',
|
|
634
|
+
tool_name: 'Agent',
|
|
635
|
+
tool_use_id: 'toolu_3667b',
|
|
636
|
+
tool_input: { subagent_type: 'worker', description: 'Long job' },
|
|
637
|
+
}).status).toBe(0)
|
|
638
|
+
|
|
639
|
+
expect(runHook(POSTTOOL_SCRIPT, {
|
|
640
|
+
tool_name: 'Agent',
|
|
641
|
+
tool_use_id: 'toolu_3667b',
|
|
642
|
+
tool_response: PROD_ASYNC_LAUNCH_RESPONSE,
|
|
643
|
+
}).status).toBe(0)
|
|
644
|
+
|
|
645
|
+
const db = openDb()
|
|
646
|
+
const row = db
|
|
647
|
+
.prepare('SELECT started_at, ended_at FROM subagents WHERE id = ?')
|
|
648
|
+
.get('toolu_3667b') as { started_at: number; ended_at: number | null }
|
|
649
|
+
expect(row.ended_at).toBeNull()
|
|
650
|
+
|
|
651
|
+
// Watcher's terminal write, 5 minutes after dispatch. Uses the REAL
|
|
652
|
+
// recordSubagentEnd the watcher's turn_end path calls, so this test can't
|
|
653
|
+
// pass against a hand-copied approximation of it.
|
|
654
|
+
const realEnd = row.started_at + 300_000
|
|
655
|
+
recordSubagentEnd(db as unknown as Parameters<typeof recordSubagentEnd>[0], {
|
|
656
|
+
id: 'toolu_3667b',
|
|
657
|
+
endedAt: realEnd,
|
|
658
|
+
status: 'completed',
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
const done = db
|
|
662
|
+
.prepare('SELECT status, ended_at, started_at FROM subagents WHERE id = ?')
|
|
663
|
+
.get('toolu_3667b') as { status: string; ended_at: number; started_at: number }
|
|
664
|
+
expect(done.status).toBe('completed')
|
|
665
|
+
// The regression signature was ended_at - started_at < ~1s on a job that
|
|
666
|
+
// ran for minutes. Assert the recorded duration is the real one.
|
|
667
|
+
expect(done.ended_at - done.started_at).toBe(300_000)
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('does NOT terminalize on an unrecognised tool_response shape', () => {
|
|
671
|
+
// Drift guard: if claude-code changes the payload again, the hook must
|
|
672
|
+
// decline to guess rather than declare a live worker finished. It warns on
|
|
673
|
+
// stderr instead, and the watcher stays in charge of the terminal write.
|
|
674
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
675
|
+
session_id: 's-3667c',
|
|
676
|
+
tool_name: 'Agent',
|
|
677
|
+
tool_use_id: 'toolu_3667c',
|
|
678
|
+
tool_input: { subagent_type: 'worker', description: 'Drifted payload' },
|
|
679
|
+
}).status).toBe(0)
|
|
680
|
+
|
|
681
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
682
|
+
tool_name: 'Agent',
|
|
683
|
+
tool_use_id: 'toolu_3667c',
|
|
684
|
+
tool_response: { someFutureField: 42, launchToken: 'abc' },
|
|
685
|
+
})
|
|
686
|
+
expect(postResult.status).toBe(0)
|
|
687
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
688
|
+
expect(postResult.stderr).toContain('unrecognised Agent tool_response shape')
|
|
689
|
+
// Shape only — never values (the payload can embed the dispatch prompt).
|
|
690
|
+
expect(postResult.stderr).not.toContain('abc')
|
|
691
|
+
|
|
692
|
+
const db = openDb()
|
|
693
|
+
const row = db
|
|
694
|
+
.prepare('SELECT status, ended_at, background, last_activity_at, started_at FROM subagents WHERE id = ?')
|
|
695
|
+
.get('toolu_3667c') as {
|
|
696
|
+
status: string
|
|
697
|
+
ended_at: number | null
|
|
698
|
+
background: number
|
|
699
|
+
last_activity_at: number | null
|
|
700
|
+
started_at: number
|
|
701
|
+
}
|
|
702
|
+
expect(row.status).toBe('running')
|
|
703
|
+
expect(row.ended_at).toBeNull()
|
|
704
|
+
// background = 0 is a CONTRACT ("PostToolUse terminalizes this row"), not
|
|
705
|
+
// a free-form flag, and it is what reapStuckRunningRows filters on. A row
|
|
706
|
+
// this hook refuses to terminalize must be handed to the watcher/reaper,
|
|
707
|
+
// or under a shape drift EVERY row would sit `running` forever with no
|
|
708
|
+
// owner at all.
|
|
709
|
+
expect(row.background).toBe(1)
|
|
710
|
+
|
|
711
|
+
// Prove the hand-off is real, not just a flag: the reaper must actually
|
|
712
|
+
// sweep this row once it goes stale. Would FAIL if the row stayed
|
|
713
|
+
// background = 0 (the reaper's SELECT requires background = 1).
|
|
714
|
+
const reaped = reapStuckRunningRows(
|
|
715
|
+
db as unknown as Parameters<typeof reapStuckRunningRows>[0],
|
|
716
|
+
{ now: (row.last_activity_at ?? row.started_at) + 3_600_001, ttlMs: 3_600_000 },
|
|
717
|
+
)
|
|
718
|
+
expect(reaped.ids).toContain('toolu_3667c')
|
|
719
|
+
const swept = db
|
|
720
|
+
.prepare('SELECT status, result_summary FROM subagents WHERE id = ?')
|
|
721
|
+
.get('toolu_3667c') as { status: string; result_summary: string | null }
|
|
722
|
+
expect(swept.status).toBe('stalled')
|
|
723
|
+
expect(swept.result_summary).toContain('reaped')
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('terminalizes an explicitly terminal async envelope instead of calling it a launch', () => {
|
|
727
|
+
// Boundary: `isAsync: true` means "this dispatch is asynchronous", not
|
|
728
|
+
// "still running". If a future claude-code reuses the envelope to report
|
|
729
|
+
// an OUTCOME, the machine-readable `status` is authoritative — the whole
|
|
730
|
+
// point of #3667 is to read structured fields instead of guessing.
|
|
731
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
732
|
+
session_id: 's-3667g',
|
|
733
|
+
tool_name: 'Agent',
|
|
734
|
+
tool_use_id: 'toolu_3667g',
|
|
735
|
+
tool_input: { subagent_type: 'worker', description: 'Terminal envelope' },
|
|
736
|
+
}).status).toBe(0)
|
|
737
|
+
|
|
738
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
739
|
+
tool_name: 'Agent',
|
|
740
|
+
tool_use_id: 'toolu_3667g',
|
|
741
|
+
tool_response: { isAsync: true, status: 'completed', agentId: 'zz1' },
|
|
742
|
+
})
|
|
743
|
+
expect(postResult.status).toBe(0)
|
|
744
|
+
// Recognised, so no drift alarm.
|
|
745
|
+
expect(postResult.stderr).not.toContain('unrecognised Agent tool_response shape')
|
|
746
|
+
|
|
747
|
+
const db = openDb()
|
|
748
|
+
const row = db
|
|
749
|
+
.prepare('SELECT status, background, ended_at FROM subagents WHERE id = ?')
|
|
750
|
+
.get('toolu_3667g') as { status: string; background: number; ended_at: number | null }
|
|
751
|
+
expect(row.status).toBe('completed')
|
|
752
|
+
expect(row.ended_at).not.toBeNull()
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('records a terminal async envelope with a FAILED status as failed, not completed', () => {
|
|
756
|
+
// The status is not just a liveness signal — it carries the outcome. A
|
|
757
|
+
// classifier that terminalized on `status` but read the outcome from
|
|
758
|
+
// is_error/text alone would file every failure as a success.
|
|
759
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
760
|
+
session_id: 's-3667h',
|
|
761
|
+
tool_name: 'Agent',
|
|
762
|
+
tool_use_id: 'toolu_3667h',
|
|
763
|
+
tool_input: { subagent_type: 'worker', description: 'Failing envelope' },
|
|
764
|
+
}).status).toBe(0)
|
|
765
|
+
|
|
766
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
767
|
+
tool_name: 'Agent',
|
|
768
|
+
tool_use_id: 'toolu_3667h',
|
|
769
|
+
tool_response: { isAsync: true, status: 'failed', agentId: 'zz2' },
|
|
770
|
+
})
|
|
771
|
+
expect(postResult.status).toBe(0)
|
|
772
|
+
// A failure is not a handback.
|
|
773
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
774
|
+
|
|
775
|
+
const db = openDb()
|
|
776
|
+
const row = db
|
|
777
|
+
.prepare('SELECT status, ended_at FROM subagents WHERE id = ?')
|
|
778
|
+
.get('toolu_3667h') as { status: string; ended_at: number | null }
|
|
779
|
+
expect(row.status).toBe('failed')
|
|
780
|
+
expect(row.ended_at).not.toBeNull()
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('does NOT read a still-in-flight async status as terminal', () => {
|
|
784
|
+
// Only the terminal statuses count. A non-terminal `status` on an
|
|
785
|
+
// isAsync envelope is still a launch — the row must stay alive.
|
|
786
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
787
|
+
session_id: 's-3667i',
|
|
788
|
+
tool_name: 'Agent',
|
|
789
|
+
tool_use_id: 'toolu_3667i',
|
|
790
|
+
tool_input: { subagent_type: 'worker', description: 'In flight' },
|
|
791
|
+
}).status).toBe(0)
|
|
792
|
+
|
|
793
|
+
expect(runHook(POSTTOOL_SCRIPT, {
|
|
794
|
+
tool_name: 'Agent',
|
|
795
|
+
tool_use_id: 'toolu_3667i',
|
|
796
|
+
tool_response: { isAsync: true, status: 'running', agentId: 'zz3' },
|
|
797
|
+
}).status).toBe(0)
|
|
798
|
+
|
|
799
|
+
const db = openDb()
|
|
800
|
+
const row = db
|
|
801
|
+
.prepare('SELECT status, background, ended_at FROM subagents WHERE id = ?')
|
|
802
|
+
.get('toolu_3667i') as { status: string; background: number; ended_at: number | null }
|
|
803
|
+
expect(row.status).toBe('running')
|
|
804
|
+
expect(row.background).toBe(1)
|
|
805
|
+
expect(row.ended_at).toBeNull()
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('does NOT terminalize when tool_response is absent', () => {
|
|
809
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
810
|
+
session_id: 's-3667d',
|
|
811
|
+
tool_name: 'Agent',
|
|
812
|
+
tool_use_id: 'toolu_3667d',
|
|
813
|
+
tool_input: { subagent_type: 'worker', description: 'No response' },
|
|
814
|
+
}).status).toBe(0)
|
|
815
|
+
|
|
816
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
817
|
+
tool_name: 'Agent',
|
|
818
|
+
tool_use_id: 'toolu_3667d',
|
|
819
|
+
})
|
|
820
|
+
expect(postResult.status).toBe(0)
|
|
821
|
+
|
|
822
|
+
const db = openDb()
|
|
823
|
+
const row = db
|
|
824
|
+
.prepare('SELECT status, ended_at FROM subagents WHERE id = ?')
|
|
825
|
+
.get('toolu_3667d') as { status: string; ended_at: number | null }
|
|
826
|
+
expect(row.status).toBe('running')
|
|
827
|
+
expect(row.ended_at).toBeNull()
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('terminalizes a bare-string dispatch error as failed', () => {
|
|
831
|
+
// Also observed in production: the Agent tool returns a bare string when
|
|
832
|
+
// the dispatch itself fails, so there is no `is_error` flag to read. No
|
|
833
|
+
// worker exists, so the watcher will never terminalize this row — the hook
|
|
834
|
+
// must, and it must not call the failure a success.
|
|
835
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
836
|
+
session_id: 's-3667e',
|
|
837
|
+
tool_name: 'Agent',
|
|
838
|
+
tool_use_id: 'toolu_3667e',
|
|
839
|
+
tool_input: { subagent_type: 'worker', description: 'Doomed dispatch' },
|
|
840
|
+
}).status).toBe(0)
|
|
841
|
+
|
|
842
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
843
|
+
tool_name: 'Agent',
|
|
844
|
+
tool_use_id: 'toolu_3667e',
|
|
845
|
+
tool_response:
|
|
846
|
+
'Error: Cannot create agent worktree: not in a git repository and no '
|
|
847
|
+
+ 'WorktreeCreate hooks are configured.',
|
|
848
|
+
})
|
|
849
|
+
expect(postResult.status).toBe(0)
|
|
850
|
+
// A failed dispatch is not a handback.
|
|
851
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
852
|
+
|
|
853
|
+
const db = openDb()
|
|
854
|
+
const row = db
|
|
855
|
+
.prepare('SELECT status, ended_at, result_summary FROM subagents WHERE id = ?')
|
|
856
|
+
.get('toolu_3667e') as { status: string; ended_at: number | null; result_summary: string | null }
|
|
857
|
+
expect(row.status).toBe('failed')
|
|
858
|
+
expect(row.ended_at).not.toBeNull()
|
|
859
|
+
expect(row.result_summary).toContain('Cannot create agent worktree')
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
it('terminalizes a prefixed bare-string dispatch error (InputValidationError) as failed', () => {
|
|
863
|
+
// Also observed in production (1 of the 54 bare-string results): the
|
|
864
|
+
// dispatch failure does NOT start with a literal "Error:". A probe
|
|
865
|
+
// anchored on that exact prefix filed this as a SUCCESSFUL sub-agent
|
|
866
|
+
// completion with the validation error stored as its result_summary —
|
|
867
|
+
// and nothing else ever revisits the row, because no worker was created.
|
|
868
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
869
|
+
session_id: 's-3667j',
|
|
870
|
+
tool_name: 'Agent',
|
|
871
|
+
tool_use_id: 'toolu_3667j',
|
|
872
|
+
tool_input: { subagent_type: 'worker', description: 'Bad input' },
|
|
873
|
+
}).status).toBe(0)
|
|
874
|
+
|
|
875
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
876
|
+
tool_name: 'Agent',
|
|
877
|
+
tool_use_id: 'toolu_3667j',
|
|
878
|
+
tool_response:
|
|
879
|
+
'InputValidationError: [\n {\n "expected": "string",\n'
|
|
880
|
+
+ ' "code": "invalid_type",\n "path": [ "prompt" ]\n }\n]',
|
|
881
|
+
})
|
|
882
|
+
expect(postResult.status).toBe(0)
|
|
883
|
+
expect(postResult.stdout).not.toContain('additionalContext')
|
|
884
|
+
|
|
885
|
+
const db = openDb()
|
|
886
|
+
const row = db
|
|
887
|
+
.prepare('SELECT status, ended_at FROM subagents WHERE id = ?')
|
|
888
|
+
.get('toolu_3667j') as { status: string; ended_at: number | null }
|
|
889
|
+
expect(row.status).toBe('failed')
|
|
890
|
+
expect(row.ended_at).not.toBeNull()
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('does not call a worker report that OPENS with "Error:" a failed dispatch', () => {
|
|
894
|
+
// The bare-string probe must not be run over a worker's own report. A
|
|
895
|
+
// report that opens "Error: I could not reproduce…" describes an error,
|
|
896
|
+
// it is not a failed dispatch, and filing it as `failed` misreports a
|
|
897
|
+
// sub-agent that ran to completion.
|
|
898
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
899
|
+
session_id: 's-3667k',
|
|
900
|
+
tool_name: 'Agent',
|
|
901
|
+
tool_use_id: 'toolu_3667k',
|
|
902
|
+
tool_input: { subagent_type: 'worker', description: 'Repro attempt', run_in_background: false },
|
|
903
|
+
}).status).toBe(0)
|
|
904
|
+
|
|
905
|
+
expect(runHook(POSTTOOL_SCRIPT, {
|
|
906
|
+
tool_name: 'Agent',
|
|
907
|
+
tool_use_id: 'toolu_3667k',
|
|
908
|
+
tool_response: {
|
|
909
|
+
content: [{ type: 'text', text: 'Error: could not be reproduced — here is what I checked instead.' }],
|
|
910
|
+
},
|
|
911
|
+
}).status).toBe(0)
|
|
912
|
+
|
|
913
|
+
const db = openDb()
|
|
914
|
+
const row = db
|
|
915
|
+
.prepare('SELECT status FROM subagents WHERE id = ?')
|
|
916
|
+
.get('toolu_3667k') as { status: string }
|
|
917
|
+
expect(row.status).toBe('completed')
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
it('terminalizes the production SYNCHRONOUS completion envelope', () => {
|
|
921
|
+
// The PR body claimed "Zero synchronous completions" from a 318-dispatch
|
|
922
|
+
// sample. Rescanning every agent's transcripts (2692 Agent/Task results)
|
|
923
|
+
// found 3 real ones, all on agent `marko`, running 164s-490s. They are a
|
|
924
|
+
// THIRD shape with its own machine-readable `status` — pinned here so the
|
|
925
|
+
// classifier is verified against it rather than assumed to be unreachable.
|
|
926
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
927
|
+
session_id: 's-3667l',
|
|
928
|
+
tool_name: 'Agent',
|
|
929
|
+
tool_use_id: 'toolu_3667l',
|
|
930
|
+
tool_input: { subagent_type: 'worker', description: 'Sync reconcile', run_in_background: false },
|
|
931
|
+
}).status).toBe(0)
|
|
932
|
+
|
|
933
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
934
|
+
tool_name: 'Agent',
|
|
935
|
+
tool_use_id: 'toolu_3667l',
|
|
936
|
+
tool_response: {
|
|
937
|
+
status: 'completed',
|
|
938
|
+
prompt: 'Reconcile yesterday…',
|
|
939
|
+
agentId: 'a1b2c3',
|
|
940
|
+
agentType: 'general-purpose',
|
|
941
|
+
content: [{ type: 'text', text: 'Analysis complete. 15 contacts checked.' }],
|
|
942
|
+
resolvedModel: 'claude-opus-5',
|
|
943
|
+
totalDurationMs: 490_293,
|
|
944
|
+
totalTokens: 120_000,
|
|
945
|
+
totalToolUseCount: 42,
|
|
946
|
+
usage: {},
|
|
947
|
+
toolStats: {},
|
|
948
|
+
},
|
|
949
|
+
})
|
|
950
|
+
expect(postResult.status).toBe(0)
|
|
951
|
+
expect(postResult.stderr).not.toContain('unrecognised Agent tool_response shape')
|
|
952
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
953
|
+
|
|
954
|
+
const db = openDb()
|
|
955
|
+
const row = db
|
|
956
|
+
.prepare('SELECT status, ended_at, result_summary FROM subagents WHERE id = ?')
|
|
957
|
+
.get('toolu_3667l') as { status: string; ended_at: number | null; result_summary: string | null }
|
|
958
|
+
expect(row.status).toBe('completed')
|
|
959
|
+
expect(row.ended_at).not.toBeNull()
|
|
960
|
+
expect(row.result_summary).toContain('15 contacts checked')
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('reads the structured status BEFORE the prose ACK, so a report about async launches still terminalizes', () => {
|
|
964
|
+
// Self-referential trap: isAsyncLaunchAck() pattern-matches the flattened
|
|
965
|
+
// response TEXT, and on a real completion that text is the worker's own
|
|
966
|
+
// report. A worker reporting on sub-agent dispatch — e.g. one reviewing
|
|
967
|
+
// this very hook — quotes the ACK phrase verbatim, which classified the
|
|
968
|
+
// completed worker as a fresh LAUNCH: promoted to background, never
|
|
969
|
+
// terminalized, no handback. The structured `status` must settle it first.
|
|
970
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
971
|
+
session_id: 's-3667m',
|
|
972
|
+
tool_name: 'Agent',
|
|
973
|
+
tool_use_id: 'toolu_3667m',
|
|
974
|
+
tool_input: { subagent_type: 'worker', description: 'Review the ACK detector', run_in_background: false },
|
|
975
|
+
}).status).toBe(0)
|
|
976
|
+
|
|
977
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
978
|
+
tool_name: 'Agent',
|
|
979
|
+
tool_use_id: 'toolu_3667m',
|
|
980
|
+
tool_response: {
|
|
981
|
+
status: 'completed',
|
|
982
|
+
totalDurationMs: 61_000,
|
|
983
|
+
content: [{
|
|
984
|
+
type: 'text',
|
|
985
|
+
text: 'Reviewed the detector. The canonical ACK is "Async agent launched '
|
|
986
|
+
+ 'successfully", followed by\nagentId: aec3ddbae614f85f5\nand a line saying '
|
|
987
|
+
+ 'the agent is working in the background.',
|
|
988
|
+
}],
|
|
989
|
+
},
|
|
990
|
+
})
|
|
991
|
+
expect(postResult.status).toBe(0)
|
|
992
|
+
|
|
993
|
+
const db = openDb()
|
|
994
|
+
const row = db
|
|
995
|
+
.prepare('SELECT status, ended_at, background FROM subagents WHERE id = ?')
|
|
996
|
+
.get('toolu_3667m') as { status: string; ended_at: number | null; background: number }
|
|
997
|
+
expect(row.status).toBe('completed')
|
|
998
|
+
expect(row.ended_at).not.toBeNull()
|
|
999
|
+
expect(row.background).toBe(0)
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
it('still terminalizes a genuine foreground completion (control)', () => {
|
|
1003
|
+
// The fix must not swing the other way: a real sync completion still ends
|
|
1004
|
+
// the row, with its result summary and a handback nudge.
|
|
1005
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
1006
|
+
session_id: 's-3667f',
|
|
1007
|
+
tool_name: 'Agent',
|
|
1008
|
+
tool_use_id: 'toolu_3667f',
|
|
1009
|
+
tool_input: { subagent_type: 'worker', description: 'Sync task', run_in_background: false },
|
|
1010
|
+
}).status).toBe(0)
|
|
1011
|
+
|
|
1012
|
+
const postResult = runHook(POSTTOOL_SCRIPT, {
|
|
1013
|
+
tool_name: 'Agent',
|
|
1014
|
+
tool_use_id: 'toolu_3667f',
|
|
1015
|
+
tool_response: { content: [{ type: 'text', text: 'All done: 3 files changed.' }] },
|
|
1016
|
+
})
|
|
1017
|
+
expect(postResult.status).toBe(0)
|
|
1018
|
+
expect(postResult.stdout).toContain('additionalContext')
|
|
1019
|
+
|
|
1020
|
+
const db = openDb()
|
|
1021
|
+
const row = db
|
|
1022
|
+
.prepare('SELECT status, ended_at, background, result_summary FROM subagents WHERE id = ?')
|
|
1023
|
+
.get('toolu_3667f') as {
|
|
1024
|
+
status: string
|
|
1025
|
+
ended_at: number | null
|
|
1026
|
+
background: number
|
|
1027
|
+
result_summary: string | null
|
|
1028
|
+
}
|
|
1029
|
+
expect(row.status).toBe('completed')
|
|
1030
|
+
expect(row.ended_at).not.toBeNull()
|
|
1031
|
+
expect(row.background).toBe(0)
|
|
1032
|
+
expect(row.result_summary).toContain('3 files changed')
|
|
1033
|
+
})
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
describe('subagent-tracker-posttool — never throws on a malformed event', () => {
|
|
1037
|
+
// This hook runs on EVERY PostToolUse across the fleet. An uncaught throw
|
|
1038
|
+
// exits non-zero, and bin/run-hook.sh turns a non-zero hook exit into a red
|
|
1039
|
+
// issue card — so a payload the hook was always going to ignore must not be
|
|
1040
|
+
// able to manufacture one. `JSON.parse('null')` in particular parses fine
|
|
1041
|
+
// and then `null.tool_name` threw a TypeError (pre-existing on main).
|
|
1042
|
+
const BAD_BODIES = ['null', '42', '"a string"', 'true', '[]', '[1,2,3]', '{}', 'not json at all', '']
|
|
1043
|
+
|
|
1044
|
+
for (const body of BAD_BODIES) {
|
|
1045
|
+
it(`exits 0 with no output on stdin ${JSON.stringify(body)}`, () => {
|
|
1046
|
+
const result = spawnSync(process.execPath, [POSTTOOL_SCRIPT], {
|
|
1047
|
+
input: body,
|
|
1048
|
+
encoding: 'utf8',
|
|
1049
|
+
env: { ...process.env, SWITCHROOM_AGENT_DIR: agentDir },
|
|
1050
|
+
timeout: 15_000,
|
|
1051
|
+
})
|
|
1052
|
+
expect(result.status).toBe(0)
|
|
1053
|
+
expect(result.stdout).toBe('')
|
|
1054
|
+
expect(result.stderr).not.toContain('TypeError')
|
|
1055
|
+
})
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
it('exits 0 when tool_use_id is missing or falsy', () => {
|
|
1059
|
+
for (const event of [
|
|
1060
|
+
{ tool_name: 'Agent' },
|
|
1061
|
+
{ tool_name: 'Agent', tool_use_id: null },
|
|
1062
|
+
{ tool_name: 'Agent', tool_use_id: '', tool_response: { result: 'x' } },
|
|
1063
|
+
]) {
|
|
1064
|
+
const result = runHook(POSTTOOL_SCRIPT, event)
|
|
1065
|
+
expect(result.status).toBe(0)
|
|
1066
|
+
expect(result.stderr).not.toContain('TypeError')
|
|
1067
|
+
}
|
|
1068
|
+
})
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
describe('subagent-tracker-posttool — concurrent dispatch (#3667)', () => {
|
|
1072
|
+
it('promotes every row correctly when 12 posttools race on one DB', () => {
|
|
1073
|
+
// A parent fanning out workers fires many PostToolUse hooks against the
|
|
1074
|
+
// same registry.db within milliseconds. Each must land: SQLITE_BUSY
|
|
1075
|
+
// swallowed as a lost update would silently leave rows unpromoted and
|
|
1076
|
+
// back in the #3667 failure mode.
|
|
1077
|
+
const ids = Array.from({ length: 12 }, (_, i) => `toolu_conc_${i}`)
|
|
1078
|
+
for (const id of ids) {
|
|
1079
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
1080
|
+
session_id: 's-conc',
|
|
1081
|
+
tool_name: 'Agent',
|
|
1082
|
+
tool_use_id: id,
|
|
1083
|
+
tool_input: { subagent_type: 'worker', description: id },
|
|
1084
|
+
}).status).toBe(0)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const results = ids.map((id) => runHook(POSTTOOL_SCRIPT, {
|
|
1088
|
+
tool_name: 'Agent',
|
|
1089
|
+
tool_use_id: id,
|
|
1090
|
+
tool_response: { ...PROD_ASYNC_LAUNCH_RESPONSE, agentId: id },
|
|
1091
|
+
}))
|
|
1092
|
+
for (const r of results) expect(r.status).toBe(0)
|
|
1093
|
+
|
|
1094
|
+
const db = openDb()
|
|
1095
|
+
const rows = db
|
|
1096
|
+
.prepare("SELECT status, background, ended_at FROM subagents WHERE id LIKE 'toolu_conc_%'")
|
|
1097
|
+
.all() as Array<{ status: string; background: number; ended_at: number | null }>
|
|
1098
|
+
expect(rows.length).toBe(ids.length)
|
|
1099
|
+
for (const row of rows) {
|
|
1100
|
+
expect(row.status).toBe('running')
|
|
1101
|
+
expect(row.background).toBe(1)
|
|
1102
|
+
expect(row.ended_at).toBeNull()
|
|
1103
|
+
}
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
it('is idempotent when the same row gets several posttool invocations', () => {
|
|
1107
|
+
// Repeat delivery must not flip a promoted row back, nor terminalize it.
|
|
1108
|
+
expect(runHook(PRETOOL_SCRIPT, {
|
|
1109
|
+
session_id: 's-idem',
|
|
1110
|
+
tool_name: 'Agent',
|
|
1111
|
+
tool_use_id: 'toolu_idem',
|
|
1112
|
+
tool_input: { subagent_type: 'worker', description: 'repeat' },
|
|
1113
|
+
}).status).toBe(0)
|
|
1114
|
+
|
|
1115
|
+
for (let i = 0; i < 4; i++) {
|
|
1116
|
+
expect(runHook(POSTTOOL_SCRIPT, {
|
|
1117
|
+
tool_name: 'Agent',
|
|
1118
|
+
tool_use_id: 'toolu_idem',
|
|
1119
|
+
tool_response: PROD_ASYNC_LAUNCH_RESPONSE,
|
|
1120
|
+
}).status).toBe(0)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const db = openDb()
|
|
1124
|
+
const row = db
|
|
1125
|
+
.prepare('SELECT status, background, ended_at FROM subagents WHERE id = ?')
|
|
1126
|
+
.get('toolu_idem') as { status: string; background: number; ended_at: number | null }
|
|
1127
|
+
expect(row.status).toBe('running')
|
|
1128
|
+
expect(row.background).toBe(1)
|
|
1129
|
+
expect(row.ended_at).toBeNull()
|
|
1130
|
+
})
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
describe('subagent-tracker-posttool — runtime-path parity (#3667)', () => {
|
|
1134
|
+
// updateRow has TWO implementations of the same decision: a node:sqlite /
|
|
1135
|
+
// bun:sqlite branch and a sqlite3-CLI fallback. The tests above only ever
|
|
1136
|
+
// exercise the first (bun always resolves a sync binding), so a divergence
|
|
1137
|
+
// in the fallback is invisible to behavioural testing — which is how it got
|
|
1138
|
+
// there: the CLI copy of the conditional fell through to the TERMINAL
|
|
1139
|
+
// update for any `kind` it did not enumerate, while the sync copy fell
|
|
1140
|
+
// through to the non-terminal one. A future fourth kind would have
|
|
1141
|
+
// terminalized live rows on the fallback runtime only.
|
|
1142
|
+
//
|
|
1143
|
+
// Static guard: both branches must dispatch on the shared chooseUpdate()
|
|
1144
|
+
// helper, and neither may re-derive the choice from `kind` inline.
|
|
1145
|
+
const SOURCE = readFileSync(POSTTOOL_SCRIPT, 'utf8')
|
|
1146
|
+
|
|
1147
|
+
it('defines chooseUpdate as the single source of truth for the update path', () => {
|
|
1148
|
+
expect(SOURCE).toContain('function chooseUpdate(')
|
|
1149
|
+
// Once for the sync branch, once for the CLI fallback — and nowhere else.
|
|
1150
|
+
const callSites = SOURCE.match(/const choice = chooseUpdate\(/g) ?? []
|
|
1151
|
+
expect(callSites.length).toBe(2)
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
it('never re-derives the update path from `kind` inside updateRow', () => {
|
|
1155
|
+
const updateRowBody = SOURCE.slice(
|
|
1156
|
+
SOURCE.indexOf('function updateRow('),
|
|
1157
|
+
SOURCE.indexOf('// Foreground handback nudge'),
|
|
1158
|
+
)
|
|
1159
|
+
expect(updateRowBody.length).toBeGreaterThan(0)
|
|
1160
|
+
// No comparison against a KIND_* constant may survive inside updateRow —
|
|
1161
|
+
// that is exactly the duplicated conditional chooseUpdate replaced.
|
|
1162
|
+
expect(updateRowBody).not.toMatch(/snapKind\s*===\s*KIND_/)
|
|
1163
|
+
expect(updateRowBody).not.toMatch(/kind\s*===\s*KIND_/)
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
it('fails SAFE (non-terminal) for a kind neither branch enumerates', () => {
|
|
1167
|
+
// chooseUpdate's contract, asserted on the source: only an explicit
|
|
1168
|
+
// KIND_COMPLETION may reach the terminal update.
|
|
1169
|
+
const body = SOURCE.slice(
|
|
1170
|
+
SOURCE.indexOf('function chooseUpdate('),
|
|
1171
|
+
SOURCE.indexOf('function chooseUpdate(') + 400,
|
|
1172
|
+
)
|
|
1173
|
+
expect(body).toContain("if (kind === KIND_COMPLETION) return 'foreground'")
|
|
1174
|
+
// The trailing arm — everything unrecognised — must NOT be 'foreground'.
|
|
1175
|
+
expect(body).toMatch(/return 'promote'\s*\n\}/)
|
|
1176
|
+
})
|
|
1177
|
+
})
|
|
1178
|
+
|
|
549
1179
|
describe('agent-dir resolution (RFC §Bug 2)', () => {
|
|
550
1180
|
// The hooks used to look only at SWITCHROOM_AGENT_DIR and then cwd.
|
|
551
1181
|
// In production neither matched the path the gateway + watcher used,
|