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.
Files changed (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Structural pins for the session-only model relaunch wiring in gateway.ts.
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 model-menu callback sr-* branch, and the boot
6
- * re-hydration / alert-sentinel block inside the startup IIFE), so mirroring
7
- * the other gateway-*.test.ts source-pinswe assert on the source structure.
8
- * The end-to-end behaviour of the carrier itself is exercised in
9
- * tests/scaffold.session-model-override.test.ts (rendered start.sh) and the
10
- * handler contract in telegram-plugin/tests/model-command.test.ts.
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), somirroring 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: scheduleModelRelaunch dep', () => {
22
- it('writes the carrier with exact "<model>\\n" bytes', () => {
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 + 900)
26
- expect(win).toMatch(/writeFileSync\(\s*join\(agentDir, '\.session-model-override'\),\s*`\$\{model\}\\n`/)
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 activeSessionModelOverride before dispatching the restart', () => {
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 + 900)
32
- const setIdx = win.indexOf('activeSessionModelOverride = model')
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 + 900)
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: model-menu sr-* target relaunches via the carrier', () => {
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 boot: session-model re-hydration + LiteLLM-down alert', () => {
58
- it('re-hydrates activeSessionModelOverride from .active-session-model', () => {
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&amp;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 &amp; 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&amp;D")).toBe("R&D");
53
+ expect(decodeCanonicalEntities("a&lt;b&gt;c")).toBe("a<b>c");
54
+ expect(decodeCanonicalEntities("say &quot;hi&quot;")).toBe('say "hi"');
55
+ expect(decodeCanonicalEntities("Ken&apos;s")).toBe("Ken's");
56
+ expect(decodeCanonicalEntities("Ken&#39;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;amp;D")).toBe("R&amp;D");
63
+ expect(decodeCanonicalEntities("&amp;lt;")).toBe("&lt;");
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 '&amp;' the model emitted", () => {
73
+ const built = buildMentalModelAppendDiff({
74
+ configText: BASE_CONFIG,
75
+ agentName: "klanker",
76
+ spec: { name: "Nutrition Protocol &amp; 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("&amp;");
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&apos;s R&amp;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&amp;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 edits the card away', () => {
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 deletes the pending entry and clears the keyboard so a
48
- // stale card can't be tapped into an approval.
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(/pendingMentalModelProposes\.delete\(stageId\)/)
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