switchroom 0.18.6 → 0.18.8
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 +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- 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/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- 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/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- 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/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -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/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -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.test.ts +46 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- 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 +54 -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/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structural pins for the session-
|
|
2
|
+
* Structural pins for the session-model stickiness wiring in gateway.ts
|
|
3
|
+
* (reference/rfcs/session-model-stickiness.md).
|
|
3
4
|
*
|
|
4
5
|
* The behaviour lives in un-exported inline closures (buildModelDeps's
|
|
5
|
-
* scheduleModelRelaunch, the
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
* tests/scaffold.session-model
|
|
10
|
-
*
|
|
6
|
+
* scheduleModelRelaunch/scheduleRestart, triggerSelfRestart, the /restart and
|
|
7
|
+
* /new handlers, the model-menu callback branches, and the boot re-hydration
|
|
8
|
+
* block inside the startup IIFE), so — mirroring the other gateway-*.test.ts
|
|
9
|
+
* source-pins — we assert on the source structure. The end-to-end behaviour
|
|
10
|
+
* of the boot resolver is exercised in tests/scaffold.session-model.test.ts
|
|
11
|
+
* (rendered start.sh), the file helpers in session-model-file.test.ts, and
|
|
12
|
+
* the handler contract in model-command.test.ts.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { describe, it, expect } from 'vitest'
|
|
@@ -18,52 +20,199 @@ import { dirname, resolve } from 'node:path'
|
|
|
18
20
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
21
|
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
20
22
|
|
|
21
|
-
describe('gateway:
|
|
22
|
-
it('
|
|
23
|
+
describe('gateway: triggerSelfRestart stamps relaunch-model intent BEFORE the kill', () => {
|
|
24
|
+
it('docker branch: writeRelaunchModelIntent(intentForRestartReason(reason)) precedes the SIGTERM scheduling', () => {
|
|
25
|
+
const fnIdx = GATEWAY_SRC.indexOf('function triggerSelfRestart(')
|
|
26
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
27
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
|
|
28
|
+
const writeIdx = win.indexOf('writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)')
|
|
29
|
+
const killIdx = win.indexOf("process.kill(1, 'SIGTERM')")
|
|
30
|
+
// Write-before-kill invariant: boot default is REVERT, so the intent must
|
|
31
|
+
// be on disk synchronously before the SIGTERM is even scheduled.
|
|
32
|
+
expect(writeIdx).toBeGreaterThan(0)
|
|
33
|
+
expect(killIdx).toBeGreaterThan(writeIdx)
|
|
34
|
+
// And before the setTimeout that schedules it.
|
|
35
|
+
const timeoutIdx = win.indexOf('setTimeout(')
|
|
36
|
+
expect(timeoutIdx).toBeGreaterThan(writeIdx)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('legacy systemd branch stamps intent too (self-target only)', () => {
|
|
40
|
+
const fnIdx = GATEWAY_SRC.indexOf('function triggerSelfRestart(')
|
|
41
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 4200)
|
|
42
|
+
const legacyIdx = win.indexOf('// Legacy systemd path.')
|
|
43
|
+
expect(legacyIdx).toBeGreaterThan(0)
|
|
44
|
+
const legacyWin = win.slice(legacyIdx)
|
|
45
|
+
expect(legacyWin).toContain('writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)')
|
|
46
|
+
expect(legacyWin.indexOf('writeRelaunchModelIntent')).toBeLessThan(legacyWin.indexOf('spawn('))
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
|
|
51
|
+
it('writes the durable file via writeSessionModelFile before dispatching the restart', () => {
|
|
23
52
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
24
53
|
expect(idx).toBeGreaterThan(0)
|
|
25
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
26
|
-
|
|
54
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
55
|
+
const writeIdx = win.indexOf('writeSessionModelFile(')
|
|
56
|
+
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
57
|
+
expect(writeIdx).toBeGreaterThan(0)
|
|
58
|
+
expect(restartIdx).toBeGreaterThan(writeIdx)
|
|
27
59
|
})
|
|
28
60
|
|
|
29
|
-
it('sets the in-memory
|
|
61
|
+
it('sets the in-memory session-model override before dispatching the restart', () => {
|
|
30
62
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
31
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
32
|
-
const setIdx = win.indexOf('
|
|
63
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
64
|
+
const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
|
|
33
65
|
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
34
66
|
expect(setIdx).toBeGreaterThan(0)
|
|
35
67
|
expect(restartIdx).toBeGreaterThan(setIdx)
|
|
36
68
|
})
|
|
37
69
|
|
|
70
|
+
it('rolls back the prior file content (not just deletion) on a non-in-flight dispatch failure', () => {
|
|
71
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
72
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
73
|
+
expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
|
|
74
|
+
expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
|
|
75
|
+
expect(win).toContain("!== 'restart_in_flight'")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('the non-in-flight rollback ALSO clears the keep-intent (a live intent with no restart coming would wrongly KEEP across a crash)', () => {
|
|
79
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
80
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
81
|
+
const inFlightIdx = win.indexOf("!== 'restart_in_flight'")
|
|
82
|
+
const clearIdx = win.indexOf('clearRelaunchModelIntent(agentDir)')
|
|
83
|
+
expect(inFlightIdx).toBeGreaterThan(0)
|
|
84
|
+
expect(clearIdx).toBeGreaterThan(inFlightIdx)
|
|
85
|
+
})
|
|
86
|
+
|
|
38
87
|
it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
|
|
39
88
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
40
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
89
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
41
90
|
expect(win).toContain('await deps.scheduleRestart(reason)')
|
|
42
91
|
})
|
|
43
92
|
})
|
|
44
93
|
|
|
45
|
-
describe('gateway:
|
|
94
|
+
describe('gateway: intent writers on the restart verbs', () => {
|
|
95
|
+
it('model-switch scheduleRestart stamps keep-intent before the hostd dispatch', () => {
|
|
96
|
+
const idx = GATEWAY_SRC.indexOf('scheduleRestart: async (reason: string)')
|
|
97
|
+
expect(idx).toBeGreaterThan(0)
|
|
98
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3200)
|
|
99
|
+
const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', reason)")
|
|
100
|
+
const dispatchIdx = win.indexOf("op: 'agent_restart'")
|
|
101
|
+
expect(keepIdx).toBeGreaterThan(0)
|
|
102
|
+
expect(dispatchIdx).toBeGreaterThan(keepIdx)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('scheduleRestart clears the keep-intent when hostd refuses (failed dispatch → no live intent on disk)', () => {
|
|
106
|
+
const idx = GATEWAY_SRC.indexOf('scheduleRestart: async (reason: string)')
|
|
107
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3200)
|
|
108
|
+
const failIdx = win.indexOf('hostd restart failed')
|
|
109
|
+
const clearIdx = win.indexOf('clearRelaunchModelIntent(smDir)')
|
|
110
|
+
expect(clearIdx).toBeGreaterThan(0)
|
|
111
|
+
expect(failIdx).toBeGreaterThan(clearIdx) // cleared before the throw's message
|
|
112
|
+
// And it sits in the same error branch as clearRestartMarker.
|
|
113
|
+
const markerIdx = win.indexOf('clearRestartMarker()')
|
|
114
|
+
expect(clearIdx).toBeGreaterThan(markerIdx)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('/restart stamps an explicit revert intent (reason honesty) before dispatch', () => {
|
|
118
|
+
const idx = GATEWAY_SRC.indexOf("stampUserRestartReason('user: /restart from chat')")
|
|
119
|
+
expect(idx).toBeGreaterThan(0)
|
|
120
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
121
|
+
const revertIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')")
|
|
122
|
+
const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
|
|
123
|
+
expect(revertIdx).toBeGreaterThan(0)
|
|
124
|
+
expect(dispatchIdx).toBeGreaterThan(revertIdx)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('/new and /reset stamp keep-intent (fresh conversation, same model — contract row 7)', () => {
|
|
128
|
+
const idx = GATEWAY_SRC.indexOf('stampUserRestartReason(`user: /${kind} from chat`)')
|
|
129
|
+
expect(idx).toBeGreaterThan(0)
|
|
130
|
+
const win = GATEWAY_SRC.slice(idx, idx + 700)
|
|
131
|
+
const keepIdx = win.indexOf("writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)")
|
|
132
|
+
const dispatchIdx = win.indexOf('tryHostdDispatch')
|
|
133
|
+
expect(keepIdx).toBeGreaterThan(0)
|
|
134
|
+
expect(dispatchIdx).toBeGreaterThan(keepIdx)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('gateway: model-menu callback persists the sticky override', () => {
|
|
139
|
+
it('a confirmed selection persists the canonical token (selectedModelToken), never the display label', () => {
|
|
140
|
+
const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
|
|
141
|
+
expect(idx).toBeGreaterThan(0)
|
|
142
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1600)
|
|
143
|
+
expect(win).toContain('outcome.selectedModelToken')
|
|
144
|
+
expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a confirmed "Default" selection CLEARS the sticky file', () => {
|
|
148
|
+
const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
|
|
149
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
150
|
+
expect(win).toContain('outcome.clearedDefault')
|
|
151
|
+
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
152
|
+
})
|
|
153
|
+
|
|
46
154
|
it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
|
|
47
155
|
const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
|
|
48
156
|
expect(idx).toBeGreaterThan(0)
|
|
49
157
|
const win = GATEWAY_SRC.slice(idx, idx + 1400)
|
|
50
158
|
expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
|
|
51
|
-
// It must return before falling through to handleModelMenuCallback (which
|
|
52
|
-
// would inject an sr-* id claude's picker rejects).
|
|
53
159
|
expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
|
|
54
160
|
})
|
|
161
|
+
|
|
162
|
+
it('the sr-to-claude transition writes the durable file (and clears it on a Default tap)', () => {
|
|
163
|
+
const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
|
|
164
|
+
expect(idx).toBeGreaterThan(0)
|
|
165
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3600)
|
|
166
|
+
expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
|
|
167
|
+
expect(win).toContain('clearSessionModelFile(agentDir)')
|
|
168
|
+
// Restart rides triggerSelfRestart with a keep-classified reason.
|
|
169
|
+
expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
|
|
170
|
+
})
|
|
55
171
|
})
|
|
56
172
|
|
|
57
|
-
describe('gateway
|
|
58
|
-
it('
|
|
173
|
+
describe('gateway: typed /model persists the REQUESTED canonical token', () => {
|
|
174
|
+
it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
|
|
175
|
+
const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
|
|
176
|
+
expect(idx).toBeGreaterThan(0)
|
|
177
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
178
|
+
expect(win).toContain("requested?.toLowerCase() === 'default'")
|
|
179
|
+
expect(win).toContain('sessionModelSource.setOverride(null)')
|
|
180
|
+
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
181
|
+
// Non-default: the requested token (shape-gated, non-sr) is what persists.
|
|
182
|
+
expect(win).toContain('isValidModelArg(requested) && !isSrModel(requested)')
|
|
183
|
+
expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*requested/)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
|
|
187
|
+
const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
|
|
188
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
189
|
+
// The default branch clears the file unconditionally, and only the
|
|
190
|
+
// in-memory override change is confirmation-gated inside it.
|
|
191
|
+
const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
|
|
192
|
+
const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
|
|
193
|
+
expect(clearIdx).toBeGreaterThan(0)
|
|
194
|
+
expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
|
|
195
|
+
// And the whole default branch is not nested in an `if (reply.selectedModel)` block:
|
|
196
|
+
const between = win.slice(0, clearIdx)
|
|
197
|
+
expect(between).not.toContain('if (reply.selectedModel) {')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('a persist failure is surfaced ON THE REPLY, not just stderr (typed + menu paths)', () => {
|
|
201
|
+
expect(GATEWAY_SRC.match(/won’t survive a relaunch/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
|
|
202
|
+
const typedIdx = GATEWAY_SRC.indexOf('persistWarning =')
|
|
203
|
+
expect(typedIdx).toBeGreaterThan(0)
|
|
204
|
+
expect(GATEWAY_SRC).toContain('reply.text + persistWarning')
|
|
205
|
+
// Menu path appends onto the outgoing card text.
|
|
206
|
+
expect(GATEWAY_SRC).toContain('outcome.reply.text +=')
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('gateway boot: session-model re-hydration + alert relay', () => {
|
|
211
|
+
it('re-hydrates the override from .active-session-model', () => {
|
|
59
212
|
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
|
|
60
213
|
expect(idx).toBeGreaterThan(0)
|
|
61
214
|
const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
|
|
62
|
-
// Only an override when the launched model differs from the configured one.
|
|
63
215
|
expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
|
|
64
|
-
// The configured value must be resolved through resolveMainModel (the SAME
|
|
65
|
-
// resolver start.sh's scaffold uses) so an unset/`default` model config does
|
|
66
|
-
// not get flagged as a phantom session override on an ordinary restart.
|
|
67
216
|
expect(win).toContain('resolveMainModel(raw ?? undefined)')
|
|
68
217
|
})
|
|
69
218
|
|
|
@@ -72,10 +221,15 @@ describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () =>
|
|
|
72
221
|
expect(idx).toBeGreaterThan(0)
|
|
73
222
|
const win = GATEWAY_SRC.slice(idx, idx + 1100)
|
|
74
223
|
expect(win).toContain('unlinkSync(alertPath)')
|
|
75
|
-
// Broadcasts to every operator in allowFrom, not just allowFrom[0].
|
|
76
224
|
expect(win).toContain('const operators = loadAccess().allowFrom')
|
|
77
225
|
expect(win).toContain('for (const operator of operators)')
|
|
78
226
|
expect(win).toContain('lockedBot.api')
|
|
79
227
|
expect(win).toContain('.sendMessage(operator')
|
|
80
228
|
})
|
|
81
229
|
})
|
|
230
|
+
|
|
231
|
+
describe('gateway: the legacy one-shot carrier is no longer written', () => {
|
|
232
|
+
it('no gateway code writes .session-model-override anymore (start.sh migration shim only reads it)', () => {
|
|
233
|
+
expect(GATEWAY_SRC).not.toMatch(/writeFileSync\([^)]*\.session-model-override/)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #2971 — gateway-side handler for `query_pending_permission`, the
|
|
3
|
+
* read-only wedge-watchdog probe that lets the permission-prompt branch
|
|
4
|
+
* defer to a live Telegram approval card instead of racing it with Esc.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the REAL createIpcServer over a real (tmp) unix socket with a
|
|
7
|
+
* raw `net.createConnection` client — the same transport the sidecar's
|
|
8
|
+
* `permission-pending-query.ts` module uses (no bridge/register handshake
|
|
9
|
+
* required; this message can be sent by ANY process inside the container,
|
|
10
|
+
* same trust model as `quota_wall_detected`).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
13
|
+
import { createConnection } from "node:net";
|
|
14
|
+
import { mkdtempSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { createIpcServer, type IpcServer, type IpcClient } from "../gateway/ipc-server.js";
|
|
18
|
+
import { validateClientMessage } from "../gateway/ipc-server.js";
|
|
19
|
+
import type { QueryPendingPermissionMessage } from "../gateway/ipc-protocol.js";
|
|
20
|
+
|
|
21
|
+
function tmpSocket(): string {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "ipc-qpp-test-"));
|
|
23
|
+
return join(dir, "test.sock");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function wait(ms: number): Promise<void> {
|
|
27
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Send one query and collect every `pending_permission_status` reply line. */
|
|
31
|
+
function queryOnce(socketPath: string, agentName: string, correlationId: string): Promise<any> {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const sock = createConnection(socketPath);
|
|
34
|
+
let buffer = "";
|
|
35
|
+
sock.on("connect", () => {
|
|
36
|
+
sock.write(JSON.stringify({ type: "query_pending_permission", agentName, correlationId } as QueryPendingPermissionMessage) + "\n");
|
|
37
|
+
});
|
|
38
|
+
sock.on("data", (chunk) => {
|
|
39
|
+
buffer += chunk.toString("utf8");
|
|
40
|
+
const idx = buffer.indexOf("\n");
|
|
41
|
+
if (idx >= 0) {
|
|
42
|
+
const line = buffer.slice(0, idx);
|
|
43
|
+
sock.end();
|
|
44
|
+
resolve(JSON.parse(line));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
sock.on("error", reject);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("validateClientMessage — query_pending_permission (#2971)", () => {
|
|
52
|
+
it("accepts a well-formed query", () => {
|
|
53
|
+
expect(
|
|
54
|
+
validateClientMessage({ type: "query_pending_permission", agentName: "carrie", correlationId: "abc" }),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects missing/malformed agentName", () => {
|
|
59
|
+
expect(validateClientMessage({ type: "query_pending_permission", correlationId: "abc" })).toBe(false);
|
|
60
|
+
expect(validateClientMessage({ type: "query_pending_permission", agentName: "", correlationId: "abc" })).toBe(false);
|
|
61
|
+
expect(validateClientMessage({ type: "query_pending_permission", agentName: "Bad Name", correlationId: "abc" })).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects missing/oversized correlationId", () => {
|
|
65
|
+
expect(validateClientMessage({ type: "query_pending_permission", agentName: "carrie" })).toBe(false);
|
|
66
|
+
expect(validateClientMessage({ type: "query_pending_permission", agentName: "carrie", correlationId: "" })).toBe(false);
|
|
67
|
+
expect(
|
|
68
|
+
validateClientMessage({ type: "query_pending_permission", agentName: "carrie", correlationId: "x".repeat(65) }),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("ipc-server — onQueryPendingPermission handler (#2971)", () => {
|
|
74
|
+
const servers: IpcServer[] = [];
|
|
75
|
+
|
|
76
|
+
afterEach(async () => {
|
|
77
|
+
for (const s of servers) await s.close();
|
|
78
|
+
servers.length = 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("fails CLOSED (pending:false) when no handler is wired — mixed-version-safe default", async () => {
|
|
82
|
+
const socketPath = tmpSocket();
|
|
83
|
+
const server = createIpcServer({
|
|
84
|
+
socketPath,
|
|
85
|
+
onClientRegistered: () => {},
|
|
86
|
+
onClientDisconnected: () => {},
|
|
87
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
88
|
+
onSessionEvent: () => {},
|
|
89
|
+
onPermissionRequest: () => {},
|
|
90
|
+
onHeartbeat: () => {},
|
|
91
|
+
onScheduleRestart: () => {},
|
|
92
|
+
// onQueryPendingPermission intentionally omitted.
|
|
93
|
+
});
|
|
94
|
+
servers.push(server);
|
|
95
|
+
await wait(50);
|
|
96
|
+
|
|
97
|
+
const reply = await queryOnce(socketPath, "carrie", "corr-1");
|
|
98
|
+
expect(reply).toEqual({ type: "pending_permission_status", correlationId: "corr-1", pending: false });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("invokes the wired handler with the query, and relays its reply", async () => {
|
|
102
|
+
const socketPath = tmpSocket();
|
|
103
|
+
let received: QueryPendingPermissionMessage | null = null;
|
|
104
|
+
const server = createIpcServer({
|
|
105
|
+
socketPath,
|
|
106
|
+
onClientRegistered: () => {},
|
|
107
|
+
onClientDisconnected: () => {},
|
|
108
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
109
|
+
onSessionEvent: () => {},
|
|
110
|
+
onPermissionRequest: () => {},
|
|
111
|
+
onHeartbeat: () => {},
|
|
112
|
+
onScheduleRestart: () => {},
|
|
113
|
+
onQueryPendingPermission: (client: IpcClient, msg: QueryPendingPermissionMessage) => {
|
|
114
|
+
received = msg;
|
|
115
|
+
client.send({
|
|
116
|
+
type: "pending_permission_status",
|
|
117
|
+
correlationId: msg.correlationId,
|
|
118
|
+
pending: true,
|
|
119
|
+
requestId: "req-live-1",
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
servers.push(server);
|
|
124
|
+
await wait(50);
|
|
125
|
+
|
|
126
|
+
const reply = await queryOnce(socketPath, "carrie", "corr-2");
|
|
127
|
+
expect(received).toEqual({ type: "query_pending_permission", agentName: "carrie", correlationId: "corr-2" });
|
|
128
|
+
expect(reply).toEqual({
|
|
129
|
+
type: "pending_permission_status",
|
|
130
|
+
correlationId: "corr-2",
|
|
131
|
+
pending: true,
|
|
132
|
+
requestId: "req-live-1",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("relays pending:false from a wired handler when nothing is live", async () => {
|
|
137
|
+
const socketPath = tmpSocket();
|
|
138
|
+
const server = createIpcServer({
|
|
139
|
+
socketPath,
|
|
140
|
+
onClientRegistered: () => {},
|
|
141
|
+
onClientDisconnected: () => {},
|
|
142
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
143
|
+
onSessionEvent: () => {},
|
|
144
|
+
onPermissionRequest: () => {},
|
|
145
|
+
onHeartbeat: () => {},
|
|
146
|
+
onScheduleRestart: () => {},
|
|
147
|
+
onQueryPendingPermission: (client: IpcClient, msg: QueryPendingPermissionMessage) => {
|
|
148
|
+
client.send({ type: "pending_permission_status", correlationId: msg.correlationId, pending: false });
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
servers.push(server);
|
|
152
|
+
await wait(50);
|
|
153
|
+
|
|
154
|
+
const reply = await queryOnce(socketPath, "carrie", "corr-3");
|
|
155
|
+
expect(reply).toEqual({ type: "pending_permission_status", correlationId: "corr-3", pending: false });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression coverage for #2976 — HTML-entity corruption in mental-model
|
|
3
|
+
* fields, config-write-boundary layer.
|
|
4
|
+
*
|
|
5
|
+
* Reachability (stated plainly so these tests don't over-claim):
|
|
6
|
+
* - `source_query` decode is the REACHABLE/load-bearing half: a proposal's
|
|
7
|
+
* free-form query can carry escaped entities the model copied out of its
|
|
8
|
+
* Telegram-HTML context, and undecoded it steers recall on `R&D`
|
|
9
|
+
* instead of `R&D`.
|
|
10
|
+
* - `name` decode is REDUNDANT with the gateway slug gate
|
|
11
|
+
* (/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ rejects an entity-bearing name before
|
|
12
|
+
* `buildMentalModelAppendDiff` ever runs). It's belt-and-suspenders; the
|
|
13
|
+
* name-path tests here exercise the boundary directly (bypassing the gate)
|
|
14
|
+
* to prove the normalization, NOT to imply the propose path could carry the
|
|
15
|
+
* bug.
|
|
16
|
+
* - The observed in-NAME corruption (`Nutrition Protocol & Deficit
|
|
17
|
+
* Status`, klanker 2026-07-06) actually arrived via the DIRECT
|
|
18
|
+
* `create_mental_model` Hindsight tool, which this PR does not touch — that
|
|
19
|
+
* vector is out of scope (steering.ts / Dockerfile.hindsight follow-ups).
|
|
20
|
+
*
|
|
21
|
+
* These tests pin the switchroom-owned WRITE-BOUNDARY normalization: name +
|
|
22
|
+
* source_query are decoded to their literal characters BEFORE synthesis into
|
|
23
|
+
* the config diff. Each assertion below FAILS against the pre-fix code (which
|
|
24
|
+
* serialized `spec.name` / `spec.source_query` verbatim) and passes after.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from "vitest";
|
|
28
|
+
import { parseDocument } from "yaml";
|
|
29
|
+
import {
|
|
30
|
+
buildMentalModelAppendDiff,
|
|
31
|
+
readDeclaredMentalModelNames,
|
|
32
|
+
decodeCanonicalEntities,
|
|
33
|
+
} from "../gateway/mental-model-propose-diff.js";
|
|
34
|
+
|
|
35
|
+
const BASE_CONFIG = `agents:
|
|
36
|
+
klanker:
|
|
37
|
+
memory:
|
|
38
|
+
backend: hindsight
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/** Pull the appended model node out of the synthesized `after` config. */
|
|
42
|
+
function appendedModel(after: string, agent = "klanker"): { name?: unknown; source_query?: unknown } {
|
|
43
|
+
const js = parseDocument(after).toJS() as {
|
|
44
|
+
agents: Record<string, { memory: { mental_models: Array<Record<string, unknown>> } }>;
|
|
45
|
+
};
|
|
46
|
+
const models = js.agents[agent]!.memory.mental_models;
|
|
47
|
+
return models[models.length - 1]!;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("decodeCanonicalEntities — single-pass, canonical six only", () => {
|
|
51
|
+
it("decodes each canonical entity to its literal character", () => {
|
|
52
|
+
expect(decodeCanonicalEntities("R&D")).toBe("R&D");
|
|
53
|
+
expect(decodeCanonicalEntities("a<b>c")).toBe("a<b>c");
|
|
54
|
+
expect(decodeCanonicalEntities("say "hi"")).toBe('say "hi"');
|
|
55
|
+
expect(decodeCanonicalEntities("Ken's")).toBe("Ken's");
|
|
56
|
+
expect(decodeCanonicalEntities("Ken's")).toBe("Ken's");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("undoes exactly ONE layer of escaping (double-escaped → single-escaped)", () => {
|
|
60
|
+
// We only reverse the escaping switchroom applied when rendering the model's
|
|
61
|
+
// own prior output — never the user's literal intent.
|
|
62
|
+
expect(decodeCanonicalEntities("R&amp;D")).toBe("R&D");
|
|
63
|
+
expect(decodeCanonicalEntities("&lt;")).toBe("<");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("leaves entity-free text untouched (idempotent on clean input)", () => {
|
|
67
|
+
expect(decodeCanonicalEntities("Nutrition Protocol & Deficit")).toBe("Nutrition Protocol & Deficit");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("#2976 — propose write-boundary normalizes the mental-model name", () => {
|
|
72
|
+
it("stores the LITERAL '&' name, not the escaped '&' the model emitted", () => {
|
|
73
|
+
const built = buildMentalModelAppendDiff({
|
|
74
|
+
configText: BASE_CONFIG,
|
|
75
|
+
agentName: "klanker",
|
|
76
|
+
spec: { name: "Nutrition Protocol & Deficit Status", source_query: "how is the deficit?" },
|
|
77
|
+
});
|
|
78
|
+
expect(built.ok).toBe(true);
|
|
79
|
+
if (!built.ok) return;
|
|
80
|
+
expect(appendedModel(built.after).name).toBe("Nutrition Protocol & Deficit Status");
|
|
81
|
+
// And there is no residual entity anywhere in the persisted config.
|
|
82
|
+
expect(built.after).not.toContain("&");
|
|
83
|
+
// Reading the declared names back yields the decoded literal.
|
|
84
|
+
expect(readDeclaredMentalModelNames(built.after, "klanker")).toContain(
|
|
85
|
+
"Nutrition Protocol & Deficit Status",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("normalizes the recall-steering source_query too (not just the name)", () => {
|
|
90
|
+
const built = buildMentalModelAppendDiff({
|
|
91
|
+
configText: BASE_CONFIG,
|
|
92
|
+
agentName: "klanker",
|
|
93
|
+
spec: { name: "rd-budget", source_query: "what is the user's R&D budget?" },
|
|
94
|
+
});
|
|
95
|
+
expect(built.ok).toBe(true);
|
|
96
|
+
if (!built.ok) return;
|
|
97
|
+
expect(appendedModel(built.after).source_query).toBe("what is the user's R&D budget?");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects an entity-escaped re-propose of an already-declared literal name (dup guard on decoded name)", () => {
|
|
101
|
+
const configWithModel = `agents:
|
|
102
|
+
klanker:
|
|
103
|
+
memory:
|
|
104
|
+
backend: hindsight
|
|
105
|
+
mental_models:
|
|
106
|
+
- name: Q3 R&D Plan
|
|
107
|
+
source_query: what is the plan?
|
|
108
|
+
`;
|
|
109
|
+
const built = buildMentalModelAppendDiff({
|
|
110
|
+
configText: configWithModel,
|
|
111
|
+
agentName: "klanker",
|
|
112
|
+
// Escaped variant of the same logical name — must collide, not create a twin.
|
|
113
|
+
spec: { name: "Q3 R&D Plan", source_query: "different query" },
|
|
114
|
+
});
|
|
115
|
+
expect(built.ok).toBe(false);
|
|
116
|
+
if (built.ok) return;
|
|
117
|
+
expect(built.error).toBe("duplicate");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -41,16 +41,19 @@ describe('handleMentalModelProposeCallback — authorization gate', () => {
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
describe('handleMentalModelProposeCallback — TTL enforced at tap time', () => {
|
|
44
|
-
it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and
|
|
44
|
+
it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and routes through the shared expiry path', () => {
|
|
45
45
|
const block = proposeCallbackBlock()
|
|
46
46
|
expect(block).toMatch(/Date\.now\(\) - pending\.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS/)
|
|
47
|
-
// The expired branch
|
|
48
|
-
//
|
|
47
|
+
// The expired branch routes through expireMentalModelProposeCard, which
|
|
48
|
+
// deletes the pending entry, clears the durable store, edits the card away
|
|
49
|
+
// (keyboard stripped), AND wakes the parked agent with a timeout synthetic —
|
|
50
|
+
// so a stale card can't be tapped into an approval and the agent isn't left
|
|
51
|
+
// parked. (The helper's delete/clear/wake behavior is pinned in
|
|
52
|
+
// pending-card-durability-wiring.test.ts.)
|
|
49
53
|
const ttlBranch =
|
|
50
54
|
block.split('Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS')[1]?.split('Single-shot')[0] ?? ''
|
|
51
|
-
expect(ttlBranch).toMatch(/
|
|
55
|
+
expect(ttlBranch).toMatch(/expireMentalModelProposeCard\(stageId, pending, Date\.now\(\)\)/)
|
|
52
56
|
expect(ttlBranch).toMatch(/expired/)
|
|
53
|
-
expect(ttlBranch).toMatch(/inline_keyboard: \[\]/)
|
|
54
57
|
})
|
|
55
58
|
})
|
|
56
59
|
|