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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildPersonDirectory,
|
|
4
|
+
resolvePersonName,
|
|
5
|
+
runPersonDirectoryBootCheck,
|
|
6
|
+
safeResolvePersonName,
|
|
7
|
+
type PersonDirectory,
|
|
8
|
+
type RawPersonEntry,
|
|
9
|
+
} from '../gateway/resolve-person.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Telegram person_id name resolution (docs/configuration.md).
|
|
13
|
+
*
|
|
14
|
+
* Covers the requirements from the converged design:
|
|
15
|
+
* - happy-path resolution
|
|
16
|
+
* - unresolved-id fallback (fail-open)
|
|
17
|
+
* - a malformed single entry is dropped WITHOUT affecting others
|
|
18
|
+
* - chat-scoping: a name is shown only in a chat/group the person is a
|
|
19
|
+
* member of (per that chat's allowFrom), never broadcast into every
|
|
20
|
+
* chat
|
|
21
|
+
* - boot-time validation alert firing on bad config
|
|
22
|
+
* - validation-step-crash alert firing (dead-man's-switch)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const LISA: RawPersonEntry = { key: 'lisa', person_id: 'Lisa', telegram_ids: ['8201250670'] }
|
|
26
|
+
const KEN: RawPersonEntry = { key: 'ken', person_id: 'Ken', telegram_ids: ['mekenthompson', '111'] }
|
|
27
|
+
|
|
28
|
+
describe('buildPersonDirectory — happy path', () => {
|
|
29
|
+
it('resolves a single well-formed entry', () => {
|
|
30
|
+
const { directory, dropped } = buildPersonDirectory([LISA])
|
|
31
|
+
expect(dropped).toEqual([])
|
|
32
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('resolves multiple entries, multiple telegram_ids each, case/@-insensitive', () => {
|
|
36
|
+
const { directory, dropped } = buildPersonDirectory([LISA, KEN])
|
|
37
|
+
expect(dropped).toEqual([])
|
|
38
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
39
|
+
expect(directory.byTelegramKey['mekenthompson']?.personId).toBe('Ken')
|
|
40
|
+
expect(directory.byTelegramKey['111']?.personId).toBe('Ken')
|
|
41
|
+
// username lookups are normalized: leading "@" stripped, lowercased
|
|
42
|
+
expect(directory.byTelegramKey['@MeKenThompson'.trim().replace(/^@/, '').toLowerCase()]?.personId).toBe('Ken')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('no entries → empty directory (fleet behaves as today)', () => {
|
|
46
|
+
const { directory, dropped } = buildPersonDirectory([])
|
|
47
|
+
expect(directory.byTelegramKey).toEqual({})
|
|
48
|
+
expect(dropped).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('buildPersonDirectory — per-entry validation drops only the bad entry', () => {
|
|
53
|
+
it('drops an entry with an empty person_id, keeps the other entry intact', () => {
|
|
54
|
+
const bad: RawPersonEntry = { key: 'ghost', person_id: '', telegram_ids: ['999'] }
|
|
55
|
+
const { directory, dropped } = buildPersonDirectory([LISA, bad])
|
|
56
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
57
|
+
expect(directory.byTelegramKey['999']).toBeUndefined()
|
|
58
|
+
expect(dropped).toHaveLength(1)
|
|
59
|
+
expect(dropped[0]?.key).toBe('ghost')
|
|
60
|
+
expect(dropped[0]?.reason).toMatch(/person_id/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('drops an entry with no telegram_ids, keeps the other entry intact', () => {
|
|
64
|
+
const bad: RawPersonEntry = { key: 'ghost', person_id: 'Ghost', telegram_ids: [] }
|
|
65
|
+
const { directory, dropped } = buildPersonDirectory([LISA, bad])
|
|
66
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
67
|
+
expect(dropped).toHaveLength(1)
|
|
68
|
+
expect(dropped[0]?.key).toBe('ghost')
|
|
69
|
+
expect(dropped[0]?.reason).toMatch(/telegram_ids/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('drops a duplicate person_id (later entry loses), keeps the first entry intact', () => {
|
|
73
|
+
const dupe: RawPersonEntry = { key: 'lisa2', person_id: 'Lisa', telegram_ids: ['555'] }
|
|
74
|
+
const { directory, dropped } = buildPersonDirectory([LISA, dupe])
|
|
75
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
76
|
+
expect(directory.byTelegramKey['555']).toBeUndefined()
|
|
77
|
+
expect(dropped).toHaveLength(1)
|
|
78
|
+
expect(dropped[0]?.key).toBe('lisa2')
|
|
79
|
+
expect(dropped[0]?.reason).toMatch(/duplicate person_id/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('drops a duplicate telegram_id claimed by a different person_id (later entry loses), keeps the first entry intact — no silent last-write-wins', () => {
|
|
83
|
+
const dupe: RawPersonEntry = { key: 'imposter', person_id: 'Imposter', telegram_ids: ['8201250670'] }
|
|
84
|
+
const { directory, dropped } = buildPersonDirectory([LISA, dupe])
|
|
85
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
86
|
+
expect(dropped).toHaveLength(1)
|
|
87
|
+
expect(dropped[0]?.key).toBe('imposter')
|
|
88
|
+
expect(dropped[0]?.reason).toMatch(/duplicate telegram_id/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('telegram_id collision on boot check fires the config-warning alert path, does not blank the whole directory', () => {
|
|
92
|
+
const dupe: RawPersonEntry = { key: 'imposter', person_id: 'Imposter', telegram_ids: ['8201250670'] }
|
|
93
|
+
const result = runPersonDirectoryBootCheck(() => [LISA, KEN, dupe])
|
|
94
|
+
expect(result.alertDetail).not.toBeNull()
|
|
95
|
+
expect(result.alertDetail).toContain('imposter')
|
|
96
|
+
expect(result.directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
97
|
+
expect(result.directory.byTelegramKey['mekenthompson']?.personId).toBe('Ken')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ── #2957 honesty fix: broadcast alertDetail must not surface the
|
|
101
|
+
// person_id human-name value (the display path chat-scopes names;
|
|
102
|
+
// the config-warning card fans out to EVERY allowFrom chat, incl.
|
|
103
|
+
// groups where the named person may not be a member). The verbose
|
|
104
|
+
// reason stays in the private logLine. ─────────────────────────
|
|
105
|
+
it('duplicate person_id: alertDetail scrubs the person_id value (keeps key + class); logLine keeps it', () => {
|
|
106
|
+
const dupe: RawPersonEntry = { key: 'lisa2', person_id: 'Lisa', telegram_ids: ['555'] }
|
|
107
|
+
const result = runPersonDirectoryBootCheck(() => [LISA, dupe])
|
|
108
|
+
expect(result.alertDetail).not.toBeNull()
|
|
109
|
+
// Broadcast-safe: names the losing config key + the collision class,
|
|
110
|
+
// but NOT the human-name "Lisa" value nor which name it collided with.
|
|
111
|
+
expect(result.alertDetail).toContain('lisa2')
|
|
112
|
+
expect(result.alertDetail).toContain('duplicate person_id already claimed')
|
|
113
|
+
expect(result.alertDetail).not.toContain('"Lisa"')
|
|
114
|
+
// Private stderr log keeps the verbose detail.
|
|
115
|
+
expect(result.logLine).toContain('"Lisa"')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('duplicate telegram_id: alertDetail scrubs the colliding id; logLine keeps it', () => {
|
|
119
|
+
const dupe: RawPersonEntry = { key: 'imposter', person_id: 'Imposter', telegram_ids: ['8201250670'] }
|
|
120
|
+
const result = runPersonDirectoryBootCheck(() => [LISA, dupe])
|
|
121
|
+
expect(result.alertDetail).not.toBeNull()
|
|
122
|
+
expect(result.alertDetail).toContain('imposter')
|
|
123
|
+
expect(result.alertDetail).toContain('duplicate telegram_id already claimed')
|
|
124
|
+
// The colliding telegram id value is scrubbed from the broadcast.
|
|
125
|
+
expect(result.alertDetail).not.toContain('8201250670')
|
|
126
|
+
expect(result.logLine).toContain('8201250670')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('drops an entry with a missing users: map key', () => {
|
|
130
|
+
const bad: RawPersonEntry = { key: '', person_id: 'Nobody', telegram_ids: ['1'] }
|
|
131
|
+
const { directory, dropped } = buildPersonDirectory([LISA, bad])
|
|
132
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
133
|
+
expect(dropped).toHaveLength(1)
|
|
134
|
+
expect(dropped[0]?.reason).toMatch(/users: map key/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('multiple malformed entries each get their own drop reason, valid entries unaffected', () => {
|
|
138
|
+
const bad1: RawPersonEntry = { key: 'a', person_id: '', telegram_ids: ['1'] }
|
|
139
|
+
const bad2: RawPersonEntry = { key: 'b', person_id: 'B', telegram_ids: [] }
|
|
140
|
+
const { directory, dropped } = buildPersonDirectory([LISA, bad1, KEN, bad2])
|
|
141
|
+
expect(dropped).toHaveLength(2)
|
|
142
|
+
expect(directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
143
|
+
expect(directory.byTelegramKey['mekenthompson']?.personId).toBe('Ken')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('resolvePersonName — unresolved id falls back (fail-open)', () => {
|
|
148
|
+
it('returns undefined for an id not in the directory', () => {
|
|
149
|
+
const { directory } = buildPersonDirectory([LISA])
|
|
150
|
+
const name = resolvePersonName(directory, { telegramId: '000', isDm: true })
|
|
151
|
+
expect(name).toBeUndefined()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('returns undefined for an empty directory', () => {
|
|
155
|
+
const { directory } = buildPersonDirectory([])
|
|
156
|
+
const name = resolvePersonName(directory, { telegramId: '8201250670', isDm: true })
|
|
157
|
+
expect(name).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('resolvePersonName — chat scoping', () => {
|
|
162
|
+
const { directory } = buildPersonDirectory([LISA])
|
|
163
|
+
|
|
164
|
+
it('DM: always resolves (the chat IS the sender)', () => {
|
|
165
|
+
const name = resolvePersonName(directory, { telegramId: '8201250670', isDm: true })
|
|
166
|
+
expect(name).toBe('Lisa')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('group: resolves when the sender is present in that chat\'s allowFrom', () => {
|
|
170
|
+
const name = resolvePersonName(directory, {
|
|
171
|
+
telegramId: '8201250670',
|
|
172
|
+
isDm: false,
|
|
173
|
+
groupAllowFrom: ['8201250670', '111'],
|
|
174
|
+
})
|
|
175
|
+
expect(name).toBe('Lisa')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('group: does NOT resolve when allowFrom is empty/unset (can\'t confirm membership)', () => {
|
|
179
|
+
expect(resolvePersonName(directory, { telegramId: '8201250670', isDm: false })).toBeUndefined()
|
|
180
|
+
expect(resolvePersonName(directory, { telegramId: '8201250670', isDm: false, groupAllowFrom: [] })).toBeUndefined()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('group: does NOT resolve when the sender is absent from that group\'s allowFrom (a name safe in a DM is not broadcast elsewhere)', () => {
|
|
184
|
+
const name = resolvePersonName(directory, {
|
|
185
|
+
telegramId: '8201250670',
|
|
186
|
+
isDm: false,
|
|
187
|
+
groupAllowFrom: ['111'], // some other member, not lisa
|
|
188
|
+
})
|
|
189
|
+
expect(name).toBeUndefined()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('group: resolves via username when username (not id) is in allowFrom', () => {
|
|
193
|
+
const { directory: dir2 } = buildPersonDirectory([KEN])
|
|
194
|
+
const name = resolvePersonName(dir2, {
|
|
195
|
+
telegramId: '999999',
|
|
196
|
+
username: 'mekenthompson',
|
|
197
|
+
isDm: false,
|
|
198
|
+
groupAllowFrom: ['mekenthompson'],
|
|
199
|
+
})
|
|
200
|
+
expect(name).toBe('Ken')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('safeResolvePersonName — fail-open call-site wrapper used by handleInbound', () => {
|
|
205
|
+
it('happy path: resolves same as resolvePersonName when nothing throws', () => {
|
|
206
|
+
const { directory } = buildPersonDirectory([LISA])
|
|
207
|
+
const name = safeResolvePersonName(directory, { telegramId: '8201250670', isDm: true }, 'raw-fallback')
|
|
208
|
+
expect(name).toBe('Lisa')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('unresolved id: falls back to the raw id/username, same as today\'s behavior', () => {
|
|
212
|
+
const { directory } = buildPersonDirectory([LISA])
|
|
213
|
+
const name = safeResolvePersonName(directory, { telegramId: '000', isDm: true }, 'raw-fallback')
|
|
214
|
+
expect(name).toBe('raw-fallback')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('resolution throwing (e.g. a malformed opts shape at the call site) is swallowed — falls back to the raw id/username instead of propagating and aborting handleInbound', () => {
|
|
218
|
+
const { directory } = buildPersonDirectory([LISA])
|
|
219
|
+
// Force resolvePersonName's internal normalizeTelegramKey to throw by
|
|
220
|
+
// passing a non-string telegramId past the type system, simulating an
|
|
221
|
+
// unexpected runtime shape from the Telegram payload.
|
|
222
|
+
const name = safeResolvePersonName(
|
|
223
|
+
directory,
|
|
224
|
+
{ telegramId: undefined as unknown as string, isDm: true },
|
|
225
|
+
'raw-fallback',
|
|
226
|
+
)
|
|
227
|
+
expect(name).toBe('raw-fallback')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('a directory whose lookup throws is also swallowed, not propagated', () => {
|
|
231
|
+
const throwingDirectory: PersonDirectory = {
|
|
232
|
+
byTelegramKey: new Proxy(
|
|
233
|
+
{},
|
|
234
|
+
{
|
|
235
|
+
get() {
|
|
236
|
+
throw new Error('boom: directory lookup exploded')
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
),
|
|
240
|
+
}
|
|
241
|
+
const spy = vi.fn()
|
|
242
|
+
try {
|
|
243
|
+
const name = safeResolvePersonName(throwingDirectory, { telegramId: '8201250670', isDm: true }, 'raw-fallback')
|
|
244
|
+
expect(name).toBe('raw-fallback')
|
|
245
|
+
} catch (err) {
|
|
246
|
+
spy(err)
|
|
247
|
+
}
|
|
248
|
+
expect(spy).not.toHaveBeenCalled()
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('runPersonDirectoryBootCheck — one-time boot validation + alerting', () => {
|
|
253
|
+
it('happy path: no alert, directory populated', () => {
|
|
254
|
+
const result = runPersonDirectoryBootCheck(() => [LISA, KEN])
|
|
255
|
+
expect(result.alertDetail).toBeNull()
|
|
256
|
+
expect(result.directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
257
|
+
expect(result.logLine).toMatch(/boot validation ok/)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('no entries configured: no alert (this is not an error)', () => {
|
|
261
|
+
const result = runPersonDirectoryBootCheck(() => [])
|
|
262
|
+
expect(result.alertDetail).toBeNull()
|
|
263
|
+
expect(result.directory.byTelegramKey).toEqual({})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('dropped entry at boot fires an alert (routes through the caller\'s fleet-alert path) and still resolves the good entries', () => {
|
|
267
|
+
const bad: RawPersonEntry = { key: 'ghost', person_id: '', telegram_ids: ['1'] }
|
|
268
|
+
const result = runPersonDirectoryBootCheck(() => [LISA, bad])
|
|
269
|
+
expect(result.alertDetail).not.toBeNull()
|
|
270
|
+
expect(result.alertDetail).toContain('dropped 1')
|
|
271
|
+
expect(result.alertDetail).toContain('ghost')
|
|
272
|
+
expect(result.directory.byTelegramKey['8201250670']?.personId).toBe('Lisa')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('dead-man\'s-switch: readEntries throwing is caught, reported via alertDetail, and PERSON_DIRECTORY falls back to empty (fail-open)', () => {
|
|
276
|
+
const result = runPersonDirectoryBootCheck(() => {
|
|
277
|
+
throw new Error('disk read exploded')
|
|
278
|
+
})
|
|
279
|
+
expect(result.alertDetail).not.toBeNull()
|
|
280
|
+
expect(result.alertDetail).toContain('crashed')
|
|
281
|
+
// alertDetail is broadcast to every allowFrom chat (incl. groups), so
|
|
282
|
+
// the raw error message — which may carry a host path or other detail
|
|
283
|
+
// — is scrubbed from it. The full message stays in the private logLine.
|
|
284
|
+
expect(result.alertDetail).not.toContain('disk read exploded')
|
|
285
|
+
expect(result.alertDetail).toContain('see gateway stderr for detail')
|
|
286
|
+
expect(result.directory.byTelegramKey).toEqual({})
|
|
287
|
+
expect(result.logLine).toContain('CRASHED')
|
|
288
|
+
expect(result.logLine).toContain('disk read exploded')
|
|
289
|
+
})
|
|
290
|
+
})
|
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
humanizeElapsed,
|
|
19
19
|
buildResumeInterruptedInbound,
|
|
20
20
|
buildResumeWatchdogReportInbound,
|
|
21
|
+
buildResumeDeferredReportInbound,
|
|
22
|
+
isResumeSyntheticTurn,
|
|
21
23
|
selectResumeBuilder,
|
|
24
|
+
decideBootResumeKind,
|
|
25
|
+
RESUME_SYNTHETIC_PROMPT_PREFIX,
|
|
26
|
+
renderInterruptedSubagentsBlock,
|
|
27
|
+
type InterruptedSubagent,
|
|
22
28
|
} from '../gateway/resume-inbound-builder.js'
|
|
23
29
|
import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
|
|
24
30
|
|
|
@@ -158,6 +164,155 @@ describe('buildResumeInterruptedInbound', () => {
|
|
|
158
164
|
})
|
|
159
165
|
})
|
|
160
166
|
|
|
167
|
+
describe('interrupted sub-agent block', () => {
|
|
168
|
+
const twoRunning: InterruptedSubagent[] = [
|
|
169
|
+
{ agentType: 'worker', description: 'refactor the auth module and add tests', status: 'running' },
|
|
170
|
+
{ agentType: 'researcher', description: 'survey the pricing pages of 5 competitors', status: 'running' },
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
it('renderInterruptedSubagentsBlock lists each worker with type + prompt', () => {
|
|
174
|
+
const block = renderInterruptedSubagentsBlock(twoRunning)
|
|
175
|
+
expect(block).toContain('2 sub-agents were still')
|
|
176
|
+
expect(block).toContain('did NOT complete')
|
|
177
|
+
expect(block).toContain('[worker]')
|
|
178
|
+
expect(block).toContain('refactor the auth module and add tests')
|
|
179
|
+
expect(block).toContain('[researcher]')
|
|
180
|
+
expect(block).toContain('survey the pricing pages of 5 competitors')
|
|
181
|
+
expect(block).toContain('Re-dispatch the ones still needed')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('returns empty string when there are no in-flight sub-agents', () => {
|
|
185
|
+
expect(renderInterruptedSubagentsBlock(undefined)).toBe('')
|
|
186
|
+
expect(renderInterruptedSubagentsBlock([])).toBe('')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('interrupted-turn inbound contains BOTH running sub-agents', () => {
|
|
190
|
+
const msg = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
|
|
191
|
+
expect(msg.text).toContain('refactor the auth module and add tests')
|
|
192
|
+
expect(msg.text).toContain('survey the pricing pages of 5 competitors')
|
|
193
|
+
expect(msg.text).toContain('did NOT complete')
|
|
194
|
+
expect(msg.text).toContain('Re-dispatch the ones still needed')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('interrupted-turn inbound is UNCHANGED when no sub-agents were running', () => {
|
|
198
|
+
const withNone = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: [] })
|
|
199
|
+
const bare = buildResumeInterruptedInbound({ turn: makeTurn() })
|
|
200
|
+
expect(withNone.text).toBe(bare.text)
|
|
201
|
+
expect(withNone.text).not.toContain('did NOT complete')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('truncates each dispatch prompt to ~200 chars', () => {
|
|
205
|
+
const long = 'x'.repeat(400)
|
|
206
|
+
const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: long }])
|
|
207
|
+
// The 400-char prompt must not survive in full; the entry line is capped.
|
|
208
|
+
expect(block).not.toContain(long)
|
|
209
|
+
expect(block).toContain('…')
|
|
210
|
+
// 200-char cap → the truncated slice (199 chars + ellipsis) is present.
|
|
211
|
+
expect(block).toContain('x'.repeat(199))
|
|
212
|
+
expect(block).not.toContain('x'.repeat(201))
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('caps the list at 10 and summarises the remainder', () => {
|
|
216
|
+
const many: InterruptedSubagent[] = Array.from({ length: 14 }, (_, i) => ({
|
|
217
|
+
agentType: 'worker',
|
|
218
|
+
description: `task number ${i + 1}`,
|
|
219
|
+
}))
|
|
220
|
+
const block = renderInterruptedSubagentsBlock(many)
|
|
221
|
+
expect(block).toContain('14 sub-agents were still')
|
|
222
|
+
expect(block).toContain('task number 10')
|
|
223
|
+
expect(block).not.toContain('task number 11')
|
|
224
|
+
expect(block).toContain('…and 4 more.')
|
|
225
|
+
// Exactly 10 numbered entries rendered.
|
|
226
|
+
const numbered = block.match(/^\s+\d+\. \[/gm) ?? []
|
|
227
|
+
expect(numbered.length).toBe(10)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('falls back to a generic label + placeholder when type/prompt are missing', () => {
|
|
231
|
+
const block = renderInterruptedSubagentsBlock([{ agentType: null, description: null }])
|
|
232
|
+
expect(block).toContain('[sub-agent]')
|
|
233
|
+
expect(block).toContain('(no task description recorded)')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('singularises a single killed sub-agent', () => {
|
|
237
|
+
const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: 'do X' }])
|
|
238
|
+
expect(block).toContain('1 sub-agent was still')
|
|
239
|
+
expect(block).not.toContain('1 sub-agents')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('codepoint-safe truncation never splits a surrogate pair', () => {
|
|
243
|
+
// 199 ASCII chars, then an astral-plane emoji (2 UTF-16 code units)
|
|
244
|
+
// straddling the cap. A code-unit slice would cut the pair in half and
|
|
245
|
+
// leave a lone surrogate; the codepoint-safe slice must not.
|
|
246
|
+
const desc = 'a'.repeat(199) + '🚀' + 'b'.repeat(50)
|
|
247
|
+
const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: desc }])
|
|
248
|
+
expect(block).toContain('…')
|
|
249
|
+
// No lone surrogates anywhere in the rendered block.
|
|
250
|
+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(block)).toBe(false)
|
|
251
|
+
expect(block).toBe(block.normalize('NFC')) // sanity: still a well-formed string
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('the watchdog report inbound carries the block in DEFERRED form (ask-first contract)', () => {
|
|
255
|
+
const msg = buildResumeWatchdogReportInbound({
|
|
256
|
+
turn: makeTurn({ ended_via: 'timeout' }),
|
|
257
|
+
idleMs: 300_000,
|
|
258
|
+
subagents: twoRunning,
|
|
259
|
+
})
|
|
260
|
+
expect(msg.text).toContain('did NOT complete')
|
|
261
|
+
expect(msg.text).toContain('refactor the auth module and add tests')
|
|
262
|
+
// Deferred wording: re-dispatch is conditional on the user asking to retry…
|
|
263
|
+
expect(msg.text).toContain("If the user asks you to retry, they'll need re-dispatching")
|
|
264
|
+
expect(msg.text).toContain("don't assume their work landed")
|
|
265
|
+
// …and the resume-path imperative must NOT appear — it would contradict
|
|
266
|
+
// the watchdog inbound's "Do NOT silently resume … ask" hang-safety gate.
|
|
267
|
+
expect(msg.text).not.toContain('Re-dispatch the ones still needed')
|
|
268
|
+
expect(msg.text).not.toContain('before declaring the task done')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('the resume-path inbound keeps the assertive imperative (and not the deferred form)', () => {
|
|
272
|
+
const msg = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
|
|
273
|
+
expect(msg.text).toContain('Re-dispatch the ones still needed before declaring the task done')
|
|
274
|
+
expect(msg.text).not.toContain('If the user asks you to retry')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
for (const reason of ['loop-guard', 'clean-restart-suppressed'] as const) {
|
|
278
|
+
it(`the deferred report (${reason}) carries the block in DEFERRED form — suppressed resumes still name worker deaths`, () => {
|
|
279
|
+
const msg = buildResumeDeferredReportInbound({
|
|
280
|
+
turn: makeTurn(),
|
|
281
|
+
reason,
|
|
282
|
+
subagents: twoRunning,
|
|
283
|
+
})
|
|
284
|
+
expect(msg.text).toContain('did NOT complete')
|
|
285
|
+
expect(msg.text).toContain('refactor the auth module and add tests')
|
|
286
|
+
expect(msg.text).toContain('survey the pricing pages of 5 competitors')
|
|
287
|
+
// Deferred wording only — never the resume-path imperative, which would
|
|
288
|
+
// contradict this inbound's "Do NOT silently resume … ask" contract.
|
|
289
|
+
expect(msg.text).toContain("If the user asks you to retry, they'll need re-dispatching")
|
|
290
|
+
expect(msg.text).not.toContain('Re-dispatch the ones still needed')
|
|
291
|
+
expect(msg.text).not.toContain('before declaring the task done')
|
|
292
|
+
// Appending the block must not disturb the loop-guard anchor at pos 0.
|
|
293
|
+
expect(msg.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it('deferred report without subagents is unchanged (no block)', () => {
|
|
298
|
+
const withNone = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard', subagents: [] })
|
|
299
|
+
const bare = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
|
|
300
|
+
expect(withNone.text).toBe(bare.text)
|
|
301
|
+
expect(withNone.text).not.toContain('did NOT complete')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('appending the block preserves the RESUME_SYNTHETIC_PROMPT_PREFIX anchor on resume + watchdog inbounds too', () => {
|
|
305
|
+
const resume = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
|
|
306
|
+
expect(resume.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
|
|
307
|
+
const report = buildResumeWatchdogReportInbound({
|
|
308
|
+
turn: makeTurn({ ended_via: 'timeout' }),
|
|
309
|
+
idleMs: 300_000,
|
|
310
|
+
subagents: twoRunning,
|
|
311
|
+
})
|
|
312
|
+
expect(report.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
161
316
|
describe('buildResumeWatchdogReportInbound', () => {
|
|
162
317
|
it('sets the resume_watchdog_timeout source and idle_ms passthrough', () => {
|
|
163
318
|
const turn = makeTurn({ ended_via: 'timeout' })
|
|
@@ -243,3 +398,134 @@ describe('selectResumeBuilder', () => {
|
|
|
243
398
|
expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
|
|
244
399
|
})
|
|
245
400
|
})
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Loop-guard: RESUME_SYNTHETIC_PROMPT_PREFIX / isResumeSyntheticTurn
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
describe('resume synthetic-turn detection (loop-guard anchor)', () => {
|
|
407
|
+
it('EVERY synthetic boot inbound text starts with the machine-stable prefix', () => {
|
|
408
|
+
// The loop-guard keys on this prefix landing in user_prompt_preview, so if
|
|
409
|
+
// a prose edit drops it the chain-cap silently breaks. Pin it here.
|
|
410
|
+
const turn = makeTurn({ user_prompt_preview: 'do the thing' })
|
|
411
|
+
expect(buildResumeInterruptedInbound({ turn }).text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
|
|
412
|
+
expect(
|
|
413
|
+
buildResumeWatchdogReportInbound({ turn: makeTurn({ ended_via: 'timeout' }), idleMs: 1000 }).text.startsWith(
|
|
414
|
+
RESUME_SYNTHETIC_PROMPT_PREFIX,
|
|
415
|
+
),
|
|
416
|
+
).toBe(true)
|
|
417
|
+
expect(
|
|
418
|
+
buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' }).text.startsWith(
|
|
419
|
+
RESUME_SYNTHETIC_PROMPT_PREFIX,
|
|
420
|
+
),
|
|
421
|
+
).toBe(true)
|
|
422
|
+
expect(
|
|
423
|
+
buildResumeDeferredReportInbound({ turn, reason: 'clean-restart-suppressed' }).text.startsWith(
|
|
424
|
+
RESUME_SYNTHETIC_PROMPT_PREFIX,
|
|
425
|
+
),
|
|
426
|
+
).toBe(true)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('isResumeSyntheticTurn is true for a turn whose preview is a synthetic prompt', () => {
|
|
430
|
+
const synthetic = buildResumeInterruptedInbound({ turn: makeTurn() })
|
|
431
|
+
// A resume turn stores the first ~200 chars of the synthetic text as its
|
|
432
|
+
// preview (channel wrapper stripped) — simulate that.
|
|
433
|
+
const resumeTurn = makeTurn({ user_prompt_preview: synthetic.text.slice(0, 200) })
|
|
434
|
+
expect(isResumeSyntheticTurn(resumeTurn)).toBe(true)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('isResumeSyntheticTurn is false for real user work and for null preview', () => {
|
|
438
|
+
expect(isResumeSyntheticTurn(makeTurn({ user_prompt_preview: 'refactor the auth module' }))).toBe(false)
|
|
439
|
+
expect(isResumeSyntheticTurn(makeTurn({ user_prompt_preview: null }))).toBe(false)
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// buildResumeDeferredReportInbound
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('buildResumeDeferredReportInbound', () => {
|
|
448
|
+
it('emits source=resume_deferred and carries the dedup anchor + reason', () => {
|
|
449
|
+
const turn = makeTurn({ turn_key: 'zz:3' })
|
|
450
|
+
const msg = buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' })
|
|
451
|
+
expect(msg.meta.source).toBe('resume_deferred')
|
|
452
|
+
expect(msg.meta.resume_turn_key).toBe('zz:3')
|
|
453
|
+
expect(msg.meta.defer_reason).toBe('loop-guard')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('loop-guard framing tells the model the chain was capped, not to resume', () => {
|
|
457
|
+
const msg = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
|
|
458
|
+
expect(msg.text.toLowerCase()).toContain('resume of earlier interrupted work')
|
|
459
|
+
expect(msg.text).toContain('Do NOT silently resume')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('clean-restart-suppressed framing cites boot_resume: never', () => {
|
|
463
|
+
const msg = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'clean-restart-suppressed' })
|
|
464
|
+
expect(msg.text).toContain('boot_resume: never')
|
|
465
|
+
expect(msg.text).toContain('Do NOT silently resume')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('routes to the origin thread when the turn carried one', () => {
|
|
469
|
+
const turn = makeTurn({ chat_id: '-100999', thread_id: '77' })
|
|
470
|
+
const msg = buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' })
|
|
471
|
+
expect(msg.chatId).toBe('-100999')
|
|
472
|
+
expect(msg.threadId).toBe(77)
|
|
473
|
+
expect(msg.meta.message_thread_id).toBe('77')
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// decideBootResumeKind — boot-resume precedence + bounded resume chain
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
describe('decideBootResumeKind', () => {
|
|
482
|
+
const MAX = 10_800_000 // 3h
|
|
483
|
+
|
|
484
|
+
it('resumes genuinely in-flight work after a deliberate restart (not suppressed)', () => {
|
|
485
|
+
// The core product fix: clean restart mid-turn → resume, not silence.
|
|
486
|
+
const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'ship the release' })
|
|
487
|
+
expect(decideBootResumeKind({ pending, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it("boot_resume:never (suppressed) → 'defer-suppressed' passive report, never silence", () => {
|
|
491
|
+
const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'ship the release' })
|
|
492
|
+
expect(decideBootResumeKind({ pending, suppressed: true, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-suppressed')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('watchdog timeout still reports even when not suppressed', () => {
|
|
496
|
+
const pending = makeTurn({ ended_via: 'timeout', user_prompt_preview: 'ship the release' })
|
|
497
|
+
expect(decideBootResumeKind({ pending, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('report')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('BOUNDED CHAIN: a restart DURING a resume turn does NOT re-resume — loop-guard fires', () => {
|
|
501
|
+
// Simulate the chain the coordinator flagged:
|
|
502
|
+
// A: real work, interrupted → resume R1 (turn B created from R1's text)
|
|
503
|
+
// restart lands during B → pending is B, whose preview IS the synthetic
|
|
504
|
+
// resume prompt. We must NOT mint a second resume (endless loop risk).
|
|
505
|
+
const r1 = buildResumeInterruptedInbound({ turn: makeTurn({ user_prompt_preview: 'real work' }) })
|
|
506
|
+
const turnB = makeTurn({ ended_via: 'restart', user_prompt_preview: r1.text.slice(0, 200) })
|
|
507
|
+
// Even though NOT suppressed (would normally resume), the loop-guard wins:
|
|
508
|
+
expect(decideBootResumeKind({ pending: turnB, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-loop')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('BOUNDED CHAIN: a restart during a deferred-report turn stays capped (still defer-loop, never resume)', () => {
|
|
512
|
+
// Depth does not grow: a report-of-report is still a passive report, never
|
|
513
|
+
// a resume — so no unbounded restart→resume→restart chain can form.
|
|
514
|
+
const deferred = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
|
|
515
|
+
const turnC = makeTurn({ ended_via: 'restart', user_prompt_preview: deferred.text.slice(0, 200) })
|
|
516
|
+
const kind = decideBootResumeKind({ pending: turnC, suppressed: false, ageMs: 1000, maxAgeMs: MAX })
|
|
517
|
+
expect(kind).toBe('defer-loop')
|
|
518
|
+
expect(kind).not.toBe('resume')
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('loop-guard takes precedence over suppression too (synthetic turn never resumes or double-reports as resume)', () => {
|
|
522
|
+
const r1 = buildResumeInterruptedInbound({ turn: makeTurn() })
|
|
523
|
+
const turnB = makeTurn({ ended_via: 'restart', user_prompt_preview: r1.text.slice(0, 200) })
|
|
524
|
+
expect(decideBootResumeKind({ pending: turnB, suppressed: true, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-loop')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('stale in-flight work downgrades to report when older than maxAgeMs', () => {
|
|
528
|
+
const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'stale work' })
|
|
529
|
+
expect(decideBootResumeKind({ pending, suppressed: false, ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
530
|
+
})
|
|
531
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the /status session-model freshness contract (#2982 + live-model PR):
|
|
3
|
+
* the FRESHEST observation wins between the transcript's `message.model` and
|
|
4
|
+
* the /model override, arbitrated by a shared monotonic sequence. This is the
|
|
5
|
+
* "model display must never be stale" invariant:
|
|
6
|
+
*
|
|
7
|
+
* - override set AFTER the last transcript observation (idle-time /model
|
|
8
|
+
* switch, no assistant line yet) → /status shows the override;
|
|
9
|
+
* - a new assistant line after that → the transcript wins again.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { createSessionModelSource } from '../gateway/session-model-source.js'
|
|
13
|
+
|
|
14
|
+
describe('createSessionModelSource — freshest observation wins', () => {
|
|
15
|
+
it('returns null when neither source has reported', () => {
|
|
16
|
+
const s = createSessionModelSource()
|
|
17
|
+
expect(s.resolve()).toBeNull()
|
|
18
|
+
expect(s.getOverride()).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('transcript-only → transcript', () => {
|
|
22
|
+
const s = createSessionModelSource()
|
|
23
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
24
|
+
expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('override-only → override (fresh boot before the first assistant line)', () => {
|
|
28
|
+
const s = createSessionModelSource()
|
|
29
|
+
s.setOverride('sr-glm-5')
|
|
30
|
+
expect(s.resolve()).toEqual({ model: 'sr-glm-5', source: 'override' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('idle-after-switch window: an override set AFTER the last transcript line wins', () => {
|
|
34
|
+
// The #2982 regression this pins: /model switch while idle — the
|
|
35
|
+
// transcript still holds the OLD model, the override holds the NEW one.
|
|
36
|
+
const s = createSessionModelSource()
|
|
37
|
+
s.noteTranscriptModel('claude-opus-4-8') // old model's last assistant line
|
|
38
|
+
s.setOverride('Sonnet 5') // confirmed switch, no assistant line yet
|
|
39
|
+
expect(s.resolve()).toEqual({ model: 'Sonnet 5', source: 'override' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('a NEW assistant line after the switch reclaims the transcript as the source', () => {
|
|
43
|
+
const s = createSessionModelSource()
|
|
44
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
45
|
+
s.setOverride('Sonnet 5')
|
|
46
|
+
s.noteTranscriptModel('claude-sonnet-5') // first line under the new model
|
|
47
|
+
expect(s.resolve()).toEqual({ model: 'claude-sonnet-5', source: 'transcript' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clearing the override (null) falls back to the transcript', () => {
|
|
51
|
+
const s = createSessionModelSource()
|
|
52
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
53
|
+
s.setOverride('sr-glm-5')
|
|
54
|
+
s.setOverride(null)
|
|
55
|
+
expect(s.getOverride()).toBeNull()
|
|
56
|
+
expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('getOverride reports the override independent of freshness', () => {
|
|
60
|
+
const s = createSessionModelSource()
|
|
61
|
+
s.setOverride('sr-glm-5')
|
|
62
|
+
s.noteTranscriptModel('claude-opus-4-8') // transcript is now fresher
|
|
63
|
+
expect(s.resolve()?.source).toBe('transcript')
|
|
64
|
+
// ...but the override record itself is still readable (menu "session" marker).
|
|
65
|
+
expect(s.getOverride()).toBe('sr-glm-5')
|
|
66
|
+
})
|
|
67
|
+
})
|