switchroom 0.18.3 → 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -265,6 +265,53 @@ describe('backfillJsonlAgentId — overlapping windows / hook precedence (#2081)
|
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
+
// ─── Fix #3: ambiguous fuzzy backfill refuses to link ────────────────────────
|
|
269
|
+
// Legacy meta.json (no toolUseId) falls back to the fuzzy (agentType,
|
|
270
|
+
// description) match. With N unlinked rows sharing that key, a bare
|
|
271
|
+
// `ORDER BY started_at DESC LIMIT 1` assigned by start-order, not true
|
|
272
|
+
// correspondence — a genuine mislink for a cross-topic identical dispatch.
|
|
273
|
+
// The fix: link only when the candidate is UNAMBIGUOUS (exactly one match);
|
|
274
|
+
// otherwise leave jsonl_agent_id NULL and let the marker/window path
|
|
275
|
+
// attribute it.
|
|
276
|
+
describe('backfillJsonlAgentId — fix #3: ambiguous fuzzy match refused', () => {
|
|
277
|
+
it('two unlinked rows sharing (agent_type, description) → fuzzy link refused, row stays NULL', () => {
|
|
278
|
+
insertSub({ id: 'toolu_amb_1', agentType: 'general-purpose', description: 'Run the tests', startedAt: 1000 })
|
|
279
|
+
insertSub({ id: 'toolu_amb_2', agentType: 'general-purpose', description: 'Run the tests', startedAt: 2000 })
|
|
280
|
+
|
|
281
|
+
// Legacy meta.json — no toolUseId, forces the fuzzy fallback path.
|
|
282
|
+
const jsonlPath = writeMeta('general-purpose', 'Run the tests')
|
|
283
|
+
const logs: string[] = []
|
|
284
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_ambiguous', (m) => logs.push(m))
|
|
285
|
+
|
|
286
|
+
// Neither candidate gets mis-assigned — both remain unlinked.
|
|
287
|
+
expect(readSub('toolu_amb_1')?.jsonl_agent_id ?? null).toBeNull()
|
|
288
|
+
expect(readSub('toolu_amb_2')?.jsonl_agent_id ?? null).toBeNull()
|
|
289
|
+
expect(logs.some((l) => l.includes('ambiguous') && l.includes('agentstem_ambiguous'))).toBe(true)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('a SINGLE unambiguous candidate still links normally (no regression)', () => {
|
|
293
|
+
insertSub({ id: 'toolu_unamb', agentType: 'general-purpose', description: 'Unique task', startedAt: 1000 })
|
|
294
|
+
|
|
295
|
+
const jsonlPath = writeMeta('general-purpose', 'Unique task')
|
|
296
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_unambiguous')
|
|
297
|
+
|
|
298
|
+
expect(readSub('toolu_unamb')?.jsonl_agent_id).toBe('agentstem_unambiguous')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('three unlinked rows sharing the same key → still refused (not just a 2-row edge case)', () => {
|
|
302
|
+
insertSub({ id: 'toolu_amb3_1', agentType: 'researcher', description: 'Investigate', startedAt: 1000 })
|
|
303
|
+
insertSub({ id: 'toolu_amb3_2', agentType: 'researcher', description: 'Investigate', startedAt: 2000 })
|
|
304
|
+
insertSub({ id: 'toolu_amb3_3', agentType: 'researcher', description: 'Investigate', startedAt: 3000 })
|
|
305
|
+
|
|
306
|
+
const jsonlPath = writeMeta('researcher', 'Investigate')
|
|
307
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_amb3')
|
|
308
|
+
|
|
309
|
+
expect(readSub('toolu_amb3_1')?.jsonl_agent_id ?? null).toBeNull()
|
|
310
|
+
expect(readSub('toolu_amb3_2')?.jsonl_agent_id ?? null).toBeNull()
|
|
311
|
+
expect(readSub('toolu_amb3_3')?.jsonl_agent_id ?? null).toBeNull()
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
268
315
|
// ─── #2506: null meta.json guard ─────────────────────────────────────────────
|
|
269
316
|
// JSON.parse('null') succeeds and returns null. Before the fix, the enclosing
|
|
270
317
|
// try/catch only covered the read+parse, so execution fell through to
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #9a — `terminatedAgentIds` (the re-discovery dedup guard added on
|
|
3
|
+
* every `cleanupTerminalAgent`, read in `scanSubagentsDir`'s skip-check)
|
|
4
|
+
* grew unboundedly: only ever cleared wholesale in `stop()`. This pins the
|
|
5
|
+
* bound: once the cap is hit, the OLDEST id is evicted on each new insert
|
|
6
|
+
* (Set preserves insertion order), while the dedup guarantee for recently-
|
|
7
|
+
* terminated ids still holds.
|
|
8
|
+
*
|
|
9
|
+
* `terminatedAgentIdsCap` is a config override so the test can exercise
|
|
10
|
+
* eviction with a handful of entries instead of the production cap
|
|
11
|
+
* (a few thousand).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
15
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
16
|
+
import * as fs from 'fs'
|
|
17
|
+
|
|
18
|
+
function buildJSONL(...lines: object[]): string {
|
|
19
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
20
|
+
}
|
|
21
|
+
function subAgentUserMsg(promptText: string) {
|
|
22
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
23
|
+
}
|
|
24
|
+
function subAgentTurnEnd() {
|
|
25
|
+
return { type: 'system', subtype: 'turn_duration', duration_ms: 100 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('terminatedAgentIds cap (fix #9a)', () => {
|
|
29
|
+
it('evicts the oldest terminated id once the cap is hit, but keeps dedup for recent ids', () => {
|
|
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
|
+
|
|
36
|
+
// 4 agents, already fully done (turn_end present) at "boot" — each will
|
|
37
|
+
// schedule a terminal cleanup immediately.
|
|
38
|
+
const agentIds = ['agent-a', 'agent-b', 'agent-c', 'agent-d']
|
|
39
|
+
const fileNames = agentIds.map((id) => `agent-${id}.jsonl`)
|
|
40
|
+
const filePaths = new Map(agentIds.map((id) => [id, `${subagentsDir}/agent-${id}.jsonl`]))
|
|
41
|
+
// Files visible to a directory scan — stay present throughout (a real
|
|
42
|
+
// gateway's JSONL files don't disappear on cleanup either; only the
|
|
43
|
+
// in-memory `terminatedAgentIds` dedup guard decides re-registration).
|
|
44
|
+
const visibleFiles = [...fileNames]
|
|
45
|
+
|
|
46
|
+
const content = Buffer.from(buildJSONL(subAgentUserMsg('done task'), subAgentTurnEnd()), 'utf-8')
|
|
47
|
+
|
|
48
|
+
let lastOpened: string | null = null
|
|
49
|
+
const mockFs = {
|
|
50
|
+
existsSync: ((p: fs.PathLike) => {
|
|
51
|
+
const ps = String(p)
|
|
52
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
53
|
+
return [...filePaths.values()].includes(ps)
|
|
54
|
+
}) as typeof fs.existsSync,
|
|
55
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
56
|
+
const ps = String(p)
|
|
57
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
58
|
+
if (ps === projectDir) return ['sess']
|
|
59
|
+
if (ps === sessionDir) return ['subagents']
|
|
60
|
+
if (ps === subagentsDir) return visibleFiles
|
|
61
|
+
return []
|
|
62
|
+
}) as unknown as typeof fs.readdirSync,
|
|
63
|
+
statSync: (() => ({ size: content.length, mtimeMs: 0 }) as fs.Stats) as typeof fs.statSync,
|
|
64
|
+
openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
|
|
65
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
66
|
+
readSync: ((
|
|
67
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
68
|
+
): number => {
|
|
69
|
+
if (lastOpened == null) return 0
|
|
70
|
+
const src = content.slice(position ?? 0, (position ?? 0) + length)
|
|
71
|
+
src.copy(buf as Buffer, offset)
|
|
72
|
+
return src.length
|
|
73
|
+
}) as unknown as typeof fs.readSync,
|
|
74
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let currentTime = 1_000_000
|
|
78
|
+
const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
|
|
79
|
+
const timeouts: Array<{ fn: () => void; ms: number; fireAt: number; ref: number }> = []
|
|
80
|
+
let nextTimeoutRef = 1
|
|
81
|
+
|
|
82
|
+
const advance = (ms: number): void => {
|
|
83
|
+
currentTime += ms
|
|
84
|
+
for (;;) {
|
|
85
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
86
|
+
const next = timeouts[0]
|
|
87
|
+
if (!next || next.fireAt > currentTime) break
|
|
88
|
+
timeouts.shift()
|
|
89
|
+
next.fn()
|
|
90
|
+
}
|
|
91
|
+
for (const iv of intervals) {
|
|
92
|
+
while (iv.fireAt <= currentTime) {
|
|
93
|
+
iv.fn()
|
|
94
|
+
iv.fireAt += iv.ms
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const watcher = startSubagentWatcher({
|
|
100
|
+
agentDir,
|
|
101
|
+
fs: mockFs,
|
|
102
|
+
terminatedAgentIdsCap: 3,
|
|
103
|
+
now: () => currentTime,
|
|
104
|
+
setInterval: (fn, ms) => {
|
|
105
|
+
intervals.push({ fn, ms, fireAt: currentTime + ms })
|
|
106
|
+
return { ref: 0 }
|
|
107
|
+
},
|
|
108
|
+
clearInterval: () => {},
|
|
109
|
+
setTimeout: (fn, ms) => {
|
|
110
|
+
const ref = nextTimeoutRef++
|
|
111
|
+
timeouts.push({ fn, ms, fireAt: currentTime + ms, ref })
|
|
112
|
+
return { ref }
|
|
113
|
+
},
|
|
114
|
+
clearTimeout: (handle) => {
|
|
115
|
+
const { ref } = handle as { ref: number }
|
|
116
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
117
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// All 4 discovered at boot, already `done` → each schedules a terminal
|
|
122
|
+
// cleanup TERMINAL_CLEANUP_GRACE_MS (30s) out, in discovery order.
|
|
123
|
+
for (const id of agentIds) {
|
|
124
|
+
expect(watcher.getRegistry().has(id)).toBe(true)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fire all 4 scheduled cleanups (in order a, b, c, d). With cap=3, the
|
|
128
|
+
// 4th cleanup (d) evicts the OLDEST tracked id (a) before inserting d.
|
|
129
|
+
// `advance()` also drives the rescan interval (default 1s) forward as
|
|
130
|
+
// part of the same clock jump, so by the time it returns the poll
|
|
131
|
+
// loop has ALSO already rediscovered the just-evicted agent-a (its
|
|
132
|
+
// JSONL file is still on disk throughout — nothing "disappears" for a
|
|
133
|
+
// real gateway either) — that's the observable proof the eviction
|
|
134
|
+
// took effect, not a separate step.
|
|
135
|
+
advance(30_000)
|
|
136
|
+
|
|
137
|
+
// agent-a was evicted from `terminatedAgentIds` (cap=3, it was the
|
|
138
|
+
// oldest insert) → the dedup guard no longer suppresses it, so the
|
|
139
|
+
// rescan re-discovered and re-registered it.
|
|
140
|
+
expect(watcher.getRegistry().has('agent-a')).toBe(true)
|
|
141
|
+
// agent-b, agent-c, agent-d are still within the cap window → the
|
|
142
|
+
// dedup guard still suppresses their re-discovery even though their
|
|
143
|
+
// JSONL files are also still sitting on disk.
|
|
144
|
+
expect(watcher.getRegistry().has('agent-b')).toBe(false)
|
|
145
|
+
expect(watcher.getRegistry().has('agent-c')).toBe(false)
|
|
146
|
+
expect(watcher.getRegistry().has('agent-d')).toBe(false)
|
|
147
|
+
|
|
148
|
+
watcher.stop()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -519,6 +519,60 @@ describe('startSubagentWatcher', () => {
|
|
|
519
519
|
expect(toolTick?.latestSummary).toBe('')
|
|
520
520
|
})
|
|
521
521
|
|
|
522
|
+
it('captures message.model into entry.currentModel and threads it onto onProgress', () => {
|
|
523
|
+
const progress: Array<{ model?: string }> = []
|
|
524
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
525
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
526
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
527
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
528
|
+
|
|
529
|
+
const h = startWatcherSync({
|
|
530
|
+
agentDir,
|
|
531
|
+
onProgress: ({ model }) => { progress.push({ model }) },
|
|
532
|
+
})
|
|
533
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research the competitors')))
|
|
534
|
+
h.poll()
|
|
535
|
+
|
|
536
|
+
// Assistant line carrying a resolved model + a tool_use (drives an
|
|
537
|
+
// onProgress tick). The model event is projected first, so the entry's
|
|
538
|
+
// currentModel is set before the tool tick fires.
|
|
539
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
540
|
+
type: 'assistant',
|
|
541
|
+
message: {
|
|
542
|
+
model: 'claude-opus-4-8',
|
|
543
|
+
content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: { file_path: '/x/CLAUDE.md' } }],
|
|
544
|
+
},
|
|
545
|
+
}))
|
|
546
|
+
h.poll()
|
|
547
|
+
|
|
548
|
+
expect(h.watcher.getRegistry().get('deadbeef')?.currentModel).toBe('claude-opus-4-8')
|
|
549
|
+
const modelled = progress.find((p) => p.model != null)
|
|
550
|
+
expect(modelled?.model).toBe('claude-opus-4-8')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('ignores a synthetic model sentinel, keeping the last real model', () => {
|
|
554
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
555
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
556
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
557
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
558
|
+
|
|
559
|
+
const h = startWatcherSync({ agentDir })
|
|
560
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research')))
|
|
561
|
+
h.poll()
|
|
562
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
563
|
+
type: 'assistant',
|
|
564
|
+
message: { model: 'claude-opus-4-8', content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: {} }] },
|
|
565
|
+
}))
|
|
566
|
+
h.poll()
|
|
567
|
+
// A compaction/synthetic line — must NOT clobber the last real model.
|
|
568
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
569
|
+
type: 'assistant',
|
|
570
|
+
message: { model: '<synthetic>', content: [{ type: 'tool_use', name: 'Bash', id: 'b1', input: {} }] },
|
|
571
|
+
}))
|
|
572
|
+
h.poll()
|
|
573
|
+
expect(h.watcher.getRegistry().get('deadbeef')?.currentModel).toBe('claude-opus-4-8')
|
|
574
|
+
})
|
|
575
|
+
|
|
522
576
|
it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED (no progress cue)', () => {
|
|
523
577
|
// The worker composes its answer as a text block, then calls
|
|
524
578
|
// stream_reply with near-identical text. The narrative cue must be
|
|
@@ -575,6 +629,67 @@ describe('startSubagentWatcher', () => {
|
|
|
575
629
|
expect(narrativeCues[0]).toContain('find the repo')
|
|
576
630
|
})
|
|
577
631
|
|
|
632
|
+
it('narration-clobber regression: a narration cue immediately followed by its resolving tool_use in the same poll does NOT get overwritten by the tool-label cue', () => {
|
|
633
|
+
// Reproduces the bug: sub_agent_text ("On it...") + sub_agent_tool_use
|
|
634
|
+
// (Bash) land in the SAME jsonl-tail read, so both the narrative
|
|
635
|
+
// resolution (fireNarrativeProgress) and the tool-description
|
|
636
|
+
// onProgress fire within one loop iteration over `events`. Since the
|
|
637
|
+
// card renders replace-on-write, the tool-label call previously always
|
|
638
|
+
// clobbered the narration call that fired moments earlier — narration
|
|
639
|
+
// was staged and "SHOWN" per the dedup gate, but never actually
|
|
640
|
+
// visible on the pinned card. Assert only ONE onProgress cue fires for
|
|
641
|
+
// this tick, and it's the narration (progressLine == null), not the
|
|
642
|
+
// tool label.
|
|
643
|
+
const allCues: Array<{ progressLine?: string; latestSummary: string }> = []
|
|
644
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
645
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
646
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
647
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
648
|
+
const h = startWatcherSync({
|
|
649
|
+
agentDir,
|
|
650
|
+
onProgress: ({ progressLine, latestSummary }) => {
|
|
651
|
+
allCues.push({ progressLine, latestSummary })
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
|
|
655
|
+
h.poll()
|
|
656
|
+
// Narration text + its resolving tool_use appended and tailed together
|
|
657
|
+
// in a single poll — this is the "same tick" race.
|
|
658
|
+
appendFileSync(
|
|
659
|
+
jsonlPath,
|
|
660
|
+
buildJSONL(subAgentAssistantText('On it. Let me find the repo.'), subAgentToolUse('Bash', 'b1')),
|
|
661
|
+
)
|
|
662
|
+
h.poll()
|
|
663
|
+
expect(allCues.length).toBe(1)
|
|
664
|
+
expect(allCues[0].progressLine).toBeUndefined()
|
|
665
|
+
expect(allCues[0].latestSummary).toContain('find the repo')
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('unregressed: two sub_agent_tool_use events with no narration between them both still show tool labels (named foreground blindspot)', () => {
|
|
669
|
+
// Guards against the clobber-guard fix over-suppressing: narrativeJustFired
|
|
670
|
+
// must be false for a tool_use that has no preceding pending narrative,
|
|
671
|
+
// so back-to-back tool calls (a researcher reading files with no prose)
|
|
672
|
+
// must both still surface a progressLine.
|
|
673
|
+
const toolCues: Array<string | undefined> = []
|
|
674
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
675
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
676
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
677
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
678
|
+
const h = startWatcherSync({
|
|
679
|
+
agentDir,
|
|
680
|
+
onProgress: ({ progressLine }) => {
|
|
681
|
+
if (progressLine != null) toolCues.push(progressLine)
|
|
682
|
+
},
|
|
683
|
+
})
|
|
684
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
|
|
685
|
+
h.poll()
|
|
686
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
687
|
+
h.poll()
|
|
688
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
|
|
689
|
+
h.poll()
|
|
690
|
+
expect(toolCues.length).toBe(2)
|
|
691
|
+
})
|
|
692
|
+
|
|
578
693
|
it('narrative gate: trailing narration at turn_end is SHOWN', () => {
|
|
579
694
|
const narrativeCues: string[] = []
|
|
580
695
|
const agentDir = join(tmpRoot, 'agent')
|
|
@@ -723,6 +723,28 @@ describe("renderActivityHeader — two-line header builder", () => {
|
|
|
723
723
|
const [h1] = renderActivityHeader("🤖", "Agent", "run a_b & c*d", 5_000, 1, "running");
|
|
724
724
|
expect(h1).toContain("run a\\_b & c\\*d");
|
|
725
725
|
});
|
|
726
|
+
|
|
727
|
+
it("appends the friendly live model to the running metrics line", () => {
|
|
728
|
+
const [, h2] = renderActivityHeader("🤖", "Agent", "", 120_000, 14, "running", "claude-opus-4-8");
|
|
729
|
+
expect(h2).toBe("_2m00s · 14 tools · opus 4.8_");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("appends the friendly live model to the done metrics line", () => {
|
|
733
|
+
const [, h2] = renderActivityHeader("🤖", "Agent", "", 65_000, 3, "done", "claude-sonnet-5");
|
|
734
|
+
expect(h2).toBe("_done · 3 tools · 1m05s · sonnet 5_");
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("shows an sr-* model id verbatim on the metrics line", () => {
|
|
738
|
+
const [, h2] = renderActivityHeader("🛠", "Worker", "run tests", 10_000, 2, "running", "sr-glm-5");
|
|
739
|
+
expect(h2).toBe("_10s · 2 tools · sr-glm-5_");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("omits the model tag for a sentinel / absent value", () => {
|
|
743
|
+
const [, h2none] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running");
|
|
744
|
+
expect(h2none).toBe("_15s · 7 tools_");
|
|
745
|
+
const [, h2synth] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running", "<synthetic>");
|
|
746
|
+
expect(h2synth).toBe("_15s · 7 tools_");
|
|
747
|
+
});
|
|
726
748
|
});
|
|
727
749
|
|
|
728
750
|
describe("agent flat path routes through the shared step-feed primitive", () => {
|
|
@@ -815,6 +837,21 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
815
837
|
expect(out).toContain("**→ Searching memory**");
|
|
816
838
|
});
|
|
817
839
|
|
|
840
|
+
it("threads the header model through the flat and nested feed paths", () => {
|
|
841
|
+
const header: SessionActivityHeader = {
|
|
842
|
+
label: "Agent",
|
|
843
|
+
elapsedMs: 120_000,
|
|
844
|
+
toolCount: 14,
|
|
845
|
+
state: "running",
|
|
846
|
+
model: "claude-opus-4-8",
|
|
847
|
+
};
|
|
848
|
+
const flat = renderActivityFeed(["Searching memory"], false, "", undefined, header)!;
|
|
849
|
+
expect(flat).toContain("_2m00s · 14 tools · opus 4.8_");
|
|
850
|
+
// Nested path (with a child line) carries the same model tag.
|
|
851
|
+
const nested = renderActivityFeedWithNested(["Reading"], ["nested step"], false, "", undefined, header)!;
|
|
852
|
+
expect(nested).toContain("· opus 4.8_");
|
|
853
|
+
});
|
|
854
|
+
|
|
818
855
|
it("prepends the done header when final=true", () => {
|
|
819
856
|
const header: SessionActivityHeader = {
|
|
820
857
|
label: "Agent",
|
|
@@ -64,6 +64,29 @@ describe('createTypingWrapper', () => {
|
|
|
64
64
|
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
+
it('fix #7: does NOT stop typing when the first of two parallel tools resolves while the second is still running', () => {
|
|
68
|
+
const deps = makeDeps()
|
|
69
|
+
const w = createTypingWrapper(deps)
|
|
70
|
+
// Two overlapping tool_use blocks on the same lane. The first fires the
|
|
71
|
+
// loop immediately; the second (still in-flight) uses the debounce.
|
|
72
|
+
w.onToolUse('t1', 'chat-A', 'Bash')
|
|
73
|
+
w.onToolUse('t2', 'chat-A', 'Read')
|
|
74
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
75
|
+
// Let t2's debounce fire so it's a live, started entry on the lane too.
|
|
76
|
+
vi.advanceTimersByTime(500)
|
|
77
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
78
|
+
|
|
79
|
+
// t1 resolves first — with the pre-fix boolean Set, this deleted the
|
|
80
|
+
// lane and stopped the loop even though t2 is still running.
|
|
81
|
+
w.onToolResult('t1')
|
|
82
|
+
expect(deps.stopTypingLoop).not.toHaveBeenCalled()
|
|
83
|
+
|
|
84
|
+
// t2 resolves too — now the lane's ref-count hits zero and the loop stops.
|
|
85
|
+
w.onToolResult('t2')
|
|
86
|
+
expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
|
|
87
|
+
expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A', null)
|
|
88
|
+
})
|
|
89
|
+
|
|
67
90
|
it('starts then stops typing when a single slow tool completes', () => {
|
|
68
91
|
const deps = makeDeps()
|
|
69
92
|
const w = createTypingWrapper(deps)
|
|
@@ -96,6 +96,25 @@ describe('classifyRejection — benign Telegram 400s', () => {
|
|
|
96
96
|
)
|
|
97
97
|
expect(classifyRejection(err)).toBe('log_only')
|
|
98
98
|
})
|
|
99
|
+
it('returns "log_only" for "group chat was upgraded to a supergroup chat" (marko 2026-07-09, recurring 2026-06-07/2026-06-09)', () => {
|
|
100
|
+
// A send targeted a basic-group chat_id that Telegram had since
|
|
101
|
+
// migrated to a supergroup (new -100xxxxxxxxxx id). This crashed the
|
|
102
|
+
// ENTIRE gateway process (every agent/chat) over a single stale
|
|
103
|
+
// cached chat_id — the same root cause recurred three times across a
|
|
104
|
+
// month because the crash-on-leak behavior masked the underlying
|
|
105
|
+
// stale-id bug instead of just logging it. A migrated/invalid
|
|
106
|
+
// destination chat must never take down the whole gateway.
|
|
107
|
+
const err = grammyError(
|
|
108
|
+
400,
|
|
109
|
+
'Bad Request: group chat was upgraded to a supergroup chat',
|
|
110
|
+
)
|
|
111
|
+
expect(classifyRejection(err)).toBe('log_only')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns "log_only" for "group chat was deactivated"', () => {
|
|
115
|
+
const err = grammyError(400, 'Bad Request: group chat was deactivated')
|
|
116
|
+
expect(classifyRejection(err)).toBe('log_only')
|
|
117
|
+
})
|
|
99
118
|
})
|
|
100
119
|
|
|
101
120
|
describe('classifyRejection — genuine errors still crash', () => {
|
|
@@ -86,6 +86,17 @@ describe('renderWorkerActivity', () => {
|
|
|
86
86
|
expect(out).not.toContain('<code>')
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
+
it('renders the friendly live model on the worker metrics line', () => {
|
|
90
|
+
const out = renderWorkerActivity(view({ model: 'claude-sonnet-5' }))
|
|
91
|
+
expect(out).toContain('_10s · 3 tools · sonnet 5_')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('omits the model tag when the worker model is unknown', () => {
|
|
95
|
+
const out = renderWorkerActivity(view())
|
|
96
|
+
expect(out).toContain('_10s · 3 tools_')
|
|
97
|
+
expect(out).not.toContain('· sonnet')
|
|
98
|
+
})
|
|
99
|
+
|
|
89
100
|
it('shows a "starting…" line when no step has run yet', () => {
|
|
90
101
|
const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
|
|
91
102
|
expect(out).toContain('🛠 **Worker**')
|
|
@@ -945,6 +956,103 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
945
956
|
})
|
|
946
957
|
})
|
|
947
958
|
|
|
959
|
+
// ─── Resurrection guard + deferred finalize (review findings #2 & #3) ─────────
|
|
960
|
+
// #3: a late watcher onProgress tick arriving after `finish()` queued its
|
|
961
|
+
// chain must NOT resurrect the handle and paint a fresh running message.
|
|
962
|
+
// #2: a terminal edit that hit a 429 cooldown stages on `pendingFinish` and
|
|
963
|
+
// is re-driven by the heartbeat after cooldown — the card can't get stuck
|
|
964
|
+
// on its last running render. "not modified" / message-gone are success/drop.
|
|
965
|
+
describe('createWorkerActivityFeed — resurrection guard + deferred finalize', () => {
|
|
966
|
+
const drain = () => new Promise((r) => setTimeout(r, 0))
|
|
967
|
+
|
|
968
|
+
it('#3: a late update tick after finish does not resurrect a running card', async () => {
|
|
969
|
+
const bot = makeFakeBot()
|
|
970
|
+
let clock = 10_000
|
|
971
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
972
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
|
|
973
|
+
expect(bot.sent).toHaveLength(1)
|
|
974
|
+
|
|
975
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done result' }))
|
|
976
|
+
// finish landed a terminal edit and dropped the handle.
|
|
977
|
+
expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
|
|
978
|
+
expect(feed.has('w1')).toBe(false)
|
|
979
|
+
|
|
980
|
+
// Late watcher tick arrives AFTER finish. Pre-fix this would create a
|
|
981
|
+
// fresh handle and paint a new running message on a finalized worker.
|
|
982
|
+
clock = 12_000
|
|
983
|
+
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'step two' }))
|
|
984
|
+
expect(bot.sent).toHaveLength(1) // no new running message
|
|
985
|
+
expect(feed.has('w1')).toBe(false)
|
|
986
|
+
expect(feed.size).toBe(0)
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
it('#2: a 429 on the finish edit stages pendingFinish; the heartbeat re-drives it after cooldown', async () => {
|
|
990
|
+
const bot = makeFakeBot()
|
|
991
|
+
let clock = 10_000
|
|
992
|
+
const feed = createWorkerActivityFeed({
|
|
993
|
+
bot,
|
|
994
|
+
now: () => clock,
|
|
995
|
+
minEditIntervalMs: 0,
|
|
996
|
+
heartbeatTickMs: 6000,
|
|
997
|
+
setInterval: () => 1,
|
|
998
|
+
clearInterval: () => {},
|
|
999
|
+
})
|
|
1000
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'running step' }))
|
|
1001
|
+
expect(bot.sent).toHaveLength(1)
|
|
1002
|
+
|
|
1003
|
+
// The terminal edit hits a 429 with a 2s retry_after.
|
|
1004
|
+
bot.failNextEditWith = { error_code: 429, parameters: { retry_after: 2 } }
|
|
1005
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'final result' }))
|
|
1006
|
+
await drain()
|
|
1007
|
+
// No terminal edit landed yet — the last edit is still the running render.
|
|
1008
|
+
expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
|
|
1009
|
+
|
|
1010
|
+
// Inside the cooldown — a heartbeat tick must NOT retry (would re-429).
|
|
1011
|
+
clock = 11_000
|
|
1012
|
+
feed.heartbeatTick()
|
|
1013
|
+
await drain()
|
|
1014
|
+
expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
|
|
1015
|
+
|
|
1016
|
+
// Past the cooldown (10_000 + 2000 + 500 jitter = 12_500) — the heartbeat
|
|
1017
|
+
// re-drives the deferred finalize and the terminal edit lands.
|
|
1018
|
+
clock = 13_000
|
|
1019
|
+
feed.heartbeatTick()
|
|
1020
|
+
await drain()
|
|
1021
|
+
expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
|
|
1022
|
+
expect(feed.has('w1')).toBe(false) // handle dropped after the terminal edit landed
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
it('#2: "message is not modified" on finish is treated as success (card already correct)', async () => {
|
|
1026
|
+
const bot = makeFakeBot()
|
|
1027
|
+
let clock = 10_000
|
|
1028
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1029
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
|
|
1030
|
+
expect(bot.sent).toHaveLength(1)
|
|
1031
|
+
|
|
1032
|
+
bot.failNextEditWith = new Error('Bad Request: message is not modified')
|
|
1033
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'x' }))
|
|
1034
|
+
await drain()
|
|
1035
|
+
// The handle is dropped — the not-modified outcome is success, no retry.
|
|
1036
|
+
expect(feed.has('w1')).toBe(false)
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
it('#2: a gone message on finish drops the handle silently (no card to finalize)', async () => {
|
|
1040
|
+
const bot = makeFakeBot()
|
|
1041
|
+
const logs: string[] = []
|
|
1042
|
+
let clock = 10_000
|
|
1043
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0, log: (m) => logs.push(m) })
|
|
1044
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
|
|
1045
|
+
expect(bot.sent).toHaveLength(1)
|
|
1046
|
+
|
|
1047
|
+
bot.failNextEditWith = new Error('Bad Request: message to edit not found')
|
|
1048
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done' }))
|
|
1049
|
+
await drain()
|
|
1050
|
+
expect(feed.has('w1')).toBe(false)
|
|
1051
|
+
// No scary "finish edit failed" warning for a transport-gone outcome.
|
|
1052
|
+
expect(logs.some((l) => l.includes('finish edit failed'))).toBe(false)
|
|
1053
|
+
})
|
|
1054
|
+
})
|
|
1055
|
+
|
|
948
1056
|
// ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
|
|
949
1057
|
// Reproduces the bug where accumulateNarrative's char-budget splice would push
|
|
950
1058
|
// the oversized line then immediately splice it out, making the narrative empty
|