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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested (depth-2+) sub-agent keying — registry primitives.
|
|
3
|
+
*
|
|
4
|
+
* Pins the three mechanisms that fix the "nested worker card frozen on
|
|
5
|
+
* starting…" class:
|
|
6
|
+
* 1. `recordNestedSubagentDispatch` — the watcher records/repairs a nested
|
|
7
|
+
* dispatch row from the PARENT worker's JSONL (the authoritative
|
|
8
|
+
* dispatch record), because the PreToolUse hook cannot attribute it
|
|
9
|
+
* (turn-active.json is gone once the main turn ends) and can lose the
|
|
10
|
+
* write entirely under concurrent nested dispatch.
|
|
11
|
+
* 2. `resolveSubagentOriginTurnKey` — transitive origin resolution via the
|
|
12
|
+
* `parent_agent_id` chain, so a nested worker's card + handback route to
|
|
13
|
+
* the originating chat/topic instead of the owner DM.
|
|
14
|
+
* 3. `backfillJsonlAgentId` — inherits `parent_turn_key` from the nested-
|
|
15
|
+
* parent chain BEFORE falling back to the (never-correct-for-nested)
|
|
16
|
+
* turns time-window match.
|
|
17
|
+
*
|
|
18
|
+
* bun:sqlite — run under Bun:
|
|
19
|
+
* bun test telegram-plugin/tests/subagent-nested-dispatch.test.ts
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
23
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
24
|
+
import { tmpdir } from 'os'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
import { openTurnsDbInMemory } from '../registry/turns-schema.js'
|
|
27
|
+
import {
|
|
28
|
+
applySubagentsSchema,
|
|
29
|
+
recordNestedSubagentDispatch,
|
|
30
|
+
resolveSubagentOriginTurnKey,
|
|
31
|
+
getSubagent,
|
|
32
|
+
} from '../registry/subagents-schema.js'
|
|
33
|
+
import { backfillJsonlAgentId } from '../subagent-watcher.js'
|
|
34
|
+
|
|
35
|
+
type Db = ReturnType<typeof openTurnsDbInMemory>
|
|
36
|
+
|
|
37
|
+
let tempDir: string
|
|
38
|
+
let db: Db
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
tempDir = mkdtempSync(join(tmpdir(), 'sub-nested-'))
|
|
42
|
+
db = openTurnsDbInMemory()
|
|
43
|
+
applySubagentsSchema(db)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
try { db.close() } catch { /* ignore */ }
|
|
48
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function insertTurn(turnKey: string, chatId: string, startedAt: number, endedAt: number | null) {
|
|
52
|
+
db.prepare(`
|
|
53
|
+
INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
|
|
54
|
+
VALUES (?, ?, NULL, ?, ?, ?, ?)
|
|
55
|
+
`).run(turnKey, chatId, startedAt, endedAt, startedAt, startedAt)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function insertSub(args: {
|
|
59
|
+
id: string
|
|
60
|
+
parentTurnKey?: string | null
|
|
61
|
+
jsonlAgentId?: string | null
|
|
62
|
+
parentAgentId?: string | null
|
|
63
|
+
background?: boolean
|
|
64
|
+
description?: string | null
|
|
65
|
+
startedAt?: number
|
|
66
|
+
}) {
|
|
67
|
+
db.prepare(`
|
|
68
|
+
INSERT INTO subagents
|
|
69
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
70
|
+
background, started_at, last_activity_at, status, jsonl_agent_id, parent_agent_id)
|
|
71
|
+
VALUES (?, 'sess-1', ?, 'worker', ?, ?, ?, ?, 'running', ?, ?)
|
|
72
|
+
`).run(
|
|
73
|
+
args.id,
|
|
74
|
+
args.parentTurnKey ?? null,
|
|
75
|
+
args.description ?? null,
|
|
76
|
+
args.background ? 1 : 0,
|
|
77
|
+
args.startedAt ?? 1000,
|
|
78
|
+
args.startedAt ?? 1000,
|
|
79
|
+
args.jsonlAgentId ?? null,
|
|
80
|
+
args.parentAgentId ?? null,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('recordNestedSubagentDispatch', () => {
|
|
85
|
+
it('creates the row when the hook lost the write (missing-row nested worker)', () => {
|
|
86
|
+
// Depth-1 parent: background worker, attributed at main-turn dispatch.
|
|
87
|
+
insertSub({ id: 'toolu_parent', parentTurnKey: '8248:_:1', jsonlAgentId: 'parent01', background: true })
|
|
88
|
+
|
|
89
|
+
recordNestedSubagentDispatch(db, {
|
|
90
|
+
toolUseId: 'toolu_child',
|
|
91
|
+
parentJsonlAgentId: 'parent01',
|
|
92
|
+
agentType: 'general-purpose',
|
|
93
|
+
description: 'nested probe',
|
|
94
|
+
background: false,
|
|
95
|
+
now: 5000,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const child = getSubagent(db, 'toolu_child')
|
|
99
|
+
expect(child).not.toBeNull()
|
|
100
|
+
expect(child?.parent_agent_id).toBe('parent01')
|
|
101
|
+
// Transitively inherited from the depth-1 row at record time.
|
|
102
|
+
expect(child?.parent_turn_key).toBe('8248:_:1')
|
|
103
|
+
expect(child?.description).toBe('nested probe')
|
|
104
|
+
expect(child?.status).toBe('running')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('repairs a hook-inserted row: stamps parent_agent_id + inherits NULL parent_turn_key', () => {
|
|
108
|
+
insertSub({ id: 'toolu_parent', parentTurnKey: 'turn-A', jsonlAgentId: 'parent01', background: true })
|
|
109
|
+
// The hook DID land the row, but with parent_turn_key NULL (marker gone)
|
|
110
|
+
// — the exact live-incident shape (sibling a18c6003… had an empty key).
|
|
111
|
+
insertSub({ id: 'toolu_child', parentTurnKey: null, description: 'hook desc' })
|
|
112
|
+
|
|
113
|
+
recordNestedSubagentDispatch(db, {
|
|
114
|
+
toolUseId: 'toolu_child',
|
|
115
|
+
parentJsonlAgentId: 'parent01',
|
|
116
|
+
description: 'watcher desc',
|
|
117
|
+
background: true,
|
|
118
|
+
now: 6000,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const child = getSubagent(db, 'toolu_child')
|
|
122
|
+
expect(child?.parent_agent_id).toBe('parent01')
|
|
123
|
+
expect(child?.parent_turn_key).toBe('turn-A')
|
|
124
|
+
// INSERT OR IGNORE — the hook row's own fields are preserved.
|
|
125
|
+
expect(child?.description).toBe('hook desc')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('is idempotent and never overwrites an existing parent_turn_key', () => {
|
|
129
|
+
insertSub({ id: 'toolu_parent', parentTurnKey: 'turn-A', jsonlAgentId: 'parent01', background: true })
|
|
130
|
+
insertSub({ id: 'toolu_child', parentTurnKey: 'turn-REAL' })
|
|
131
|
+
|
|
132
|
+
recordNestedSubagentDispatch(db, {
|
|
133
|
+
toolUseId: 'toolu_child', parentJsonlAgentId: 'parent01', background: false, now: 1,
|
|
134
|
+
})
|
|
135
|
+
recordNestedSubagentDispatch(db, {
|
|
136
|
+
toolUseId: 'toolu_child', parentJsonlAgentId: 'parent01', background: false, now: 2,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const child = getSubagent(db, 'toolu_child')
|
|
140
|
+
expect(child?.parent_turn_key).toBe('turn-REAL')
|
|
141
|
+
expect(child?.parent_agent_id).toBe('parent01')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('resolveSubagentOriginTurnKey — transitive origin walk', () => {
|
|
146
|
+
it('depth-1: returns the row own parent_turn_key', () => {
|
|
147
|
+
insertSub({ id: 't1', parentTurnKey: 'turn-A', jsonlAgentId: 'a1' })
|
|
148
|
+
expect(resolveSubagentOriginTurnKey(db, 'a1')).toBe('turn-A')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('depth-2: NULL own key resolves via the parent chain', () => {
|
|
152
|
+
insertSub({ id: 't1', parentTurnKey: 'turn-A', jsonlAgentId: 'a1' })
|
|
153
|
+
insertSub({ id: 't2', parentTurnKey: null, jsonlAgentId: 'a2', parentAgentId: 'a1' })
|
|
154
|
+
expect(resolveSubagentOriginTurnKey(db, 'a2')).toBe('turn-A')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('depth-3: walks multiple hops', () => {
|
|
158
|
+
insertSub({ id: 't1', parentTurnKey: 'turn-A', jsonlAgentId: 'a1' })
|
|
159
|
+
insertSub({ id: 't2', parentTurnKey: null, jsonlAgentId: 'a2', parentAgentId: 'a1' })
|
|
160
|
+
insertSub({ id: 't3', parentTurnKey: null, jsonlAgentId: 'a3', parentAgentId: 'a2' })
|
|
161
|
+
expect(resolveSubagentOriginTurnKey(db, 'a3')).toBe('turn-A')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('cycle-safe: a corrupt self/loop chain returns null instead of hanging', () => {
|
|
165
|
+
insertSub({ id: 't1', parentTurnKey: null, jsonlAgentId: 'a1', parentAgentId: 'a2' })
|
|
166
|
+
insertSub({ id: 't2', parentTurnKey: null, jsonlAgentId: 'a2', parentAgentId: 'a1' })
|
|
167
|
+
expect(resolveSubagentOriginTurnKey(db, 'a1')).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('missing row / unattributed chain returns null (caller keeps DM fallback)', () => {
|
|
171
|
+
expect(resolveSubagentOriginTurnKey(db, 'nope')).toBeNull()
|
|
172
|
+
insertSub({ id: 't1', parentTurnKey: null, jsonlAgentId: 'a1' })
|
|
173
|
+
expect(resolveSubagentOriginTurnKey(db, 'a1')).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('backfillJsonlAgentId — nested-parent inheritance beats the window match', () => {
|
|
178
|
+
function writeMeta(stem: string, toolUseId: string): string {
|
|
179
|
+
const jsonlPath = join(tempDir, `agent-${stem}.jsonl`)
|
|
180
|
+
writeFileSync(join(tempDir, `agent-${stem}.meta.json`), JSON.stringify({
|
|
181
|
+
agentType: 'general-purpose', description: 'nested probe', toolUseId,
|
|
182
|
+
}))
|
|
183
|
+
return jsonlPath
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it('inherits the ancestor turn key when the dispatch happened AFTER the main turn ended', () => {
|
|
187
|
+
// The originating turn ended at t=2000; an unrelated turn is open at
|
|
188
|
+
// child-dispatch time (t=5000) — the window match would MIS-attribute.
|
|
189
|
+
insertTurn('turn-ORIGIN', '8248', 1000, 2000)
|
|
190
|
+
insertTurn('turn-OTHER', '9999', 4000, null)
|
|
191
|
+
insertSub({ id: 'toolu_parent', parentTurnKey: 'turn-ORIGIN', jsonlAgentId: 'parent01', background: true })
|
|
192
|
+
// Watcher-recorded nested row (parent_agent_id set, key inherited=NULL
|
|
193
|
+
// here to force the backfill path to resolve it).
|
|
194
|
+
db.prepare(`
|
|
195
|
+
INSERT INTO subagents
|
|
196
|
+
(id, parent_session_id, parent_turn_key, agent_type, description,
|
|
197
|
+
background, started_at, last_activity_at, status, jsonl_agent_id, parent_agent_id)
|
|
198
|
+
VALUES ('toolu_child', NULL, NULL, 'general-purpose', 'nested probe', 0, 5000, 5000, 'running', NULL, 'parent01')
|
|
199
|
+
`).run()
|
|
200
|
+
|
|
201
|
+
const jsonlPath = writeMeta('child01', 'toolu_child')
|
|
202
|
+
backfillJsonlAgentId(db, jsonlPath, 'child01')
|
|
203
|
+
|
|
204
|
+
const child = getSubagent(db, 'toolu_child')
|
|
205
|
+
expect(child?.jsonl_agent_id).toBe('child01')
|
|
206
|
+
// Inherited from the parent chain — NOT 'turn-OTHER' from the open window.
|
|
207
|
+
expect(child?.parent_turn_key).toBe('turn-ORIGIN')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -563,3 +563,40 @@ describe('agent-dir resolution (RFC §Bug 2)', () => {
|
|
|
563
563
|
}
|
|
564
564
|
})
|
|
565
565
|
})
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// Concurrent nested dispatch — row-creation reliability (unified cards)
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
describe('pretool — concurrent dispatch reliability', () => {
|
|
572
|
+
it('N hook processes writing the same registry.db concurrently all land their rows', async () => {
|
|
573
|
+
// The live-incident failure mode: several sub-agents dispatch at once
|
|
574
|
+
// (a depth-1 worker fanning out nested workers), the hook processes
|
|
575
|
+
// contend on registry.db, and a loser's INSERT vanished with
|
|
576
|
+
// SQLITE_BUSY → 0 rows → the worker's card froze on "starting…".
|
|
577
|
+
// The busy_timeout arming + this pin keep that from regressing.
|
|
578
|
+
const N = 8
|
|
579
|
+
const { spawn } = require('child_process') as typeof import('child_process')
|
|
580
|
+
const runs = Array.from({ length: N }, (_, i) => new Promise<number>((resolve) => {
|
|
581
|
+
const child = spawn(process.execPath, [PRETOOL_SCRIPT], {
|
|
582
|
+
env: { ...process.env, SWITCHROOM_AGENT_DIR: agentDir },
|
|
583
|
+
stdio: ['pipe', 'ignore', 'inherit'],
|
|
584
|
+
})
|
|
585
|
+
child.on('close', (code: number | null) => resolve(code ?? -1))
|
|
586
|
+
child.stdin!.end(JSON.stringify({
|
|
587
|
+
session_id: 'sess-conc',
|
|
588
|
+
tool_name: 'Task',
|
|
589
|
+
tool_use_id: `toolu_conc_${i}`,
|
|
590
|
+
tool_input: { description: `concurrent worker ${i}`, run_in_background: true },
|
|
591
|
+
}))
|
|
592
|
+
}))
|
|
593
|
+
const codes = await Promise.all(runs)
|
|
594
|
+
expect(codes.every((c) => c === 0)).toBe(true)
|
|
595
|
+
|
|
596
|
+
const db = openDb()
|
|
597
|
+
const rows = db.prepare(
|
|
598
|
+
"SELECT id FROM subagents WHERE id LIKE 'toolu_conc_%' ORDER BY id",
|
|
599
|
+
).all() as Array<{ id: string }>
|
|
600
|
+
expect(rows.length).toBe(N)
|
|
601
|
+
}, 30_000)
|
|
602
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-promotion / onProgress gate-ordering regression (unified progress
|
|
3
|
+
* cards — the frozen-"starting…" contributor in registerAgent).
|
|
4
|
+
*
|
|
5
|
+
* Pre-fix sequence for a worker file that is IN-FLIGHT at boot:
|
|
6
|
+
* 1. registerAgent creates the entry with `historical = true`,
|
|
7
|
+
* 2. the initial readSubTail runs — every onProgress cue is suppressed by
|
|
8
|
+
* the `!entry.historical` guard — and leaves the cursor at EOF,
|
|
9
|
+
* 3. THEN the promotion block flips `historical = false`.
|
|
10
|
+
* Result: the promoted live worker's existing activity never fires
|
|
11
|
+
* onProgress, so its card (painted later by the heartbeat) shows a bare
|
|
12
|
+
* "starting…" stub, and for a quiet worker no later tick ever repairs it.
|
|
13
|
+
*
|
|
14
|
+
* The fix re-reads the tail from byte 0 AFTER promotion (resetting
|
|
15
|
+
* toolCount/lastTool so nothing double-counts), so the replayed events both
|
|
16
|
+
* rebuild the entry and fire onProgress. This test pins that.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
20
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
21
|
+
import * as fs from 'fs'
|
|
22
|
+
|
|
23
|
+
function buildJSONL(...lines: object[]): string {
|
|
24
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('boot promotion — in-flight worker replays its activity through onProgress', () => {
|
|
28
|
+
it('fires onProgress for pre-existing tool activity after promotion (not suppressed)', () => {
|
|
29
|
+
const agentId = 'inflight-at-boot-01'
|
|
30
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
31
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
32
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
33
|
+
const sessionDir = `${projectDir}/sess`
|
|
34
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
35
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
36
|
+
|
|
37
|
+
let currentTime = 100_000
|
|
38
|
+
// The file EXISTS AT BOOT with real in-flight activity: a prompt and two
|
|
39
|
+
// tool steps, still running (no turn_end). mtime is fresh (30s ago) so
|
|
40
|
+
// the freshness gate promotes it.
|
|
41
|
+
const content = Buffer.from(buildJSONL(
|
|
42
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'long research task' }] } },
|
|
43
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
|
|
44
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
45
|
+
), 'utf-8')
|
|
46
|
+
|
|
47
|
+
let lastOpened: string | null = null
|
|
48
|
+
const mockFs = {
|
|
49
|
+
existsSync: ((p: fs.PathLike) => {
|
|
50
|
+
const ps = String(p)
|
|
51
|
+
return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
|
|
52
|
+
}) as typeof fs.existsSync,
|
|
53
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
54
|
+
const ps = String(p)
|
|
55
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
56
|
+
if (ps === projectDir) return ['sess']
|
|
57
|
+
if (ps === sessionDir) return ['subagents']
|
|
58
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
59
|
+
return []
|
|
60
|
+
}) as unknown as typeof fs.readdirSync,
|
|
61
|
+
statSync: (() => ({ size: content.length, mtimeMs: currentTime - 30_000 }) as fs.Stats) as typeof fs.statSync,
|
|
62
|
+
openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
|
|
63
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
64
|
+
readSync: ((
|
|
65
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
66
|
+
): number => {
|
|
67
|
+
if (lastOpened !== jsonlPath) return 0
|
|
68
|
+
const src = content.slice(position ?? 0, (position ?? 0) + length)
|
|
69
|
+
src.copy(buf as Buffer, offset)
|
|
70
|
+
return src.length
|
|
71
|
+
}) as unknown as typeof fs.readSync,
|
|
72
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const progressCalls: Array<{ agentId: string; progressLine?: string; toolCount: number }> = []
|
|
76
|
+
const watcher = startSubagentWatcher({
|
|
77
|
+
agentDir,
|
|
78
|
+
fs: mockFs,
|
|
79
|
+
now: () => currentTime,
|
|
80
|
+
setInterval: () => ({ ref: 0 }),
|
|
81
|
+
clearInterval: () => {},
|
|
82
|
+
setTimeout: () => ({ ref: 0 }),
|
|
83
|
+
clearTimeout: () => {},
|
|
84
|
+
onProgress: ({ agentId: id, progressLine, toolCount }) => {
|
|
85
|
+
progressCalls.push({ agentId: id, progressLine, toolCount })
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
90
|
+
expect(entry).toBeDefined()
|
|
91
|
+
// Promoted live (fresh mtime, still running).
|
|
92
|
+
expect(entry?.historical).toBe(false)
|
|
93
|
+
// Replay rebuilt the entry WITHOUT double-counting the initial read.
|
|
94
|
+
expect(entry?.toolCount).toBe(2)
|
|
95
|
+
expect(entry?.lastTool?.name).toBe('Bash')
|
|
96
|
+
// THE regression pin: the replayed tool steps fired onProgress — pre-fix
|
|
97
|
+
// this was zero (suppressed by the historical gate, cursor left at EOF).
|
|
98
|
+
const mine = progressCalls.filter((c) => c.agentId === agentId)
|
|
99
|
+
expect(mine.length).toBeGreaterThanOrEqual(2)
|
|
100
|
+
expect(mine.some((c) => c.progressLine != null && c.progressLine.length > 0)).toBe(true)
|
|
101
|
+
|
|
102
|
+
watcher.stop()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('a STALE running-at-boot file stays historical and fires no onProgress (no replay spam)', () => {
|
|
106
|
+
const agentId = 'stale-at-boot-01'
|
|
107
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
108
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
109
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
110
|
+
const sessionDir = `${projectDir}/sess`
|
|
111
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
112
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
113
|
+
|
|
114
|
+
const currentTime = 100_000_000
|
|
115
|
+
const content = Buffer.from(buildJSONL(
|
|
116
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'dead prior-session task' }] } },
|
|
117
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x' } }] } },
|
|
118
|
+
), 'utf-8')
|
|
119
|
+
|
|
120
|
+
let lastOpened: string | null = null
|
|
121
|
+
const mockFs = {
|
|
122
|
+
existsSync: ((p: fs.PathLike) => {
|
|
123
|
+
const ps = String(p)
|
|
124
|
+
return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
|
|
125
|
+
}) as typeof fs.existsSync,
|
|
126
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
127
|
+
const ps = String(p)
|
|
128
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
129
|
+
if (ps === projectDir) return ['sess']
|
|
130
|
+
if (ps === sessionDir) return ['subagents']
|
|
131
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
132
|
+
return []
|
|
133
|
+
}) as unknown as typeof fs.readdirSync,
|
|
134
|
+
// Last write DAYS ago — far past the 15-min freshness window.
|
|
135
|
+
statSync: (() => ({ size: content.length, mtimeMs: currentTime - 3 * 24 * 3600_000 }) as fs.Stats) as typeof fs.statSync,
|
|
136
|
+
openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
|
|
137
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
138
|
+
readSync: ((
|
|
139
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
140
|
+
): number => {
|
|
141
|
+
if (lastOpened !== jsonlPath) return 0
|
|
142
|
+
const src = content.slice(position ?? 0, (position ?? 0) + length)
|
|
143
|
+
src.copy(buf as Buffer, offset)
|
|
144
|
+
return src.length
|
|
145
|
+
}) as unknown as typeof fs.readSync,
|
|
146
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const progressCalls: string[] = []
|
|
150
|
+
const watcher = startSubagentWatcher({
|
|
151
|
+
agentDir,
|
|
152
|
+
fs: mockFs,
|
|
153
|
+
now: () => currentTime,
|
|
154
|
+
setInterval: () => ({ ref: 0 }),
|
|
155
|
+
clearInterval: () => {},
|
|
156
|
+
setTimeout: () => ({ ref: 0 }),
|
|
157
|
+
clearTimeout: () => {},
|
|
158
|
+
onProgress: ({ agentId: id }) => { progressCalls.push(id) },
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
162
|
+
expect(entry?.historical).toBe(true)
|
|
163
|
+
expect(progressCalls.filter((id) => id === agentId)).toHaveLength(0)
|
|
164
|
+
|
|
165
|
+
watcher.stop()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -26,9 +26,17 @@ function subAgentUserMsg(promptText: string) {
|
|
|
26
26
|
function makeHarness(opts: {
|
|
27
27
|
agentId?: string
|
|
28
28
|
configStallThresholdMs?: number
|
|
29
|
+
configSilentSynthStallThresholdMs?: number
|
|
29
30
|
configSilentStallTerminalMs?: number
|
|
31
|
+
initialContent?: string
|
|
30
32
|
} = {}) {
|
|
31
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
agentId = 'env-thresh-agent',
|
|
35
|
+
configStallThresholdMs,
|
|
36
|
+
configSilentSynthStallThresholdMs,
|
|
37
|
+
configSilentStallTerminalMs,
|
|
38
|
+
initialContent,
|
|
39
|
+
} = opts
|
|
32
40
|
|
|
33
41
|
let currentTime = 1000
|
|
34
42
|
const stallCalls: Array<{ idleMs: number }> = []
|
|
@@ -43,7 +51,7 @@ function makeHarness(opts: {
|
|
|
43
51
|
const subagentsDir = `${sessionDir}/subagents`
|
|
44
52
|
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
45
53
|
const fileContents = new Map<string, Buffer>()
|
|
46
|
-
fileContents.set(jsonlPath, Buffer.from(buildJSONL(subAgentUserMsg('bg task')), 'utf-8'))
|
|
54
|
+
fileContents.set(jsonlPath, Buffer.from(initialContent ?? buildJSONL(subAgentUserMsg('bg task')), 'utf-8'))
|
|
47
55
|
|
|
48
56
|
let lastOpenedPath: string | null = null
|
|
49
57
|
const mockFs = {
|
|
@@ -81,7 +89,7 @@ function makeHarness(opts: {
|
|
|
81
89
|
const watcher = startSubagentWatcher({
|
|
82
90
|
agentDir,
|
|
83
91
|
stallThresholdMs: configStallThresholdMs,
|
|
84
|
-
silentSynthesisStallThresholdMs: configStallThresholdMs,
|
|
92
|
+
silentSynthesisStallThresholdMs: configSilentSynthStallThresholdMs ?? configStallThresholdMs,
|
|
85
93
|
silentStallTerminalMs: configSilentStallTerminalMs,
|
|
86
94
|
rescanMs: 500,
|
|
87
95
|
onStall: (_id, idleMs) => stallCalls.push({ idleMs }),
|
|
@@ -211,6 +219,41 @@ describe('subagent-watcher env-var threshold overrides', () => {
|
|
|
211
219
|
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
212
220
|
})
|
|
213
221
|
|
|
222
|
+
it('a Bash-in-flight worker skips the tight stall threshold but still releases the terminal gate', () => {
|
|
223
|
+
// End-to-end of the long-runner suppression under compressed env-tuned
|
|
224
|
+
// windows: a worker mid-`Bash` (a) is NOT flagged at the tight active-loop
|
|
225
|
+
// threshold, (b) IS eventually flagged at the widened silent-synthesis
|
|
226
|
+
// window, and (c) the pass-2 terminal synthesis STILL fires the fixed
|
|
227
|
+
// window after that — the load-bearing completion-gate release is intact.
|
|
228
|
+
const h = makeHarness({
|
|
229
|
+
agentId: 'env-thresh-bash',
|
|
230
|
+
configStallThresholdMs: 1000, // tight active-loop threshold
|
|
231
|
+
configSilentSynthStallThresholdMs: 10_000, // widened long-runner window
|
|
232
|
+
configSilentStallTerminalMs: 5000, // compressed terminal window
|
|
233
|
+
initialContent: buildJSONL(
|
|
234
|
+
subAgentUserMsg('bg task'),
|
|
235
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
236
|
+
),
|
|
237
|
+
})
|
|
238
|
+
h.advance(500) // register + tail → toolCount=1, lastTool=Bash
|
|
239
|
+
h.unmarkHistorical()
|
|
240
|
+
|
|
241
|
+
// 5s idle — well past the 1s active-loop threshold, but a Bash worker uses
|
|
242
|
+
// the 10s silent-synthesis window, so NO false stall.
|
|
243
|
+
h.advance(5_000)
|
|
244
|
+
expect(h.stallCalls).toHaveLength(0)
|
|
245
|
+
|
|
246
|
+
// Cross the 10s window → the stall IS flagged (pass-2 becomes reachable).
|
|
247
|
+
h.advance(6_000)
|
|
248
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
249
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
250
|
+
|
|
251
|
+
// 5s post-stall → terminal synthesis releases the deferred-completion gate.
|
|
252
|
+
h.advance(6_000)
|
|
253
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
254
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
255
|
+
})
|
|
256
|
+
|
|
214
257
|
it('zero env value falls through to default (zero is not "fire immediately")', () => {
|
|
215
258
|
process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '0'
|
|
216
259
|
const h = makeHarness({ configStallThresholdMs: 1000 })
|
|
@@ -311,6 +311,76 @@ describe('subagent-watcher onStall callback (Option C, issue #393)', () => {
|
|
|
311
311
|
expect(stallCalls).toHaveLength(1)
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
+
// Bash-in-flight suppression: a worker whose LAST observed tool is a known
|
|
315
|
+
// long-runner (`Bash` — a build, `npm test`, a long curl) legitimately goes
|
|
316
|
+
// quiet in the JSONL for minutes while the command runs. Pre-fix the tight
|
|
317
|
+
// 60s active-loop threshold misfired on exactly this population, producing
|
|
318
|
+
// the false "stall detected (idle 60s)" the operator hit while a background
|
|
319
|
+
// worker was mid-Bash. The watcher now widens the threshold for such workers
|
|
320
|
+
// to the silent-synthesis window.
|
|
321
|
+
it('does NOT trip stall at 60s when the last tool is Bash (long-runner suppression)', () => {
|
|
322
|
+
const agentId = 'stall-bash-01'
|
|
323
|
+
const { stallCalls, advance, watcher } = makeStallHarness({
|
|
324
|
+
agentId,
|
|
325
|
+
stallThresholdMs: 60_000,
|
|
326
|
+
silentSynthesisStallThresholdMs: 300_000, // 5min
|
|
327
|
+
rescanMs: 500,
|
|
328
|
+
initialContent: buildJSONL(
|
|
329
|
+
subAgentUserMsg('background task'),
|
|
330
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
331
|
+
),
|
|
332
|
+
})
|
|
333
|
+
advance(500) // register + tail read → toolCount=1, lastTool=Bash
|
|
334
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
335
|
+
if (entry) entry.historical = false
|
|
336
|
+
expect(entry?.lastTool?.name).toBe('Bash')
|
|
337
|
+
advance(120_000) // 2min idle — far past 60s but under the silent-synth window
|
|
338
|
+
expect(stallCalls).toHaveLength(0) // NOT falsely stalled mid-Bash
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('DOES trip stall for a Bash worker once past the silent-synthesis window (pass-2 gate still reachable)', () => {
|
|
342
|
+
const agentId = 'stall-bash-02'
|
|
343
|
+
const { stallCalls, advance, watcher } = makeStallHarness({
|
|
344
|
+
agentId,
|
|
345
|
+
stallThresholdMs: 60_000,
|
|
346
|
+
silentSynthesisStallThresholdMs: 300_000, // 5min
|
|
347
|
+
rescanMs: 500,
|
|
348
|
+
initialContent: buildJSONL(
|
|
349
|
+
subAgentUserMsg('background task'),
|
|
350
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'sleep 999' } }] } },
|
|
351
|
+
),
|
|
352
|
+
})
|
|
353
|
+
advance(500)
|
|
354
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
355
|
+
if (entry) entry.historical = false
|
|
356
|
+
advance(120_000) // under the window — no stall yet
|
|
357
|
+
expect(stallCalls).toHaveLength(0)
|
|
358
|
+
advance(200_000) // total ~5min20s — past the silent-synth window → stall fires
|
|
359
|
+
expect(stallCalls).toHaveLength(1)
|
|
360
|
+
expect(stallCalls[0].agentId).toBe(agentId)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('a non-long-runner last tool (Read) still trips at the tight 60s threshold', () => {
|
|
364
|
+
// Guard the suppression is scoped to long-runners only — a Read that goes
|
|
365
|
+
// quiet for 60s is genuinely stalled and must still flag.
|
|
366
|
+
const agentId = 'stall-read-01'
|
|
367
|
+
const { stallCalls, advance, watcher } = makeStallHarness({
|
|
368
|
+
agentId,
|
|
369
|
+
stallThresholdMs: 60_000,
|
|
370
|
+
silentSynthesisStallThresholdMs: 600_000, // way out — proves the 60s path is taken
|
|
371
|
+
rescanMs: 500,
|
|
372
|
+
initialContent: buildJSONL(
|
|
373
|
+
subAgentUserMsg('background task'),
|
|
374
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-R', name: 'Read', input: { path: '/x' } }] } },
|
|
375
|
+
),
|
|
376
|
+
})
|
|
377
|
+
advance(500)
|
|
378
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
379
|
+
if (entry) entry.historical = false
|
|
380
|
+
advance(65_000) // 65s quiet after a Read → genuinely stalled
|
|
381
|
+
expect(stallCalls).toHaveLength(1)
|
|
382
|
+
})
|
|
383
|
+
|
|
314
384
|
// Test 10: onStall is NOT called for sub-agents already done/failed
|
|
315
385
|
it('does not call onStall for sub-agents in done/failed state', () => {
|
|
316
386
|
const agentId = 'stall-test-10-done'
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
renderActivityFeedWithNested,
|
|
9
9
|
renderActivityHeader,
|
|
10
10
|
formatFeedElapsed,
|
|
11
|
+
formatStepSuffix,
|
|
12
|
+
STEP_TIMER_MIN_MS,
|
|
11
13
|
type SessionActivityHeader,
|
|
12
14
|
} from "../tool-activity-summary.js";
|
|
13
15
|
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
|
|
@@ -782,6 +784,20 @@ describe("formatFeedElapsed — elapsed formatter", () => {
|
|
|
782
784
|
});
|
|
783
785
|
});
|
|
784
786
|
|
|
787
|
+
describe("formatStepSuffix — per-step suffix with 10s gate", () => {
|
|
788
|
+
it("returns empty string while the step is younger than STEP_TIMER_MIN_MS", () => {
|
|
789
|
+
expect(formatStepSuffix(0)).toBe("");
|
|
790
|
+
expect(formatStepSuffix(9_999)).toBe("");
|
|
791
|
+
expect(formatStepSuffix(STEP_TIMER_MIN_MS - 1)).toBe("");
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("renders ` · <elapsed>` once the step reaches the gate", () => {
|
|
795
|
+
expect(formatStepSuffix(STEP_TIMER_MIN_MS)).toBe(" · 10s");
|
|
796
|
+
expect(formatStepSuffix(13_000)).toBe(" · 13s");
|
|
797
|
+
expect(formatStepSuffix(65_000)).toBe(" · 1m05s");
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
785
801
|
describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
786
802
|
it("prepends the two-line header when header is supplied (running)", () => {
|
|
787
803
|
const header: SessionActivityHeader = {
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
// A representative slice mirroring the real TOOL_SCHEMAS names.
|
|
16
16
|
const SAMPLE: NamedTool[] = [
|
|
17
17
|
{ name: 'reply', description: 'r' },
|
|
18
|
-
{ name: 'stream_reply', description: 's' },
|
|
19
18
|
{ name: 'get_recent_messages', description: 'g' },
|
|
20
19
|
{ name: 'react', description: 'k' },
|
|
21
20
|
{ name: 'edit_message', description: 'e' },
|
|
@@ -58,10 +57,9 @@ describe('buildEffectiveToolSchemas — per-tool deferral pins (B)', () => {
|
|
|
58
57
|
}
|
|
59
58
|
})
|
|
60
59
|
|
|
61
|
-
it('the reply path (reply
|
|
60
|
+
it('the reply path (reply) is ALWAYS pinned — never defers', () => {
|
|
62
61
|
const out = buildEffectiveToolSchemas(SAMPLE, { linearEnabled: false })
|
|
63
62
|
expect(metaOf(out, 'reply')).toEqual({ 'anthropic/alwaysLoad': true })
|
|
64
|
-
expect(metaOf(out, 'stream_reply')).toEqual({ 'anthropic/alwaysLoad': true })
|
|
65
63
|
})
|
|
66
64
|
|
|
67
65
|
it('cold tools carry NO _meta (so they defer under tool-search)', () => {
|
|
@@ -41,18 +41,15 @@ describe('computeLabel — never-null built-in fallthrough', () => {
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
describe('computeLabel — surface-tool suppression is key-agnostic', () => {
|
|
44
|
-
it('returns null for telegram reply
|
|
44
|
+
it('returns null for telegram reply under any registration key', () => {
|
|
45
45
|
// Standard switchroom-telegram key.
|
|
46
46
|
expect(computeLabel('mcp__switchroom-telegram__reply', {})).toBeNull()
|
|
47
|
-
expect(computeLabel('mcp__switchroom-telegram__stream_reply', {})).toBeNull()
|
|
48
47
|
|
|
49
48
|
// Legacy clerk-telegram key — same plugin, different registration name.
|
|
50
49
|
expect(computeLabel('mcp__clerk-telegram__reply', {})).toBeNull()
|
|
51
|
-
expect(computeLabel('mcp__clerk-telegram__stream_reply', {})).toBeNull()
|
|
52
50
|
|
|
53
51
|
// Hypothetical custom fork.
|
|
54
52
|
expect(computeLabel('mcp__my-custom-telegram__reply', {})).toBeNull()
|
|
55
|
-
expect(computeLabel('mcp__my-custom-telegram__stream_reply', {})).toBeNull()
|
|
56
53
|
})
|
|
57
54
|
|
|
58
55
|
it('returns null for telegram react under any registration key', () => {
|