switchroom 0.16.46 → 0.17.0
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 +83 -81
- package/dist/auth-broker/index.js +104 -88
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -15
- package/dist/cli/notion-write-pretool.mjs +85 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1720 -1392
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +209 -173
- package/dist/vault/approvals/kernel-server.js +86 -83
- package/dist/vault/broker/server.js +284 -139
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +120 -155
- package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
- package/telegram-plugin/dist/server.js +168 -203
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +566 -631
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flagship harness for NESTED (depth-2+) sub-agent card visibility — the
|
|
3
|
+
* unified progress-card fix. Mirrors
|
|
4
|
+
* worker-visibility-prose-silent-harness.test.ts: it wires the REAL
|
|
5
|
+
* `startSubagentWatcher` to the REAL `createWorkerActivityFeed` on ONE
|
|
6
|
+
* virtual clock, with a REAL registry DB (bun:sqlite) and REAL files in a
|
|
7
|
+
* tempdir, and mirrors the gateway's onProgress/onFinish routing
|
|
8
|
+
* (resolveWorkerFeedDispatch + resolveSubagentOriginTurnKey).
|
|
9
|
+
*
|
|
10
|
+
* Scenario (the live incident shape): a depth-1 BACKGROUND worker —
|
|
11
|
+
* attributed to its originating turn at dispatch — spawns a nested worker
|
|
12
|
+
* AFTER the main turn has ended. The PreToolUse hook cannot attribute the
|
|
13
|
+
* nested dispatch (turn-active.json is gone) and may lose the row entirely.
|
|
14
|
+
* Pre-fix: the nested card posted, heartbeat-edited every ~6s, and stayed
|
|
15
|
+
* frozen on "starting…" forever, routed to the owner DM.
|
|
16
|
+
*
|
|
17
|
+
* Asserts the required end-state behaviour for the nested permutation:
|
|
18
|
+
* (a) a registry row EXISTS for the nested worker (watcher-recorded from
|
|
19
|
+
* the parent's JSONL), keyed so the child's meta.json links it,
|
|
20
|
+
* (b) the nested card reaches LIVE TOOL ACTIVITY (real steps, climbing
|
|
21
|
+
* tool count) — never frozen "starting…",
|
|
22
|
+
* (c) the card routes to the ORIGINATING chat (transitive turn-key
|
|
23
|
+
* inheritance), not the owner-DM fallback,
|
|
24
|
+
* (d) clean finalization on turn_end (card edits to done, handle dropped),
|
|
25
|
+
* (e) parallel-worker isolation: the depth-1 parent's own feed message is
|
|
26
|
+
* independent of the nested child's (no cross-worker interleaving).
|
|
27
|
+
*
|
|
28
|
+
* bun:sqlite — run under Bun:
|
|
29
|
+
* bun test telegram-plugin/tests/nested-worker-visibility-harness.test.ts
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect, afterEach } from 'bun:test'
|
|
33
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, appendFileSync } from 'fs'
|
|
34
|
+
import { tmpdir } from 'os'
|
|
35
|
+
import { join } from 'path'
|
|
36
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
37
|
+
import { createWorkerActivityFeed, type WorkerActivityView } from '../worker-activity-feed.js'
|
|
38
|
+
import { openTurnsDbInMemory } from '../registry/turns-schema.js'
|
|
39
|
+
import {
|
|
40
|
+
applySubagentsSchema,
|
|
41
|
+
getSubagent,
|
|
42
|
+
getSubagentByJsonlId,
|
|
43
|
+
resolveSubagentOriginTurnKey,
|
|
44
|
+
} from '../registry/subagents-schema.js'
|
|
45
|
+
import { resolveWorkerFeedDispatch } from '../gateway/worker-feed-dispatch.js'
|
|
46
|
+
|
|
47
|
+
const OWNER_DM = 'owner-dm-fallback'
|
|
48
|
+
const ORIGIN_CHAT = '-1001234567890'
|
|
49
|
+
const ORIGIN_TURN_KEY = `${ORIGIN_CHAT}:_:1783202199395`
|
|
50
|
+
|
|
51
|
+
function jline(o: object): string {
|
|
52
|
+
return JSON.stringify(o) + '\n'
|
|
53
|
+
}
|
|
54
|
+
function userMsg(text: string): string {
|
|
55
|
+
return jline({ type: 'user', message: { content: [{ type: 'text', text }] } })
|
|
56
|
+
}
|
|
57
|
+
function toolUse(id: string, name: string, input: Record<string, unknown>): string {
|
|
58
|
+
return jline({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name, input }] } })
|
|
59
|
+
}
|
|
60
|
+
function turnEnd(): string {
|
|
61
|
+
return jline({ type: 'system', subtype: 'turn_duration', durationMs: 1234 })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface FakeBot {
|
|
65
|
+
sent: Array<{ chatId: string; text: string }>
|
|
66
|
+
edits: Array<{ chatId: string; messageId: number; text: string }>
|
|
67
|
+
sendMessage: (chatId: string, text: string) => Promise<{ message_id: number }>
|
|
68
|
+
editMessageText: (chatId: string, messageId: number, text: string) => Promise<unknown>
|
|
69
|
+
}
|
|
70
|
+
function makeFakeBot(): FakeBot {
|
|
71
|
+
let nextId = 7000
|
|
72
|
+
const fb: FakeBot = {
|
|
73
|
+
sent: [],
|
|
74
|
+
edits: [],
|
|
75
|
+
sendMessage: async (chatId, text) => {
|
|
76
|
+
fb.sent.push({ chatId, text })
|
|
77
|
+
return { message_id: nextId++ }
|
|
78
|
+
},
|
|
79
|
+
editMessageText: async (chatId, messageId, text) => {
|
|
80
|
+
fb.edits.push({ chatId, messageId, text })
|
|
81
|
+
return {}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return fb
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const flush = async (): Promise<void> => {
|
|
88
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let cleanupDirs: string[] = []
|
|
92
|
+
let stoppers: Array<() => void> = []
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
for (const s of stoppers) { try { s() } catch { /* ignore */ } }
|
|
95
|
+
stoppers = []
|
|
96
|
+
for (const d of cleanupDirs) { try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ } }
|
|
97
|
+
cleanupDirs = []
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
function makeHarness() {
|
|
101
|
+
const agentDir = mkdtempSync(join(tmpdir(), 'nested-harness-'))
|
|
102
|
+
cleanupDirs.push(agentDir)
|
|
103
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'proj', 'sess-1', 'subagents')
|
|
104
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
105
|
+
|
|
106
|
+
const db = openTurnsDbInMemory()
|
|
107
|
+
applySubagentsSchema(db)
|
|
108
|
+
// The originating turn — ENDED before the nested dispatch happens.
|
|
109
|
+
db.prepare(`
|
|
110
|
+
INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
|
|
111
|
+
VALUES (?, ?, NULL, 500, 900, 500, 900)
|
|
112
|
+
`).run(ORIGIN_TURN_KEY, ORIGIN_CHAT)
|
|
113
|
+
// Depth-1 background worker row — stamped by the pretool hook while the
|
|
114
|
+
// main turn was live (the mechanism PRs #2085/#2075 already fixed).
|
|
115
|
+
db.prepare(`
|
|
116
|
+
INSERT INTO subagents
|
|
117
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
118
|
+
background, started_at, last_activity_at, status, jsonl_agent_id, parent_agent_id)
|
|
119
|
+
VALUES ('toolu_parent', 'sess-1', ?, 'worker', 'depth-1 orchestrator', 1, 800, 800, 'running', NULL, NULL)
|
|
120
|
+
`).run(ORIGIN_TURN_KEY)
|
|
121
|
+
|
|
122
|
+
let currentTime = 1000
|
|
123
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
124
|
+
let nextRef = 1
|
|
125
|
+
const sharedSetInterval = (fn: () => void, ms: number): unknown => {
|
|
126
|
+
const ref = nextRef++
|
|
127
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
128
|
+
return { ref }
|
|
129
|
+
}
|
|
130
|
+
const sharedClearInterval = (h: unknown): void => {
|
|
131
|
+
const { ref } = h as { ref: number }
|
|
132
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
133
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
134
|
+
}
|
|
135
|
+
const sharedSetTimeout = sharedSetInterval
|
|
136
|
+
const sharedClearTimeout = sharedClearInterval
|
|
137
|
+
|
|
138
|
+
const bot = makeFakeBot()
|
|
139
|
+
const feed = createWorkerActivityFeed({
|
|
140
|
+
bot,
|
|
141
|
+
now: () => currentTime,
|
|
142
|
+
firstPaintMinMs: 0, // paint immediately — routing/content is under test, not paint gating
|
|
143
|
+
minEditIntervalMs: 0,
|
|
144
|
+
heartbeatTickMs: 6000,
|
|
145
|
+
setInterval: sharedSetInterval,
|
|
146
|
+
clearInterval: sharedClearInterval,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
150
|
+
|
|
151
|
+
// Mirror the gateway's routing: registry-derived dispatch + transitive
|
|
152
|
+
// origin resolution, worker feed for background OR nested, orphan-DM
|
|
153
|
+
// fallback otherwise.
|
|
154
|
+
const routeChat = (agentId: string): { chatId: string } => {
|
|
155
|
+
const key = resolveSubagentOriginTurnKey(db, agentId)
|
|
156
|
+
if (key == null) return { chatId: OWNER_DM }
|
|
157
|
+
const turn = db.prepare('SELECT chat_id FROM turns WHERE turn_key = ?').get(key) as { chat_id: string } | null
|
|
158
|
+
return { chatId: turn?.chat_id ?? OWNER_DM }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const watcher = startSubagentWatcher({
|
|
162
|
+
agentDir,
|
|
163
|
+
db,
|
|
164
|
+
rescanMs: 500,
|
|
165
|
+
stallThresholdMs: 60_000,
|
|
166
|
+
silentSynthesisStallThresholdMs: 300_000,
|
|
167
|
+
silentStallTerminalMs: 300_000,
|
|
168
|
+
now: () => currentTime,
|
|
169
|
+
setInterval: sharedSetInterval,
|
|
170
|
+
clearInterval: sharedClearInterval,
|
|
171
|
+
setTimeout: sharedSetTimeout,
|
|
172
|
+
clearTimeout: sharedClearTimeout,
|
|
173
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, lastTool, toolCount, progressLine }) => {
|
|
174
|
+
const dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(db, agentId), description)
|
|
175
|
+
const isWorkerSurface = dispatch.isBackground || dispatch.isNested || !dispatch.hasRow
|
|
176
|
+
if (!isWorkerSurface) return // foreground-nest path — out of scope here
|
|
177
|
+
const { chatId } = routeChat(agentId)
|
|
178
|
+
const view: WorkerActivityView = {
|
|
179
|
+
description: dispatch.feedDescription,
|
|
180
|
+
lastTool,
|
|
181
|
+
toolCount,
|
|
182
|
+
latestSummary: progressLine != null && progressLine.length > 0 ? progressLine : latestSummary,
|
|
183
|
+
elapsedMs,
|
|
184
|
+
state: 'running',
|
|
185
|
+
}
|
|
186
|
+
void feed.update(agentId, chatId, view)
|
|
187
|
+
},
|
|
188
|
+
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
|
|
189
|
+
finishCalls.push({ agentId, outcome })
|
|
190
|
+
const dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(db, agentId), description)
|
|
191
|
+
void feed.finish(agentId, {
|
|
192
|
+
description: dispatch.feedDescription,
|
|
193
|
+
lastTool: null,
|
|
194
|
+
toolCount,
|
|
195
|
+
latestSummary: resultText,
|
|
196
|
+
elapsedMs: durationMs,
|
|
197
|
+
state: outcome === 'failed' ? 'failed' : 'done',
|
|
198
|
+
})
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
stoppers.push(() => watcher.stop(), () => feed.stop())
|
|
202
|
+
|
|
203
|
+
const advance = (ms: number): void => {
|
|
204
|
+
currentTime += ms
|
|
205
|
+
for (;;) {
|
|
206
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
207
|
+
const next = intervals[0]
|
|
208
|
+
if (!next || next.fireAt > currentTime) break
|
|
209
|
+
next.fireAt += next.ms
|
|
210
|
+
next.fn()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const writeWorker = (stem: string, toolUseId: string, description: string, firstLines: string): void => {
|
|
215
|
+
writeFileSync(join(subagentsDir, `agent-${stem}.meta.json`), JSON.stringify({
|
|
216
|
+
agentType: 'general-purpose', description, toolUseId,
|
|
217
|
+
}))
|
|
218
|
+
writeFileSync(join(subagentsDir, `agent-${stem}.jsonl`), firstLines)
|
|
219
|
+
}
|
|
220
|
+
const appendWorker = (stem: string, lines: string): void => {
|
|
221
|
+
appendFileSync(join(subagentsDir, `agent-${stem}.jsonl`), lines)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { db, bot, feed, watcher, advance, writeWorker, appendWorker, finishCalls }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe('nested (depth-2+) worker — end-to-end visibility harness', () => {
|
|
228
|
+
it('records the row, shows live tool activity, routes to the origin chat, and finalizes', async () => {
|
|
229
|
+
const h = makeHarness()
|
|
230
|
+
h.advance(500) // boot scan (empty dir)
|
|
231
|
+
|
|
232
|
+
// Depth-1 parent JSONL appears (post-boot → live) and immediately
|
|
233
|
+
// dispatches a NESTED worker. The main turn ended at t=900 — there is no
|
|
234
|
+
// turn-active marker, and the hook "lost" the child row entirely (we
|
|
235
|
+
// never insert it): the exact frozen-card incident shape.
|
|
236
|
+
h.writeWorker('parent01', 'toolu_parent', 'depth-1 orchestrator',
|
|
237
|
+
userMsg('orchestrate the nested probe')
|
|
238
|
+
+ toolUse('toolu_child', 'Task', {
|
|
239
|
+
description: 'nested probe', subagent_type: 'general-purpose', run_in_background: false,
|
|
240
|
+
}))
|
|
241
|
+
h.advance(500)
|
|
242
|
+
await flush()
|
|
243
|
+
|
|
244
|
+
// (a) The watcher recorded the nested dispatch from the parent's JSONL.
|
|
245
|
+
const childRow = getSubagent(h.db, 'toolu_child')
|
|
246
|
+
expect(childRow).not.toBeNull()
|
|
247
|
+
expect(childRow?.parent_agent_id).toBe('parent01')
|
|
248
|
+
expect(childRow?.parent_turn_key).toBe(ORIGIN_TURN_KEY)
|
|
249
|
+
|
|
250
|
+
// The child JSONL appears and runs REAL tools (no prose).
|
|
251
|
+
h.writeWorker('child01', 'toolu_child', 'nested probe',
|
|
252
|
+
userMsg('probe the nested structure')
|
|
253
|
+
+ toolUse('t1', 'Read', { file_path: '/repo/src/index.ts' }))
|
|
254
|
+
h.advance(500)
|
|
255
|
+
await flush()
|
|
256
|
+
|
|
257
|
+
// Linked + transitively attributed.
|
|
258
|
+
expect(getSubagent(h.db, 'toolu_child')?.jsonl_agent_id).toBe('child01')
|
|
259
|
+
expect(resolveSubagentOriginTurnKey(h.db, 'child01')).toBe(ORIGIN_TURN_KEY)
|
|
260
|
+
|
|
261
|
+
// (b)+(c) The nested card painted in the ORIGINATING chat with a REAL
|
|
262
|
+
// tool step — not the owner DM, not frozen "starting…".
|
|
263
|
+
const childMsgs = [...h.bot.sent, ...h.bot.edits].filter((m) => m.text.includes('nested probe'))
|
|
264
|
+
expect(childMsgs.length).toBeGreaterThanOrEqual(1)
|
|
265
|
+
for (const m of childMsgs) expect(m.chatId).toBe(ORIGIN_CHAT)
|
|
266
|
+
const lastChild = childMsgs[childMsgs.length - 1]!
|
|
267
|
+
expect(lastChild.text).not.toContain('starting…')
|
|
268
|
+
expect(lastChild.text).toContain('index.ts')
|
|
269
|
+
|
|
270
|
+
// More tool activity → climbing tool count, still live.
|
|
271
|
+
h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'ls -la /repo' }))
|
|
272
|
+
h.advance(1000)
|
|
273
|
+
await flush()
|
|
274
|
+
const afterSecondTool = [...h.bot.edits].filter((m) => m.text.includes('nested probe')).pop()
|
|
275
|
+
expect(afterSecondTool?.text).toContain('2 tools')
|
|
276
|
+
|
|
277
|
+
// (e) Parallel isolation: the parent's own card (if painted) never
|
|
278
|
+
// absorbed the child's steps, and vice versa.
|
|
279
|
+
const parentMsgs = [...h.bot.sent, ...h.bot.edits].filter((m) => m.text.includes('depth-1 orchestrator'))
|
|
280
|
+
for (const m of parentMsgs) {
|
|
281
|
+
expect(m.text).not.toContain('index.ts')
|
|
282
|
+
}
|
|
283
|
+
for (const m of [...h.bot.sent, ...h.bot.edits].filter((x) => x.text.includes('nested probe'))) {
|
|
284
|
+
expect(m.text).not.toContain('orchestrate the nested probe')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// (d) turn_end → clean finalize: the card edits to done and the feed
|
|
288
|
+
// handle is dropped (never heartbeated frozen forever).
|
|
289
|
+
h.appendWorker('child01', turnEnd())
|
|
290
|
+
h.advance(1000)
|
|
291
|
+
await flush()
|
|
292
|
+
expect(h.finishCalls.some((f) => f.agentId === 'child01')).toBe(true)
|
|
293
|
+
const finalEdit = h.bot.edits.filter((m) => m.text.includes('nested probe')).pop()
|
|
294
|
+
expect(finalEdit?.text).toContain('done')
|
|
295
|
+
expect(h.feed.messageIdOf('child01')).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('links late (backfill retry) when the child JSONL appears BEFORE the parent dispatch line is read', async () => {
|
|
299
|
+
const h = makeHarness()
|
|
300
|
+
h.advance(500) // boot scan
|
|
301
|
+
|
|
302
|
+
// Child JSONL first — registration backfill finds NO row ("Phase 2 Pre
|
|
303
|
+
// hook pending", the 2-minute freeze window from the live gateway log).
|
|
304
|
+
h.writeWorker('child01', 'toolu_child', 'nested probe',
|
|
305
|
+
userMsg('probe') + toolUse('t1', 'Read', { file_path: '/repo/a.ts' }))
|
|
306
|
+
h.advance(500)
|
|
307
|
+
await flush()
|
|
308
|
+
expect(getSubagent(h.db, 'toolu_child')).toBeNull()
|
|
309
|
+
|
|
310
|
+
// Parent's dispatch line lands → watcher records the child row.
|
|
311
|
+
h.writeWorker('parent01', 'toolu_parent', 'depth-1 orchestrator',
|
|
312
|
+
userMsg('orchestrate')
|
|
313
|
+
+ toolUse('toolu_child', 'Task', { description: 'nested probe', run_in_background: false }))
|
|
314
|
+
h.advance(500)
|
|
315
|
+
await flush()
|
|
316
|
+
expect(getSubagent(h.db, 'toolu_child')).not.toBeNull()
|
|
317
|
+
|
|
318
|
+
// The child's JSONL grows → the liveness-path backfill RETRY links it
|
|
319
|
+
// (registration's one-shot backfill already missed).
|
|
320
|
+
h.advance(3000) // past BACKFILL_RETRY_INTERVAL_MS
|
|
321
|
+
h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'pwd' }))
|
|
322
|
+
h.advance(1000)
|
|
323
|
+
await flush()
|
|
324
|
+
const row = getSubagent(h.db, 'toolu_child')
|
|
325
|
+
expect(row?.jsonl_agent_id).toBe('child01')
|
|
326
|
+
expect(row?.parent_turn_key).toBe(ORIGIN_TURN_KEY)
|
|
327
|
+
expect(resolveSubagentOriginTurnKey(h.db, 'child01')).toBe(ORIGIN_TURN_KEY)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -95,6 +95,59 @@ describe('pending-inbound-buffer', () => {
|
|
|
95
95
|
expect(drained.map((m) => m.meta?.source)).toEqual(['m3', 'm4', 'm5'])
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
// #2789 A: a >cap burst mid-turn used to evict the oldest SILENTLY —
|
|
99
|
+
// the durable spool copy only replays at boot / escalates after 15 min,
|
|
100
|
+
// so within a live session the evicted message was just gone with no
|
|
101
|
+
// user-visible signal. onEvict makes the eviction non-silent so the
|
|
102
|
+
// caller can surface a coalesced "messages deferred" notice.
|
|
103
|
+
it('#2789 A: fires onEvict with the evicted message on cap eviction (not a silent drop)', () => {
|
|
104
|
+
const evicted: InboundMessage[] = []
|
|
105
|
+
const buf = createPendingInboundBuffer({
|
|
106
|
+
capPerAgent: 3,
|
|
107
|
+
log: () => {},
|
|
108
|
+
onEvict: (_agent, m) => evicted.push(m),
|
|
109
|
+
})
|
|
110
|
+
// Fill to cap — no eviction yet, no notice.
|
|
111
|
+
buf.push('a', inbound('m1', 1))
|
|
112
|
+
buf.push('a', inbound('m2', 2))
|
|
113
|
+
buf.push('a', inbound('m3', 3))
|
|
114
|
+
expect(evicted).toHaveLength(0)
|
|
115
|
+
// The 4th push overflows the cap → oldest (m1) evicted → onEvict fires.
|
|
116
|
+
buf.push('a', inbound('m4', 4))
|
|
117
|
+
expect(evicted.map((m) => m.meta?.source)).toEqual(['m1'])
|
|
118
|
+
// The 5th evicts m2.
|
|
119
|
+
buf.push('a', inbound('m5', 5))
|
|
120
|
+
expect(evicted.map((m) => m.meta?.source)).toEqual(['m1', 'm2'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('#2789 A: a >32 burst produces one onEvict per evicted entry, never a silent drop', () => {
|
|
124
|
+
const evicted: InboundMessage[] = []
|
|
125
|
+
const buf = createPendingInboundBuffer({
|
|
126
|
+
log: () => {}, // default cap = 32
|
|
127
|
+
onEvict: (_agent, m) => evicted.push(m),
|
|
128
|
+
})
|
|
129
|
+
// 40 messages into a 32-cap buffer → exactly 8 evictions, all reported.
|
|
130
|
+
for (let i = 1; i <= 40; i++) buf.push('a', inbound(`m${i}`, i))
|
|
131
|
+
expect(buf.depth('a')).toBe(32)
|
|
132
|
+
expect(evicted).toHaveLength(8)
|
|
133
|
+
expect(evicted.map((m) => m.meta?.source)).toEqual([
|
|
134
|
+
'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8',
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('#2789 A: a throwing onEvict never breaks the push hot path', () => {
|
|
139
|
+
const buf = createPendingInboundBuffer({
|
|
140
|
+
capPerAgent: 1,
|
|
141
|
+
log: () => {},
|
|
142
|
+
onEvict: () => {
|
|
143
|
+
throw new Error('notice failed')
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
buf.push('a', inbound('m1', 1))
|
|
147
|
+
expect(() => buf.push('a', inbound('m2', 2))).not.toThrow()
|
|
148
|
+
expect(buf.depth('a')).toBe(1)
|
|
149
|
+
})
|
|
150
|
+
|
|
98
151
|
it('push returns false when eviction occurred', () => {
|
|
99
152
|
const buf = createPendingInboundBuffer({ capPerAgent: 2, log: () => {} })
|
|
100
153
|
expect(buf.push('a', inbound('m1'))).toBe(true)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { redact } from '../secret-detect/redact.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Behavioral coverage for the progress_update outbound secret-scrub gap.
|
|
6
|
+
*
|
|
7
|
+
* progress_update was the ONLY send site in gateway.ts that did not call
|
|
8
|
+
* `redactOutboundText` — so a secret the agent echoed into a progress line
|
|
9
|
+
* reached Telegram (and history) unmasked, while reply / stream_reply /
|
|
10
|
+
* edit_message / turn_flush all redact. The fix adds
|
|
11
|
+
* `redactOutboundText(text, 'progress_update')` — and, critically, places it
|
|
12
|
+
* BEFORE the 300-char truncation the tool applies. Ordering matters: if the
|
|
13
|
+
* truncation ran first, a secret straddling the 300-char cut would be sliced
|
|
14
|
+
* into a partial token the shape detector no longer matches, leaking the
|
|
15
|
+
* visible bytes.
|
|
16
|
+
*
|
|
17
|
+
* These tests reproduce the gateway's `executeProgressUpdate` send prep — the
|
|
18
|
+
* exact `redact` engine used in production, in the same order the fix uses
|
|
19
|
+
* (redact → truncate) — and pin both the ordinary mask and the
|
|
20
|
+
* straddling-the-boundary case, plus a contrast showing the WRONG order leaks.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Build the token fixture by concatenation so the source file never contains a
|
|
24
|
+
// contiguous secret-shaped literal (repo Push Protection / no-pii lint).
|
|
25
|
+
const ID = '17'
|
|
26
|
+
const BODY40 = 'aB3dE6fH9j'.repeat(4) // 40 base62 chars — Sanctum's Str::random(40)
|
|
27
|
+
const SANCTUM = `${ID}|${BODY40}` // e.g. 17|aB3dE6fH9j…
|
|
28
|
+
|
|
29
|
+
/** The production send-prep order: redact the FULL text, THEN truncate. */
|
|
30
|
+
function progressSendPrep(text: string): string {
|
|
31
|
+
let out = redact(text)
|
|
32
|
+
if (out.length > 300) out = out.slice(0, 299) + '…'
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The buggy order the fix guards against: truncate first, then redact. */
|
|
37
|
+
function truncateFirstThenRedact(text: string): string {
|
|
38
|
+
let out = text
|
|
39
|
+
if (out.length > 300) out = out.slice(0, 299) + '…'
|
|
40
|
+
return redact(out)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('progress_update outbound secret-scrub', () => {
|
|
44
|
+
it('masks a secret-shaped token in an ordinary (short) progress line', () => {
|
|
45
|
+
const out = progressSendPrep(`Deploying with token ${SANCTUM} now — hold tight`)
|
|
46
|
+
expect(out).not.toContain(SANCTUM)
|
|
47
|
+
expect(out).not.toContain(BODY40)
|
|
48
|
+
expect(out).toContain('[REDACTED:laravel_sanctum_token]')
|
|
49
|
+
// Surrounding prose survives.
|
|
50
|
+
expect(out).toContain('Deploying with token')
|
|
51
|
+
expect(out).toContain('hold tight')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('masks a secret that STRADDLES the 300-char truncation boundary', () => {
|
|
55
|
+
// Pad so the token spans char 300: 286 chars of prose, a space, then the
|
|
56
|
+
// 43-char token. The 299/300 cut lands mid-token.
|
|
57
|
+
const pad = 'progress: still working on the deploy, '.repeat(8).slice(0, 286)
|
|
58
|
+
const text = `${pad} ${SANCTUM} and continuing after the token here`
|
|
59
|
+
// Sanity: the token really does straddle the cut point.
|
|
60
|
+
const cut = 299
|
|
61
|
+
expect(text.indexOf(SANCTUM)).toBeLessThan(cut)
|
|
62
|
+
expect(text.indexOf(SANCTUM) + SANCTUM.length).toBeGreaterThan(cut)
|
|
63
|
+
|
|
64
|
+
const out = progressSendPrep(text)
|
|
65
|
+
// Because redact runs on the FULL text first, the whole token is masked
|
|
66
|
+
// before any slice — no secret bytes reach the wire. (The mask MARKER
|
|
67
|
+
// itself may fall across the 300-char cut and be truncated — harmless,
|
|
68
|
+
// since it carries no secret — so assert the leading marker survives, not
|
|
69
|
+
// the full literal.)
|
|
70
|
+
expect(out).not.toContain(SANCTUM)
|
|
71
|
+
expect(out).not.toContain(BODY40)
|
|
72
|
+
expect(out).toContain('[REDACTED')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('proves the ordering matters: truncate-FIRST would leak the straddling secret', () => {
|
|
76
|
+
// A regression fence: if someone reorders the fix so truncation precedes
|
|
77
|
+
// redaction, the cut splits the token, the shape detector misses the
|
|
78
|
+
// fragment, and partial secret bytes survive. This asserts the wrong order
|
|
79
|
+
// leaks — the exact failure the production ordering prevents.
|
|
80
|
+
const pad = 'progress: still working on the deploy, '.repeat(8).slice(0, 286)
|
|
81
|
+
const text = `${pad} ${SANCTUM} and continuing after the token here`
|
|
82
|
+
|
|
83
|
+
const wrong = truncateFirstThenRedact(text)
|
|
84
|
+
// The leading fragment of the token body (pre-cut) survives unmasked.
|
|
85
|
+
const leakedPrefix = BODY40.slice(0, 6)
|
|
86
|
+
expect(wrong).toContain(leakedPrefix)
|
|
87
|
+
|
|
88
|
+
// The correct order masks it — the fragment does NOT survive.
|
|
89
|
+
const right = progressSendPrep(text)
|
|
90
|
+
expect(right).not.toContain(leakedPrefix)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('idempotent: a masked progress line has no secret bytes on a second pass', () => {
|
|
94
|
+
const once = progressSendPrep(`token ${SANCTUM}`)
|
|
95
|
+
const twice = progressSendPrep(once)
|
|
96
|
+
expect(twice).not.toContain(SANCTUM)
|
|
97
|
+
expect(twice).not.toContain(BODY40)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
findOrphanedTurns,
|
|
20
20
|
markOrphanedWithTimeoutClassification,
|
|
21
21
|
findLatestTurnIfInterrupted,
|
|
22
|
+
markTurnResumed,
|
|
23
|
+
getTurnByKey,
|
|
22
24
|
} from '../registry/turns-schema.js'
|
|
23
25
|
|
|
24
26
|
// Convenience: the boot reaper with no live hang marker — every open turn
|
|
@@ -506,4 +508,69 @@ describe('findLatestTurnIfInterrupted', () => {
|
|
|
506
508
|
expect(findLatestTurnIfInterrupted(db)).toBeNull()
|
|
507
509
|
db.close()
|
|
508
510
|
})
|
|
511
|
+
|
|
512
|
+
// ── #2793 part A: resume is at-most-once via the `resumed_at` ledger ──
|
|
513
|
+
// Repro: without the ledger, an interrupted turn whose resume ran its
|
|
514
|
+
// side effects but never reached a clean 'stop' (process died before the
|
|
515
|
+
// follow-up turn wrote ended_at, or the resume inbound was accepted but
|
|
516
|
+
// never consumed) stays "latest + not clean" — so every subsequent boot
|
|
517
|
+
// re-mints a fresh resume and DOUBLE-EXECUTES. The ledger makes it null
|
|
518
|
+
// after the first commit.
|
|
519
|
+
it('re-fires an interrupted turn until it is stamped resumed (double-exec repro)', () => {
|
|
520
|
+
const db = openTurnsDbInMemory()
|
|
521
|
+
recordTurnStart(db, { turnKey: 'res:1', chatId: 'res' })
|
|
522
|
+
recordTurnEnd(db, { turnKey: 'res:1', endedVia: 'restart' })
|
|
523
|
+
// Boot 1: the turn is interrupted and still unresumed → it is returned
|
|
524
|
+
// (the gateway will mint a resume for it).
|
|
525
|
+
const first = findLatestTurnIfInterrupted(db)
|
|
526
|
+
expect(first).not.toBeNull()
|
|
527
|
+
expect(first!.turn_key).toBe('res:1')
|
|
528
|
+
expect(first!.resumed_at).toBeNull()
|
|
529
|
+
// Gateway durably queues the resume, then stamps the ledger.
|
|
530
|
+
markTurnResumed(db, 'res:1', 1_700_000_000_000)
|
|
531
|
+
// Boot 2 (crash happened after side effects, turn still 'restart' with
|
|
532
|
+
// no clean follow-up): the SAME turn must NOT be resumed again.
|
|
533
|
+
expect(findLatestTurnIfInterrupted(db)).toBeNull()
|
|
534
|
+
db.close()
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('does not re-fire even an OPEN (ended_at IS NULL) turn once resumed', () => {
|
|
538
|
+
const db = openTurnsDbInMemory()
|
|
539
|
+
recordTurnStart(db, { turnKey: 'res:2', chatId: 'res' })
|
|
540
|
+
// Still open (never reaped to a terminal ended_via), but already resumed
|
|
541
|
+
// once — the accept-but-never-consumed window. Must not re-fire.
|
|
542
|
+
markTurnResumed(db, 'res:2')
|
|
543
|
+
expect(findLatestTurnIfInterrupted(db)).toBeNull()
|
|
544
|
+
db.close()
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// markTurnResumed — the at-most-once resume ledger (#2793 part A)
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
describe('markTurnResumed', () => {
|
|
553
|
+
it('stamps resumed_at on the target turn', () => {
|
|
554
|
+
const db = openTurnsDbInMemory()
|
|
555
|
+
recordTurnStart(db, { turnKey: 'mk:1', chatId: 'mk' })
|
|
556
|
+
recordTurnEnd(db, { turnKey: 'mk:1', endedVia: 'restart' })
|
|
557
|
+
markTurnResumed(db, 'mk:1', 1_700_000_000_000)
|
|
558
|
+
expect(getTurnByKey(db, 'mk:1')!.resumed_at).toBe(1_700_000_000_000)
|
|
559
|
+
db.close()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('is first-write-wins: a second stamp does not overwrite the first', () => {
|
|
563
|
+
const db = openTurnsDbInMemory()
|
|
564
|
+
recordTurnStart(db, { turnKey: 'mk:2', chatId: 'mk' })
|
|
565
|
+
markTurnResumed(db, 'mk:2', 1_700_000_000_000)
|
|
566
|
+
markTurnResumed(db, 'mk:2', 1_800_000_000_000)
|
|
567
|
+
expect(getTurnByKey(db, 'mk:2')!.resumed_at).toBe(1_700_000_000_000)
|
|
568
|
+
db.close()
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('no-ops for an unknown turn_key', () => {
|
|
572
|
+
const db = openTurnsDbInMemory()
|
|
573
|
+
expect(() => markTurnResumed(db, 'nope:1')).not.toThrow()
|
|
574
|
+
db.close()
|
|
575
|
+
})
|
|
509
576
|
})
|
|
@@ -37,14 +37,14 @@ describe("shouldSuppressRepresent — #2472 duplicate-represent guard", () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it("does NOT suppress the FIRST represent — genuine plain-text-no-reply still represents once", () => {
|
|
40
|
-
// First represent: lastRepresentedAt is undefined
|
|
41
|
-
//
|
|
42
|
-
// re-ask must still fire
|
|
43
|
-
const o = obligation({ lastRepresentedAt: undefined });
|
|
40
|
+
// First represent: lastRepresentedAt is undefined, no reply tool call was
|
|
41
|
+
// ever recorded (the genuine plain-text-no-reply case → no outbound row, so
|
|
42
|
+
// the predicate reports false). The single re-ask must still fire.
|
|
43
|
+
const o = obligation({ openedAt: 0, lastRepresentedAt: undefined });
|
|
44
44
|
const suppress = shouldSuppressRepresent(o, {
|
|
45
45
|
historyEnabled: true,
|
|
46
|
-
//
|
|
47
|
-
hasOutboundDeliveredSince: () =>
|
|
46
|
+
// No outbound row exists for a plain-text answer → predicate is false.
|
|
47
|
+
hasOutboundDeliveredSince: () => false,
|
|
48
48
|
});
|
|
49
49
|
expect(suppress).toBe(false); // represent fires exactly once
|
|
50
50
|
});
|
|
@@ -70,6 +70,42 @@ describe("shouldSuppressRepresent — #2472 duplicate-represent guard", () => {
|
|
|
70
70
|
expect(suppress).toBe(false);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it("#2788 Gap B — SUPPRESSES the FIRST represent when a genuine reply was delivered since openedAt", () => {
|
|
74
|
+
// The narrow false "you never answered" window: a real reply landed at
|
|
75
|
+
// t=1500 after the obligation was raised at t=1000, but its routing didn't
|
|
76
|
+
// resolve back to the origin so the ledger's close path missed it. The FIRST
|
|
77
|
+
// represent must now dedup against outbound history (cutoff = openedAt) and
|
|
78
|
+
// suppress, instead of emitting a false "you never answered".
|
|
79
|
+
const o = obligation({ openedAt: 1000, lastRepresentedAt: undefined });
|
|
80
|
+
const suppress = shouldSuppressRepresent(o, {
|
|
81
|
+
historyEnabled: true,
|
|
82
|
+
hasOutboundDeliveredSince: replyDeliveredAt(1500),
|
|
83
|
+
});
|
|
84
|
+
expect(suppress).toBe(true); // first represent deduped → no false "you never answered"
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("#2788 Gap B — first represent still fires when the only reply PREDATES openedAt", () => {
|
|
88
|
+
// A reply at t=500 answered an EARLIER turn, before this obligation was
|
|
89
|
+
// raised at t=1000. It is not evidence THIS obligation was answered → the
|
|
90
|
+
// first represent must still fire.
|
|
91
|
+
const o = obligation({ openedAt: 1000, lastRepresentedAt: undefined });
|
|
92
|
+
const suppress = shouldSuppressRepresent(o, {
|
|
93
|
+
historyEnabled: true,
|
|
94
|
+
hasOutboundDeliveredSince: replyDeliveredAt(500),
|
|
95
|
+
});
|
|
96
|
+
expect(suppress).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("#2788 Gap B — first represent falls back to firing when openedAt is unknown", () => {
|
|
100
|
+
// Without an openedAt cutoff we cannot dedup safely — never suppress on doubt.
|
|
101
|
+
const o = obligation({ openedAt: undefined, lastRepresentedAt: undefined });
|
|
102
|
+
const suppress = shouldSuppressRepresent(o, {
|
|
103
|
+
historyEnabled: true,
|
|
104
|
+
hasOutboundDeliveredSince: () => true,
|
|
105
|
+
});
|
|
106
|
+
expect(suppress).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
73
109
|
it("never suppresses when history is unavailable (safe: re-ask rather than silently drop)", () => {
|
|
74
110
|
const o = obligation({ lastRepresentedAt: 1000 });
|
|
75
111
|
const suppress = shouldSuppressRepresent(o, {
|
|
@@ -509,7 +509,16 @@ describe('projectSubagentLine', () => {
|
|
|
509
509
|
// rendering."
|
|
510
510
|
expect(events.length).toBe(2)
|
|
511
511
|
expect(events[0].kind).toBe('sub_agent_tool_use')
|
|
512
|
-
|
|
512
|
+
// The nested_spawn now ALSO carries the dispatch toolUseId + input so
|
|
513
|
+
// the watcher can key the nested worker's registry row
|
|
514
|
+
// (recordNestedSubagentDispatch — the depth-2+ card fix). Rendering is
|
|
515
|
+
// unchanged: still no sub_agent_tool_use for the nested Agent.
|
|
516
|
+
expect(events[1]).toEqual({
|
|
517
|
+
kind: 'sub_agent_nested_spawn',
|
|
518
|
+
agentId: 'X',
|
|
519
|
+
toolUseId: 'toolu_b',
|
|
520
|
+
input: { description: 'nested', prompt: 'nested-p' },
|
|
521
|
+
})
|
|
513
522
|
})
|
|
514
523
|
|
|
515
524
|
it('emits sub_agent_text + sub_agent_tool_use in source order for [text, tool_use]', () => {
|