switchroom 0.19.38 → 0.19.39
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/auth-broker/index.js +3 -3
- package/dist/cli/switchroom.js +618 -378
- package/dist/host-control/main.js +4 -4
- package/dist/vault/approvals/kernel-server.js +3 -3
- package/dist/vault/broker/server.js +3 -3
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +140 -19
- package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
- package/telegram-plugin/gateway/stream-render.ts +238 -17
- package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
- package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
- package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
- package/telegram-plugin/tests/queued-card-surface.test.ts +262 -0
- package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
- package/vendor/hindsight-memory/scripts/retain.py +19 -15
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
- 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
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -220,15 +220,18 @@ class HindsightClient:
|
|
|
220
220
|
tags: Optional[list] = None,
|
|
221
221
|
timeout: int = 15,
|
|
222
222
|
async_processing: bool = True,
|
|
223
|
-
observation_scopes: Optional[
|
|
223
|
+
observation_scopes: Optional[object] = None,
|
|
224
224
|
) -> dict:
|
|
225
225
|
"""Retain content into a bank's memory.
|
|
226
226
|
|
|
227
227
|
``observation_scopes`` is a per-row Hindsight field controlling which
|
|
228
228
|
observation scope consolidation writes this item's observations into.
|
|
229
229
|
``"shared"`` puts them in ONE global untagged scope instead of a scope
|
|
230
|
-
per tag
|
|
231
|
-
|
|
230
|
+
per tag; an explicit ``list[list[str]]`` (e.g. ``[["lesson"]]``, what
|
|
231
|
+
the ``curated`` strategy emits) declares the exact scope(s) to
|
|
232
|
+
consolidate into, JSON-serialised onto the wire as a nested array.
|
|
233
|
+
``None`` (a falsy value) omits the field entirely, so the engine's own
|
|
234
|
+
default stands and the wire body is byte-identical to a
|
|
232
235
|
pre-``observation_scopes`` client.
|
|
233
236
|
|
|
234
237
|
By default posts with ``async=true`` so the server processes extraction
|
|
@@ -336,7 +339,7 @@ class HindsightClient:
|
|
|
336
339
|
tags: Optional[list],
|
|
337
340
|
timeout: int,
|
|
338
341
|
async_processing: bool,
|
|
339
|
-
observation_scopes: Optional[
|
|
342
|
+
observation_scopes: Optional[object] = None,
|
|
340
343
|
) -> dict:
|
|
341
344
|
"""POST exactly one retain item. Raises on any HTTP/transport error."""
|
|
342
345
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
|