switchroom 0.19.38 → 0.19.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +15 -6
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +1141 -566
  5. package/dist/host-control/main.js +220 -8
  6. package/dist/vault/approvals/kernel-server.js +15 -6
  7. package/dist/vault/broker/server.js +15 -6
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +450 -206
  12. package/telegram-plugin/dist/server.js +8 -0
  13. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  14. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  15. package/telegram-plugin/gateway/gateway.ts +3 -3
  16. package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
  17. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  18. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  19. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  20. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  21. package/telegram-plugin/gateway/stream-render.ts +277 -18
  22. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  23. package/telegram-plugin/hooks/hooks.json +11 -0
  24. package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
  25. package/telegram-plugin/session-tail.ts +20 -0
  26. package/telegram-plugin/silence-poke.ts +28 -0
  27. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
  28. package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
  29. package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  37. package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
  38. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  39. package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
  40. package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +19 -15
  42. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
  43. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
  45. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Part B (#3927 follow-up) — the queued-turn card.
3
+ *
4
+ * Since #3927 a genuinely-queued mid-turn message is PARKED with no surface at
5
+ * all until it dequeues: on the machine-authoritative path the legacy Hook A
6
+ * placeholder never fires (it lives in the buffer-until-idle branch that returns
7
+ * before the machine enqueues). These tests assert the fix's lifecycle against
8
+ * the REAL `handleSessionEvent`:
9
+ *
10
+ * • park → a "⏳ Queued" card is posted ONCE, reply-anchored to the parked
11
+ * message, in the envelope's own chat/thread.
12
+ * • dequeue → the SAME card id is adopted as the turn's activityMessageId and
13
+ * EDITED in place (not re-sent) — one continuous lifecycle.
14
+ * • remove → the card is finalized as "folded into the current task".
15
+ * • TTL → the card is finalized as timed-out, never left frozen.
16
+ */
17
+ import { describe, it, expect, beforeEach } from 'vitest'
18
+ import {
19
+ handleSessionEvent,
20
+ __resetParkedTurnStartsForTest,
21
+ __parkedTurnStartCountForTest,
22
+ } from '../gateway/stream-render.js'
23
+ import { makeHarness, enqueue, CHAT, type Harness } from './turn-mint-harness.js'
24
+ import { deriveTurnId } from '../gateway/derive-turn-id.js'
25
+
26
+ interface SendRec { chatId: string; markdown: string; opts: Record<string, unknown>; id: number }
27
+ interface EditRec { chatId: string; messageId: number; markdown: string }
28
+ interface DelRec { chatId: string; messageId: number }
29
+
30
+ /** Attach a recording bot to a harness so the queued-card send/edit/delete calls
31
+ * are observable. `sendRichMessage` returns an incrementing message id. */
32
+ function withRecordingBot(h: Harness) {
33
+ const sends: SendRec[] = []
34
+ const edits: EditRec[] = []
35
+ const deletes: DelRec[] = []
36
+ let nextId = 9001
37
+ ;(h.deps as unknown as { bot: unknown }).bot = {
38
+ api: {
39
+ sendRichMessage: async (chatId: string, msg: { markdown: string }, opts: Record<string, unknown>) => {
40
+ const id = nextId++
41
+ sends.push({ chatId, markdown: msg.markdown, opts, id })
42
+ return { message_id: id }
43
+ },
44
+ editMessageText: async (chatId: string, messageId: number, msg: { markdown: string }) => {
45
+ edits.push({ chatId, messageId, markdown: msg.markdown })
46
+ return true
47
+ },
48
+ deleteMessage: async (chatId: string, messageId: number) => {
49
+ deletes.push({ chatId, messageId })
50
+ return true
51
+ },
52
+ },
53
+ }
54
+ return { sends, edits, deletes }
55
+ }
56
+
57
+ /** Flush the microtask + macrotask queue so the async card send's `.then` (which
58
+ * stores the card id on the parked envelope) has run. */
59
+ const settle = () => new Promise((r) => setTimeout(r, 0))
60
+
61
+ beforeEach(() => {
62
+ __resetParkedTurnStartsForTest()
63
+ })
64
+
65
+ describe('Part B — queued card is posted at park and adopted on dequeue', () => {
66
+ it('parks with a reply-anchored "Queued" card, then EDITS that same card in place on dequeue', async () => {
67
+ const h = makeHarness()
68
+ const rec = withRecordingBot(h)
69
+
70
+ // Turn A mints on an idle session.
71
+ handleSessionEvent(h.deps, enqueue('501'))
72
+ const turnA = h.current()!
73
+ expect(rec.sends).toHaveLength(0) // idle mint posts no queued card
74
+
75
+ // Message B arrives mid-turn → parks → queued card is posted ONCE.
76
+ handleSessionEvent(h.deps, enqueue('502', 'also check the vault'))
77
+ await settle()
78
+ expect(__parkedTurnStartCountForTest()).toBe(1)
79
+ expect(rec.sends).toHaveLength(1)
80
+ const card = rec.sends[0]!
81
+ expect(card.markdown).toContain('Queued')
82
+ // Reply-anchored to B's own message id, in B's chat.
83
+ expect((card.opts.reply_parameters as { message_id: number }).message_id).toBe(502)
84
+ expect(card.chatId).toBe('1001')
85
+
86
+ // Turn A ends; the CLI drains the queue → B mints and ADOPTS the card.
87
+ turnA.endedAt = Date.now()
88
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
89
+ const turnB = h.current()!
90
+ expect(turnB).not.toBe(turnA)
91
+ expect(turnB.sourceMessageId).toBe(502)
92
+ // The queued card became the live progress card — same id, adopted in place.
93
+ expect(turnB.activityMessageId).toBe(card.id)
94
+ expect(turnB.activityEverOpened).toBe(true)
95
+ // No SECOND card was ever sent (edited in place, not re-posted).
96
+ expect(rec.sends).toHaveLength(1)
97
+ })
98
+
99
+ it('posts one card PER envelope (multiple queued messages each get their own)', async () => {
100
+ const h = makeHarness()
101
+ const rec = withRecordingBot(h)
102
+
103
+ handleSessionEvent(h.deps, enqueue('601'))
104
+ handleSessionEvent(h.deps, enqueue('602'))
105
+ handleSessionEvent(h.deps, enqueue('603'))
106
+ await settle()
107
+ expect(__parkedTurnStartCountForTest()).toBe(2)
108
+ expect(rec.sends).toHaveLength(2)
109
+ expect(new Set(rec.sends.map((s) => s.id)).size).toBe(2)
110
+ })
111
+ })
112
+
113
+ describe('Part B — queued card is finalized, never frozen', () => {
114
+ it('finalizes the card as "folded" when the message is removed (folded into the running turn)', async () => {
115
+ const h = makeHarness()
116
+ const rec = withRecordingBot(h)
117
+
118
+ handleSessionEvent(h.deps, enqueue('701'))
119
+ const queued = enqueue('702', 'while you are in there…')
120
+ handleSessionEvent(h.deps, queued)
121
+ await settle()
122
+ expect(rec.sends).toHaveLength(1)
123
+ const cardId = rec.sends[0]!.id
124
+
125
+ handleSessionEvent(h.deps, { kind: 'queue_remove', rawContent: queued.rawContent })
126
+ expect(__parkedTurnStartCountForTest()).toBe(0)
127
+ // The card was edited (finalized) in place — folded, not left on "Queued".
128
+ expect(rec.edits).toHaveLength(1)
129
+ expect(rec.edits[0]!.messageId).toBe(cardId)
130
+ expect(rec.edits[0]!.markdown).toContain('Folded')
131
+ })
132
+
133
+ it('finalizes the card as timed-out on TTL expiry (a dequeue that never arrives)', async () => {
134
+ // Runner-agnostic: the TTL prune reads wall-clock `Date.now()` (it is NOT a
135
+ // scheduled setTimeout), so we drive it by advancing `Date.now` around the
136
+ // dequeue rather than a vitest-only fake-timer API (`vi.runAllTimersAsync`
137
+ // is absent under bun — the same reason handback-preturn-signal.test.ts
138
+ // injects its own scheduler). `parkedAt` is stamped with the REAL clock at
139
+ // park; only the prune's `now` is advanced, so the entry ages past the TTL.
140
+ const h = makeHarness()
141
+ const rec = withRecordingBot(h)
142
+
143
+ handleSessionEvent(h.deps, enqueue('1201'))
144
+ const turnA = h.current()!
145
+ handleSessionEvent(h.deps, enqueue('1202'))
146
+ await settle() // real microtask/macrotask flush → the card id lands on the entry
147
+ expect(rec.sends).toHaveLength(1)
148
+ const cardId = rec.sends[0]!.id
149
+
150
+ // 31 minutes pass with neither dequeue nor remove; a stray dequeue prunes it.
151
+ const realNow = Date.now
152
+ Date.now = () => realNow() + 31 * 60_000
153
+ try {
154
+ turnA.endedAt = Date.now()
155
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
156
+ } finally {
157
+ Date.now = realNow
158
+ }
159
+ await settle()
160
+
161
+ expect(__parkedTurnStartCountForTest()).toBe(0)
162
+ expect(h.current()).toBe(turnA) // 1202 was NOT minted 31 min late
163
+ expect(rec.edits).toHaveLength(1)
164
+ expect(rec.edits[0]!.messageId).toBe(cardId)
165
+ expect(rec.edits[0]!.markdown).toContain('timed out')
166
+ })
167
+ })
168
+
169
+ /**
170
+ * Regression for the handback-while-busy MAJOR (adversarial review of #4040).
171
+ *
172
+ * The COMMON worker-handoff case: a worker hands back while the parent session
173
+ * is busy → the handback pre-turn signal arms a card for that turn → the same
174
+ * handback inbound parks → (before the fix) the park posted a SECOND "⏳ Queued"
175
+ * card unconditionally → on dequeue the handback `tryAdopt` won the surface, so
176
+ * the Part B `activityMessageId == null` guard was false and the queued card was
177
+ * neither adopted nor finalized → TWO cards for one message, the queued one
178
+ * FROZEN forever.
179
+ *
180
+ * The shared harness hardcodes `HANDBACK_PRETURN_ENABLED:false` +
181
+ * `tryAdopt:()=>null`, so it never exercised this; here we turn the signal ON
182
+ * with a card-bearing `tryAdopt`, matching prod. Both tests fail on the current
183
+ * HEAD (a queued card is posted and then frozen) and pass after the fix.
184
+ */
185
+ const HANDBACK_CARD_ID = 7777
186
+
187
+ /** Turn the handback pre-turn signal ON for message `msgId`'s turn. `pendingAtPark`
188
+ * models whether `noteHandbackRelease` has already armed the entry by the time the
189
+ * message parks (true = the common case; false = the sub-second race the beginTurn
190
+ * safety net covers). `tryAdopt` always returns the handback card for that turn. */
191
+ function withHandbackFor(h: Harness, msgId: string, pendingAtPark: boolean) {
192
+ const tid = deriveTurnId(CHAT, null, msgId)!
193
+ const d = h.deps as unknown as {
194
+ HANDBACK_PRETURN_ENABLED: boolean
195
+ handbackPreturnSignal: {
196
+ hasPendingForTurnId: (t: string) => boolean
197
+ tryAdopt: (t: string) => { activityMessageId: number | null; statusKey: string } | null
198
+ }
199
+ }
200
+ d.HANDBACK_PRETURN_ENABLED = true
201
+ d.handbackPreturnSignal = {
202
+ hasPendingForTurnId: (t: string) => pendingAtPark && t === tid,
203
+ tryAdopt: (t: string) =>
204
+ t === tid ? { activityMessageId: HANDBACK_CARD_ID, statusKey: `${CHAT}:main` } : null,
205
+ }
206
+ return tid
207
+ }
208
+
209
+ describe('Part B — handback-while-busy: exactly one card, never a frozen "Queued"', () => {
210
+ it('SUPPRESSES the queued card at park when a handback signal already owns the turn (common case)', async () => {
211
+ const h = makeHarness()
212
+ const rec = withRecordingBot(h)
213
+ withHandbackFor(h, '502', /* pendingAtPark */ true)
214
+
215
+ // Turn A mints on an idle session.
216
+ handleSessionEvent(h.deps, enqueue('501'))
217
+ const turnA = h.current()!
218
+
219
+ // The handback inbound (502) parks while A is busy. The handback signal
220
+ // already owns 502's surface → NO queued card is posted.
221
+ handleSessionEvent(h.deps, enqueue('502', 'handback: worker done'))
222
+ await settle()
223
+ expect(rec.sends).toHaveLength(0) // ← fails on HEAD (a 2nd card was posted)
224
+
225
+ // A ends → 502 dequeues → adopts the HANDBACK card as its one surface.
226
+ turnA.endedAt = Date.now()
227
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
228
+ const turnB = h.current()!
229
+ expect(turnB.activityMessageId).toBe(HANDBACK_CARD_ID)
230
+ // No queued card was ever posted, edited, or left frozen.
231
+ expect(rec.sends).toHaveLength(0)
232
+ expect(rec.edits).toHaveLength(0)
233
+ expect(rec.deletes).toHaveLength(0)
234
+ })
235
+
236
+ it('DELETES the orphaned queued card at dequeue when the handback armed after park (the race)', async () => {
237
+ const h = makeHarness()
238
+ const rec = withRecordingBot(h)
239
+ withHandbackFor(h, '502', /* pendingAtPark */ false)
240
+
241
+ handleSessionEvent(h.deps, enqueue('501'))
242
+ const turnA = h.current()!
243
+
244
+ // 502 parks BEFORE the handback entry armed → the queued card is posted.
245
+ handleSessionEvent(h.deps, enqueue('502', 'handback: worker done'))
246
+ await settle()
247
+ expect(rec.sends).toHaveLength(1)
248
+ const queuedCardId = rec.sends[0]!.id
249
+
250
+ // A ends → 502 dequeues → the handback adoption WINS the surface, and the
251
+ // now-orphaned queued card is DELETED so it never freezes on "⏳ Queued".
252
+ turnA.endedAt = Date.now()
253
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
254
+ const turnB = h.current()!
255
+ expect(turnB.activityMessageId).toBe(HANDBACK_CARD_ID) // handback card is the surface
256
+ expect(rec.deletes).toHaveLength(1) // ← fails on HEAD (no cleanup, card frozen)
257
+ expect(rec.deletes[0]!.messageId).toBe(queuedCardId)
258
+ // One live card total: the handback card. The queued card was removed, not
259
+ // left as a frozen "Queued" and not finalized as folded/timed-out.
260
+ expect(rec.edits).toHaveLength(0)
261
+ })
262
+ })
@@ -547,6 +547,53 @@ describe('deliverCapturedProse — silent-end recovery (routed into the send mod
547
547
  expect(h.calls).toHaveLength(0)
548
548
  })
549
549
 
550
+ it('PERSIST PARITY: the exhausted-boundary plain-text fallback records the recovered answer to history', async () => {
551
+ // #3228 exhausted-boundary: the rich send fails on the attempt where the
552
+ // re-prompt budget is already spent, so deliverCapturedProse delivers the
553
+ // real answer as PLAIN text. Pre-fix that plain-text delivery recorded only
554
+ // to the in-memory dedup and NEVER called recordOutbound — so the recovered
555
+ // answer reached the user but was silently absent from history.db. This
556
+ // asserts recordOutbound now fires once with the delivered message_id + the
557
+ // recovered text.
558
+ const dedup = new OutboundDedupCache()
559
+ // Rich send parse-rejects (one chunk → one queued failure); the plain
560
+ // sendMessage fallback succeeds.
561
+ const { api, calls } = makeFakeBot({ sendRichMessage: [grammy400("can't parse entities")] })
562
+ const recorded: Array<{ chat_id: string; thread_id: number | null; message_ids: number[]; texts: string[] }> = []
563
+ const text = 'The exhausted-boundary recovered answer.'
564
+ const deps: DeliverCapturedProseDeps = {
565
+ outboundDedup: dedup,
566
+ bot: { api } as unknown as DeliverCapturedProseDeps['bot'],
567
+ robustApiCall: (fn) => fn(),
568
+ redactOutboundText: (t) => redact(t),
569
+ recordOutbound: (rec) => recorded.push({ chat_id: rec.chat_id, thread_id: rec.thread_id, message_ids: rec.message_ids, texts: rec.texts }),
570
+ HISTORY_ENABLED: true,
571
+ OBLIGATION_LEDGER_ENABLED: true,
572
+ obligationLedger: { close: () => {} },
573
+ clearSilentEndState: () => {},
574
+ // Budget already spent → the fallback path fires.
575
+ recordUndeliveredTurnEnd: () => ({ exhausted: true }),
576
+ hasOutboundDeliveredSince: () => false,
577
+ }
578
+ await deliverCapturedProse(deps, {
579
+ chatId: CHAT,
580
+ threadId: undefined,
581
+ statusKeyStr: `${CHAT}:main`,
582
+ registryKey: null,
583
+ originTurnId: 'turn-45',
584
+ text,
585
+ })
586
+ // The plain-text fallback actually delivered.
587
+ const plainSends = calls.filter((c) => c.method === 'sendMessage')
588
+ expect(plainSends).toHaveLength(1)
589
+ expect(plainSends[0]!.text).toBe(text)
590
+ // …and it was persisted to history exactly once with the delivered id + text.
591
+ expect(recorded).toHaveLength(1)
592
+ expect(recorded[0]!.chat_id).toBe(CHAT)
593
+ expect(recorded[0]!.message_ids).toEqual([plainSends[0]!.message_id])
594
+ expect(recorded[0]!.texts).toEqual([text])
595
+ })
596
+
550
597
  it('already-deduped content settles bookkeeping WITHOUT a wire send', async () => {
551
598
  const dedup = new OutboundDedupCache()
552
599
  const text = 'Already went out earlier via the stream.'
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Regression: the PreToolUse hook must NOT write labels for SIDECHAIN
3
+ * (sub-agent) tool calls into the parent session's sidecar.
4
+ *
5
+ * The bug (log-verified): Claude Code fires PreToolUse for sub-agent tool
6
+ * calls too, and passes the PARENT `session_id`. The hook wrote the worker's
7
+ * label into `tool-labels-<parentSession>.jsonl`; the gateway's real-time
8
+ * draft-mirror tails that file and emits a main-tier `tool_label` event per
9
+ * line, binding it to whatever turn is live — so a still-running background
10
+ * worker dispatched by the PREVIOUS turn streamed its Bash step labels into an
11
+ * unrelated new turn's progress card, and inflated that turn's
12
+ * `labeledToolCount` / re-armed its orphaned-reply fuse.
13
+ *
14
+ * The fix severs the chain at its cheapest, at-source link: the hook drops
15
+ * sidechain calls, so a sub-agent `toolUseId` never reaches the parent sidecar
16
+ * → never becomes a `tool_label` event → never touches an unrelated card.
17
+ * These tests assert that OUTCOME (no foreign sidecar line), plus the pure
18
+ * predicate that distinguishes main-tier from sub-agent payloads.
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest'
22
+ import { spawnSync } from 'node:child_process'
23
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { join, resolve } from 'node:path'
26
+ // The hook exports isSubagentToolCall for unit testing (see the "Skip main()
27
+ // when imported" guard at the bottom of the hook).
28
+ import { isSubagentToolCall, hasAttributionSignal } from '../hooks/tool-label-pretool.mjs'
29
+
30
+ const HOOK_PATH = resolve(__dirname, '..', 'hooks', 'tool-label-pretool.mjs')
31
+ const FIXTURE_DIR = resolve(__dirname, 'fixtures')
32
+
33
+ /** Load a committed real-shape PreToolUse fixture (see the drift-canary block). */
34
+ function loadFixture(name: string): Record<string, unknown> {
35
+ return JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf8'))
36
+ }
37
+
38
+ /** Run the hook once with a payload; return the sidecar file's lines (if any). */
39
+ function runHook(payload: Record<string, unknown>): {
40
+ stateDir: string
41
+ lines: string[]
42
+ sidecarPath: string
43
+ stderr: string
44
+ } {
45
+ const stateDir = mkdtempSync(join(tmpdir(), 'sidechain-label-'))
46
+ const sessionId = String(payload.session_id ?? 'sess')
47
+ const sidecarPath = join(stateDir, `tool-labels-${sessionId}.jsonl`)
48
+ const res = spawnSync(process.execPath, [HOOK_PATH], {
49
+ input: JSON.stringify(payload),
50
+ env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
51
+ encoding: 'utf8',
52
+ })
53
+ // The hook must always exit 0 (exit 2 would BLOCK the tool call).
54
+ expect(res.status).toBe(0)
55
+ const lines = existsSync(sidecarPath)
56
+ ? readFileSync(sidecarPath, 'utf8').split('\n').filter(Boolean)
57
+ : []
58
+ return { stateDir, lines, sidecarPath, stderr: res.stderr ?? '' }
59
+ }
60
+
61
+ describe('isSubagentToolCall — main-tier vs sidechain discrimination', () => {
62
+ it('treats agent_type "main" as main-tier (keep)', () => {
63
+ expect(isSubagentToolCall({ agent_type: 'main', agent_id: 's1', session_id: 's1' })).toBe(false)
64
+ })
65
+
66
+ it('treats an ABSENT agent_type as main-tier (safe default — keep)', () => {
67
+ expect(isSubagentToolCall({ session_id: 's1' })).toBe(false)
68
+ expect(isSubagentToolCall({ agent_id: 's1', session_id: 's1' })).toBe(false)
69
+ })
70
+
71
+ it('flags every concrete non-"main" agent_type as sidechain (drop)', () => {
72
+ for (const at of ['worker', 'general-purpose', 'Explore', 'Plan', 'researcher', 'my-custom-agent']) {
73
+ expect(isSubagentToolCall({ agent_type: at, session_id: 'parent' })).toBe(true)
74
+ }
75
+ })
76
+
77
+ it('flags a distinct agent_id (sub-agent id ≠ parent session) as sidechain', () => {
78
+ // Corroborating signal even if agent_type were somehow absent.
79
+ expect(isSubagentToolCall({ agent_id: 'sub-abc', session_id: 'parent-xyz' })).toBe(true)
80
+ })
81
+
82
+ it('does not flag main when agent_id equals session_id', () => {
83
+ expect(isSubagentToolCall({ agent_id: 'same', session_id: 'same' })).toBe(false)
84
+ })
85
+
86
+ it('is robust to a null/undefined event', () => {
87
+ expect(isSubagentToolCall(null)).toBe(false)
88
+ expect(isSubagentToolCall(undefined)).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('PreToolUse hook — sidechain calls never reach the parent sidecar', () => {
93
+ it('writes a sidecar line for a MAIN-tier Bash call', () => {
94
+ const { stateDir, lines } = runHook({
95
+ session_id: 'parent-sess',
96
+ tool_use_id: 'toolu_main_1',
97
+ tool_name: 'Bash',
98
+ tool_input: { command: 'git status', description: 'Check status' },
99
+ agent_type: 'main',
100
+ agent_id: 'parent-sess',
101
+ })
102
+ try {
103
+ expect(lines.length).toBe(1)
104
+ const row = JSON.parse(lines[0])
105
+ expect(row.tool_use_id).toBe('toolu_main_1')
106
+ expect(row.label).toBe('Check status')
107
+ } finally {
108
+ rmSync(stateDir, { recursive: true, force: true })
109
+ }
110
+ })
111
+
112
+ it('DROPS a sub-agent Bash call (no line in the parent sidecar)', () => {
113
+ // The exact shape of the log-verified bug: a background worker's Bash call,
114
+ // carrying the PARENT session_id but a "worker" agent_type.
115
+ const { stateDir, lines, sidecarPath } = runHook({
116
+ session_id: 'parent-sess',
117
+ tool_use_id: 'toolu_worker_9',
118
+ tool_name: 'Bash',
119
+ tool_input: { command: 'npm run build', description: 'Build the worker output' },
120
+ agent_type: 'worker',
121
+ agent_id: 'sub-agent-42',
122
+ })
123
+ try {
124
+ // No sidecar row at all → the draft-mirror never emits a foreign
125
+ // tool_label → the live turn's card / labeledToolCount / fuse are
126
+ // untouched. This is the regression that would have caught the bug.
127
+ expect(lines.length).toBe(0)
128
+ expect(existsSync(sidecarPath)).toBe(false)
129
+ } finally {
130
+ rmSync(stateDir, { recursive: true, force: true })
131
+ }
132
+ })
133
+
134
+ it('DROPS a sub-agent call flagged only by a distinct agent_id', () => {
135
+ const { stateDir, lines } = runHook({
136
+ session_id: 'parent-sess',
137
+ tool_use_id: 'toolu_worker_10',
138
+ tool_name: 'Read',
139
+ tool_input: { file_path: '/etc/hosts' },
140
+ agent_id: 'sub-agent-77',
141
+ })
142
+ try {
143
+ expect(lines.length).toBe(0)
144
+ } finally {
145
+ rmSync(stateDir, { recursive: true, force: true })
146
+ }
147
+ })
148
+ })
149
+
150
+ /**
151
+ * DRIFT CANARY — the compile-time half.
152
+ *
153
+ * The hand-built payloads above all use the field NAMES this reviewer round
154
+ * happened to expect (`agent_type`, `agent_id`). If a future Claude Code
155
+ * renamed BOTH (e.g. `agent_type`→`agentType`), those tests would stay green
156
+ * while the hook silently fails open and the cross-turn contamination bug
157
+ * returns. These two fixtures are the REAL 2.1.219 PreToolUse shape, verified
158
+ * field-by-field against the installed binary's base `Kf(...)` hook-input
159
+ * builder:
160
+ * { session_id, transcript_path, cwd, prompt_id?, permission_mode,
161
+ * agent_id, agent_type, effort?, hook_event_name, tool_name, tool_input,
162
+ * tool_use_id }
163
+ * Piping the genuine object shape through the real hook proves the predicate
164
+ * works against what claude actually emits (not a mental model of it), and
165
+ * pins the field names so a rename that reaches these fixtures fails CI. The
166
+ * runtime WARNING below covers the residual case (a live CLI drift the frozen
167
+ * fixture can't see).
168
+ */
169
+ describe('drift canary — real 2.1.219 PreToolUse payload shape', () => {
170
+ it('DROPS a genuine sub-agent payload (full Kf shape) — zero sidecar lines, no file', () => {
171
+ const fixture = loadFixture('pretool-subagent-2.1.219.json')
172
+ // Sanity: the fixture really is a sidechain shape (parent session_id, own
173
+ // agent_id, concrete agent_type) — if a maintainer refreshes it from a
174
+ // future CLI and the schema moved, this predicate assertion trips first.
175
+ expect(isSubagentToolCall(fixture)).toBe(true)
176
+ const { stateDir, lines, sidecarPath } = runHook(fixture)
177
+ try {
178
+ expect(lines.length).toBe(0)
179
+ expect(existsSync(sidecarPath)).toBe(false)
180
+ } finally {
181
+ rmSync(stateDir, { recursive: true, force: true })
182
+ }
183
+ })
184
+
185
+ it('KEEPS a genuine main-tier payload (full Kf shape) — one sidecar line', () => {
186
+ const fixture = loadFixture('pretool-main-2.1.219.json')
187
+ expect(isSubagentToolCall(fixture)).toBe(false)
188
+ const { stateDir, lines } = runHook(fixture)
189
+ try {
190
+ expect(lines.length).toBe(1)
191
+ const row = JSON.parse(lines[0])
192
+ expect(row.tool_use_id).toBe('toolu_01MainReadCall4z')
193
+ expect(row.label).toBe('Reading index.ts')
194
+ } finally {
195
+ rmSync(stateDir, { recursive: true, force: true })
196
+ }
197
+ })
198
+ })
199
+
200
+ /**
201
+ * SYMMETRIC FALSE-POSITIVE guard — an explicit "main" must win over identity.
202
+ *
203
+ * The predicate's identity fallback (`agent_id !== session_id` ⇒ sidechain)
204
+ * must never override an explicit `agent_type: "main"`. If a FUTURE main-tier
205
+ * payload stopped setting `agent_id === session_id`, the old ordering would
206
+ * have wrongly DROPPED legit main labels (the whole live feed for that turn).
207
+ */
208
+ describe('agent_type "main" is authoritative — never a false-positive drop', () => {
209
+ it('KEEPS a "main" payload even when agent_id differs from session_id', () => {
210
+ expect(
211
+ isSubagentToolCall({ agent_type: 'main', agent_id: 'different-id', session_id: 's1' }),
212
+ ).toBe(false)
213
+ const { stateDir, lines } = runHook({
214
+ session_id: 's1',
215
+ tool_use_id: 'toolu_main_diverged',
216
+ tool_name: 'Read',
217
+ tool_input: { file_path: '/x/y.ts' },
218
+ agent_type: 'main',
219
+ agent_id: 'different-id',
220
+ })
221
+ try {
222
+ expect(lines.length).toBe(1)
223
+ expect(JSON.parse(lines[0]).tool_use_id).toBe('toolu_main_diverged')
224
+ } finally {
225
+ rmSync(stateDir, { recursive: true, force: true })
226
+ }
227
+ })
228
+ })
229
+
230
+ /**
231
+ * FAIL-LOUD guard — a payload with NEITHER identity field is anomalous on
232
+ * 2.1.219 and means the schema may have drifted out from under the predicate.
233
+ * The hook must warn (so plugin-logger surfaces it) but NOT drop (dropping
234
+ * every label on drift would dark-out the whole feed).
235
+ */
236
+ describe('hasAttributionSignal + the schema-drift warning', () => {
237
+ it('hasAttributionSignal is true with either field, false with neither', () => {
238
+ expect(hasAttributionSignal({ agent_type: 'main' })).toBe(true)
239
+ expect(hasAttributionSignal({ agent_id: 'x' })).toBe(true)
240
+ expect(hasAttributionSignal({ agent_type: '', agent_id: '' })).toBe(false)
241
+ expect(hasAttributionSignal({ session_id: 's1', tool_use_id: 't' })).toBe(false)
242
+ expect(hasAttributionSignal(null)).toBe(false)
243
+ })
244
+
245
+ it('WARNS but still writes when a real tool call carries neither agent_type nor agent_id', () => {
246
+ const { stateDir, lines, stderr } = runHook({
247
+ session_id: 's1',
248
+ tool_use_id: 'toolu_no_attribution',
249
+ tool_name: 'Read',
250
+ tool_input: { file_path: '/x/y.ts' },
251
+ // No agent_type, no agent_id — the both-fields-dropped drift scenario.
252
+ })
253
+ try {
254
+ // Loud: the drift warning fires (plugin-logger tails stderr)…
255
+ expect(stderr).toContain('sidechain-attribution invariant may be')
256
+ // …but NOT dropped — main-tier labels still flow so the feed isn't dark.
257
+ expect(lines.length).toBe(1)
258
+ expect(JSON.parse(lines[0]).tool_use_id).toBe('toolu_no_attribution')
259
+ } finally {
260
+ rmSync(stateDir, { recursive: true, force: true })
261
+ }
262
+ })
263
+
264
+ it('does NOT warn on a normal 2.1.219 payload (both fields present)', () => {
265
+ const { stateDir, stderr } = runHook(loadFixture('pretool-main-2.1.219.json'))
266
+ try {
267
+ expect(stderr).not.toContain('sidechain-attribution invariant may be')
268
+ } finally {
269
+ rmSync(stateDir, { recursive: true, force: true })
270
+ }
271
+ })
272
+ })