switchroom 0.18.14 → 0.18.17

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 (49) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +473 -49
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +1200 -1067
  5. package/dist/host-control/main.js +56 -51
  6. package/dist/vault/approvals/kernel-server.js +19 -12
  7. package/dist/vault/broker/server.js +675 -668
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/bridge/bridge.js +21 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +531 -259
  12. package/telegram-plugin/dist/server.js +22 -1
  13. package/telegram-plugin/draft-stream.ts +78 -3
  14. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  15. package/telegram-plugin/gateway/effort-command.ts +9 -7
  16. package/telegram-plugin/gateway/gateway.ts +310 -219
  17. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  18. package/telegram-plugin/gateway/model-command.ts +96 -18
  19. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  20. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  21. package/telegram-plugin/litellm-local-notice.ts +189 -0
  22. package/telegram-plugin/model-unavailable.ts +214 -0
  23. package/telegram-plugin/quota-watch.ts +16 -4
  24. package/telegram-plugin/runtime-metrics.ts +47 -0
  25. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  26. package/telegram-plugin/send-gate.ts +34 -4
  27. package/telegram-plugin/session-tail.ts +14 -2
  28. package/telegram-plugin/stream-controller.ts +143 -20
  29. package/telegram-plugin/stream-reply-handler.ts +12 -2
  30. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  31. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  32. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  33. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  34. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  35. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  36. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  37. package/telegram-plugin/tests/model-command.test.ts +84 -1
  38. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  39. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  40. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  41. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  42. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  43. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  44. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  45. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  46. package/telegram-plugin/tests/throttle-tier.test.ts +176 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  48. package/telegram-plugin/throttle-tier.ts +98 -1
  49. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -1,15 +1,14 @@
1
1
  /**
2
- * Structural pins for the session-model stickiness wiring in gateway.ts
3
- * (reference/rfcs/session-model-stickiness.md).
2
+ * Structural pins for the session-scoped /model wiring in gateway.ts
3
+ * (reference/rfcs/session-model-stickiness.md §0.1, rev 4 — consume-once).
4
4
  *
5
5
  * The behaviour lives in un-exported inline closures (buildModelDeps's
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.
6
+ * scheduleModelRelaunch/scheduleRestart, the typed/menu recorders, and the
7
+ * boot re-hydration block inside the startup IIFE), so — mirroring the other
8
+ * gateway-*.test.ts source-pins — we assert on the source structure. The
9
+ * end-to-end boot behaviour is exercised in tests/scaffold.session-model.test.ts
10
+ * (rendered start.sh), the file helpers in session-model-file.test.ts, and the
11
+ * handler contract in model-command.test.ts.
13
12
  */
14
13
 
15
14
  import { describe, it, expect } from 'vitest'
@@ -20,35 +19,27 @@ import { dirname, resolve } from 'node:path'
20
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
21
20
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
22
21
 
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)
22
+ describe('gateway: the .relaunch-model-intent subsystem is retired (rev 4)', () => {
23
+ it('gateway.ts no longer writes/clears/imports any relaunch-model intent', () => {
24
+ expect(GATEWAY_SRC).not.toContain('writeRelaunchModelIntent')
25
+ expect(GATEWAY_SRC).not.toContain('clearRelaunchModelIntent')
26
+ expect(GATEWAY_SRC).not.toContain('intentForRestartReason')
27
+ expect(GATEWAY_SRC).not.toContain('clearStaleGatewayShutdownIntent')
28
+ expect(GATEWAY_SRC).not.toContain('GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX')
29
+ expect(GATEWAY_SRC).not.toContain('.relaunch-model-intent')
37
30
  })
38
31
 
39
- it('legacy systemd branch stamps intent too (self-target only)', () => {
32
+ it('triggerSelfRestart just signals — no intent stamp before the kill', () => {
40
33
  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('))
34
+ expect(fnIdx).toBeGreaterThan(0)
35
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
36
+ expect(win).toContain("process.kill(1, 'SIGTERM')")
37
+ expect(win).not.toContain('writeRelaunchModelIntent')
47
38
  })
48
39
  })
49
40
 
50
- describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
51
- it('writes the durable file via writeSessionModelFile before dispatching the restart', () => {
41
+ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrier)', () => {
42
+ it('writes the carrier via writeSessionModelFile before dispatching the restart', () => {
52
43
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
53
44
  expect(idx).toBeGreaterThan(0)
54
45
  const win = GATEWAY_SRC.slice(idx, idx + 1800)
@@ -67,7 +58,7 @@ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
67
58
  expect(restartIdx).toBeGreaterThan(setIdx)
68
59
  })
69
60
 
70
- it('rolls back the prior file content (not just deletion) on a non-in-flight dispatch failure', () => {
61
+ it('rolls back the prior carrier content (not just deletion) on a non-in-flight dispatch failure', () => {
71
62
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
72
63
  const win = GATEWAY_SRC.slice(idx, idx + 1800)
73
64
  expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
@@ -75,15 +66,6 @@ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
75
66
  expect(win).toContain("!== 'restart_in_flight'")
76
67
  })
77
68
 
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
-
87
69
  it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
88
70
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
89
71
  const win = GATEWAY_SRC.slice(idx, idx + 1800)
@@ -91,63 +73,20 @@ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
91
73
  })
92
74
  })
93
75
 
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 a KEEP intent before dispatch (#3039: a restart is not "clear my model")', () => {
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 keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
122
- const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
123
- expect(keepIdx).toBeGreaterThan(0)
124
- expect(dispatchIdx).toBeGreaterThan(keepIdx)
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
- // Recording extracted into recordModelMenuSideEffects (#3017) — shared by the
141
- // live dispatcher and the deferred (queued mid-turn) apply so both record
142
- // identically.
76
+ describe('gateway: menu callback carrier handling (session-scoped)', () => {
77
+ it('a live Claude selection records the in-memory override but writes NO carrier', () => {
143
78
  const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
144
79
  expect(idx).toBeGreaterThan(0)
145
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
146
- expect(win).toContain('outcome.selectedModelToken')
147
- expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
80
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
81
+ // The first (live-switch) block sets the override but no longer persists.
82
+ expect(win).toContain('sessionModelSource.setOverride(outcome.selectedModel)')
83
+ const overrideIdx = win.indexOf('sessionModelSource.setOverride(outcome.selectedModel)')
84
+ const nextClearIdx = win.indexOf('outcome.clearedDefault')
85
+ const writeBetween = win.slice(overrideIdx, nextClearIdx)
86
+ expect(writeBetween).not.toContain('writeSessionModelFile(')
148
87
  })
149
88
 
150
- it('a confirmed "Default" selection CLEARS the sticky file', () => {
89
+ it('a confirmed "Default" selection CLEARS the carrier', () => {
151
90
  const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
152
91
  const win = GATEWAY_SRC.slice(idx, idx + 2400)
153
92
  expect(win).toContain('outcome.clearedDefault')
@@ -155,9 +94,6 @@ describe('gateway: model-menu callback persists the sticky override', () => {
155
94
  })
156
95
 
157
96
  it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
158
- // Anchor on the sr-* TARGET dispatcher branch specifically (a mid-turn
159
- // busy-gate #3017 also matches `if (data.startsWith(MODEL_CALLBACK_SR))`, so
160
- // anchor on the relaunch call that is unique to the idle apply branch).
161
97
  const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
162
98
  expect(idx).toBeGreaterThan(0)
163
99
  const win = GATEWAY_SRC.slice(idx, idx + 1400)
@@ -165,53 +101,37 @@ describe('gateway: model-menu callback persists the sticky override', () => {
165
101
  expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
166
102
  })
167
103
 
168
- it('the sr-to-claude transition writes the durable file (and clears it on a Default tap)', () => {
104
+ it('the sr-to-claude transition (a relaunch) writes the carrier, and clears it on a Default tap', () => {
169
105
  const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
170
106
  expect(idx).toBeGreaterThan(0)
171
107
  const win = GATEWAY_SRC.slice(idx, idx + 3600)
172
108
  expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
173
109
  expect(win).toContain('clearSessionModelFile(agentDir)')
174
- // Restart rides triggerSelfRestart with a keep-classified reason.
175
110
  expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
176
111
  })
177
112
  })
178
113
 
179
- describe('gateway: typed /model persists the REQUESTED canonical token', () => {
180
- it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
181
- // Recording extracted into recordTypedModelSwitch (#3017) — shared by the
182
- // live `bot.command('model')` handler and the deferred (queued mid-turn) apply.
114
+ describe('gateway: typed /model is session-scoped (live Claude switch writes no carrier)', () => {
115
+ it('the Claude path sets the in-memory override but never persists a carrier', () => {
183
116
  const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
184
117
  expect(idx).toBeGreaterThan(0)
185
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
186
- expect(win).toContain("requested?.toLowerCase() === 'default'")
187
- expect(win).toContain('sessionModelSource.setOverride(null)')
188
- expect(win).toContain('clearSessionModelFile(smDir)')
189
- // Non-default: the requested token (shape-gated, non-sr) is what persists.
190
- expect(win).toContain('isValidModelArg(requested) && !isSrModel(requested)')
191
- expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*requested/)
118
+ const win = GATEWAY_SRC.slice(idx, idx + 1400)
119
+ expect(win).toContain('sessionModelSource.setOverride(reply.selectedModel)')
120
+ // No durable carrier write on the live-switch path.
121
+ expect(win).not.toContain('writeSessionModelFile(')
192
122
  })
193
123
 
194
- it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
124
+ it('`/model default` clears the carrier + in-memory override (silent-switch path must not resurrect)', () => {
195
125
  const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
196
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
197
- // The default branch clears the file unconditionally, and only the
198
- // in-memory override change is confirmation-gated inside it.
126
+ const win = GATEWAY_SRC.slice(idx, idx + 1400)
127
+ expect(win).toContain("requested?.toLowerCase() === 'default'")
128
+ expect(win).toContain('clearSessionModelFile(smDir)')
129
+ expect(win).toContain('sessionModelSource.setOverride(null)')
130
+ // The file-clear is not gated on a positive confirmation.
199
131
  const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
200
132
  const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
201
133
  expect(clearIdx).toBeGreaterThan(0)
202
134
  expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
203
- // And the whole default branch is not nested in an `if (reply.selectedModel)` block:
204
- const between = win.slice(0, clearIdx)
205
- expect(between).not.toContain('if (reply.selectedModel) {')
206
- })
207
-
208
- it('a persist failure is surfaced ON THE REPLY, not just stderr (typed + menu paths)', () => {
209
- expect(GATEWAY_SRC.match(/won’t survive a relaunch/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
210
- const typedIdx = GATEWAY_SRC.indexOf('persistWarning =')
211
- expect(typedIdx).toBeGreaterThan(0)
212
- expect(GATEWAY_SRC).toContain('reply.text + persistWarning')
213
- // Menu path appends onto the outgoing card text.
214
- expect(GATEWAY_SRC).toContain('outcome.reply.text +=')
215
135
  })
216
136
  })
217
137