switchroom 0.15.2 → 0.15.4
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/bin/turn-pacing-hook.sh +112 -0
- package/bin/workspace-dynamic-hook.sh +105 -15
- package/bin/workspace-stable-hook.sh +2 -2
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +75 -12
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +1596 -1515
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +35 -2
- package/profiles/default/CLAUDE.md.hbs +13 -4
- package/telegram-plugin/dist/gateway/gateway.js +533 -33
- package/telegram-plugin/gateway/gateway.ts +152 -14
- package/telegram-plugin/gateway/inbound-spool.ts +107 -16
- package/telegram-plugin/gateway/model-command.ts +261 -7
- package/telegram-plugin/tests/inbound-spool.test.ts +101 -0
- package/telegram-plugin/tests/model-command.test.ts +179 -0
- package/telegram-plugin/tests/welcome-text.test.ts +11 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +93 -0
- package/telegram-plugin/welcome-text.ts +16 -1
- package/profiles/default/workspace/HEARTBEAT.md.hbs +0 -40
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* Telegram `/model` command — show or switch the Claude model for this
|
|
3
3
|
* agent's live session.
|
|
4
4
|
*
|
|
5
|
-
* `/model` (bare)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* `/model` (bare) renders the model dashboard: the live model, a brief
|
|
6
|
+
* quota line, and an inline-keyboard menu of the options claude's own
|
|
7
|
+
* `/model` picker offers (discovered live via `src/agents/model-picker.ts`
|
|
8
|
+
* — opened, parsed, Esc'd; never hardcoded, so new models appear the
|
|
9
|
+
* moment the installed CLI offers them). A button tap re-opens the
|
|
10
|
+
* picker fresh, matches the row by label, and applies session-only.
|
|
11
|
+
* When discovery fails (agent mid-turn, CLI UI changed, kill-switched
|
|
12
|
+
* via SWITCHROOM_MODEL_MENU=0) it falls back to the static v1 text.
|
|
12
13
|
*
|
|
13
14
|
* `/model <alias|full-id>` types claude's own `/model <name>` into the
|
|
14
15
|
* agent's tmux pane via the existing allowlisted inject primitive
|
|
@@ -24,6 +25,12 @@
|
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
import type { InjectResult } from '../../src/agents/inject.js'
|
|
28
|
+
import {
|
|
29
|
+
labelTag,
|
|
30
|
+
type DiscoverResult,
|
|
31
|
+
type SelectResult,
|
|
32
|
+
type ModelPickerOption,
|
|
33
|
+
} from '../../src/agents/model-picker.js'
|
|
27
34
|
|
|
28
35
|
/**
|
|
29
36
|
* Aliases the claude CLI resolves natively. Listed in help text only —
|
|
@@ -180,3 +187,250 @@ export async function handleModelCommand(
|
|
|
180
187
|
html: true,
|
|
181
188
|
}
|
|
182
189
|
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Picker-driven model menu (v2) — discovery, render, callback selection.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export interface ModelMenuDeps {
|
|
196
|
+
/** Live picker discovery — src/agents/model-picker.ts discoverModels. */
|
|
197
|
+
discover: (agent: string) => Promise<DiscoverResult>
|
|
198
|
+
/** Live picker selection by label — selectModel (session-only `s`). */
|
|
199
|
+
select: (agent: string, label: string) => Promise<SelectResult>
|
|
200
|
+
/**
|
|
201
|
+
* True while the agent is mid-turn. Driving the picker types into
|
|
202
|
+
* claude's input box; doing that mid-turn would queue "/model" as
|
|
203
|
+
* user text instead of opening the modal — refuse instead.
|
|
204
|
+
*/
|
|
205
|
+
isBusy: () => boolean
|
|
206
|
+
getAgentName: () => string
|
|
207
|
+
/** One-line quota summary (e.g. "29% / 5h · 33% / 7d") or null. */
|
|
208
|
+
getQuotaBrief: () => Promise<string | null>
|
|
209
|
+
escapeHtml: (s: string) => string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Raw Telegram inline-keyboard shape (grammY accepts it verbatim). */
|
|
213
|
+
export interface ModelMenuKeyboardButton {
|
|
214
|
+
text: string
|
|
215
|
+
callback_data: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ModelMenuReply {
|
|
219
|
+
text: string
|
|
220
|
+
html: true
|
|
221
|
+
/** Rows of buttons; absent on the no-menu fallback. */
|
|
222
|
+
keyboard?: ModelMenuKeyboardButton[][]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const MODEL_CALLBACK_PREFIX = 'mdl:'
|
|
226
|
+
const MODEL_CALLBACK_SELECT = 'mdl:s:'
|
|
227
|
+
export const MODEL_CALLBACK_REFRESH = 'mdl:r'
|
|
228
|
+
|
|
229
|
+
export function modelSelectCallbackData(label: string): string {
|
|
230
|
+
// Identity is the label's hash, not its index — a tap re-discovers
|
|
231
|
+
// the picker and matches by tag, so a list that shifted between
|
|
232
|
+
// render and tap can never select the wrong row. 8 hex chars keeps
|
|
233
|
+
// callback_data tiny (well under Telegram's 64-byte cap).
|
|
234
|
+
return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
|
|
238
|
+
return {
|
|
239
|
+
text: '⏳ The agent is mid-turn — the model picker needs an idle prompt. Try again in a moment.',
|
|
240
|
+
html: true,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function menuKeyboard(options: ModelPickerOption[]): ModelMenuKeyboardButton[][] {
|
|
245
|
+
// One option per row (labels + ✔ render cleanly at full width on
|
|
246
|
+
// mobile), refresh on a trailing row.
|
|
247
|
+
const rows: ModelMenuKeyboardButton[][] = options.map((o) => [
|
|
248
|
+
{
|
|
249
|
+
text: o.current ? `✅ ${o.label}` : o.label,
|
|
250
|
+
callback_data: modelSelectCallbackData(o.label),
|
|
251
|
+
},
|
|
252
|
+
])
|
|
253
|
+
rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
|
|
254
|
+
return rows
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build the `/model` dashboard: live model + quota brief + tap menu.
|
|
259
|
+
* Returns a keyboard-less fallback (v1-shaped static text) when the
|
|
260
|
+
* picker can't be driven right now — the command never hard-fails.
|
|
261
|
+
*/
|
|
262
|
+
export async function buildModelMenu(
|
|
263
|
+
deps: ModelMenuDeps & ModelCommandDeps,
|
|
264
|
+
): Promise<ModelMenuReply> {
|
|
265
|
+
if (deps.isBusy()) return busyReply(deps)
|
|
266
|
+
|
|
267
|
+
const [discovered, quota] = await Promise.all([
|
|
268
|
+
deps.discover(deps.getAgentName()),
|
|
269
|
+
deps.getQuotaBrief().catch(() => null),
|
|
270
|
+
])
|
|
271
|
+
|
|
272
|
+
if (!discovered.ok) {
|
|
273
|
+
// Graceful static fallback — same content as the v1 show path,
|
|
274
|
+
// with the discovery failure surfaced.
|
|
275
|
+
const v1 = await handleModelCommand({ kind: 'show' }, deps)
|
|
276
|
+
return {
|
|
277
|
+
text: [`<i>(picker unavailable: ${deps.escapeHtml(discovered.reason)})</i>`, v1.text].join('\n'),
|
|
278
|
+
html: true,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// claude's ✔ marks the DEFAULT FOR NEW SESSIONS, which is a different axis
|
|
283
|
+
// from the model the agent is running right now (set via --model at launch
|
|
284
|
+
// or a prior session switch). Labelling the ✔ row "Now:" was misleading —
|
|
285
|
+
// it could read "Opus 4.8" while the live session is on Fable. Call it what
|
|
286
|
+
// it is, and tell the operator a switch applies to the live session.
|
|
287
|
+
const current = discovered.options.find((o) => o.current)
|
|
288
|
+
const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
|
|
289
|
+
if (discovered.dismissFailed) {
|
|
290
|
+
lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
|
|
291
|
+
}
|
|
292
|
+
if (current) {
|
|
293
|
+
const detail = current.detail ? ` · ${deps.escapeHtml(current.detail)}` : ''
|
|
294
|
+
lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`)
|
|
295
|
+
} else {
|
|
296
|
+
lines.push('Default (new sessions): <i>unknown (no ✔ row in picker)</i>')
|
|
297
|
+
}
|
|
298
|
+
if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
|
|
299
|
+
lines.push('', 'Tap a model to switch the <b>live session</b>:')
|
|
300
|
+
lines.push(PERSIST_NOTE)
|
|
301
|
+
|
|
302
|
+
return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(discovered.options) }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface ModelCallbackOutcome {
|
|
306
|
+
/**
|
|
307
|
+
* When true, the caller should ONLY show the toast (`answer`) and leave
|
|
308
|
+
* the existing menu message untouched — used for the mid-turn refusal so
|
|
309
|
+
* the menu keeps its buttons and the operator can simply tap again when
|
|
310
|
+
* the agent goes idle, instead of the menu collapsing to a button-less
|
|
311
|
+
* "try again" line (which read as "nothing happened").
|
|
312
|
+
*/
|
|
313
|
+
toastOnly?: boolean
|
|
314
|
+
/**
|
|
315
|
+
* On a successful session switch, the live model name now running (parsed
|
|
316
|
+
* from claude's confirmation, e.g. "Fable 5"). The gateway records this as
|
|
317
|
+
* the session-model override so `/status` reflects what's actually running.
|
|
318
|
+
* Absent on every non-switch outcome.
|
|
319
|
+
*/
|
|
320
|
+
selectedModel?: string
|
|
321
|
+
/** Short toast for answerCallbackQuery. */
|
|
322
|
+
answer: string
|
|
323
|
+
/** Replacement dashboard (message edit). */
|
|
324
|
+
reply: ModelMenuReply
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Handle a `mdl:*` callback tap. `mdl:r` re-renders the dashboard;
|
|
329
|
+
* `mdl:s:<tag>` re-discovers the picker, resolves the tag back to a
|
|
330
|
+
* live label, and applies it session-only. A tag that no longer
|
|
331
|
+
* matches (claude updated its options since render) re-renders the
|
|
332
|
+
* menu instead of guessing.
|
|
333
|
+
*/
|
|
334
|
+
export async function handleModelMenuCallback(
|
|
335
|
+
data: string,
|
|
336
|
+
deps: ModelMenuDeps & ModelCommandDeps,
|
|
337
|
+
): Promise<ModelCallbackOutcome> {
|
|
338
|
+
if (data === MODEL_CALLBACK_REFRESH) {
|
|
339
|
+
return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
|
|
340
|
+
}
|
|
341
|
+
if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
|
|
342
|
+
return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
|
|
343
|
+
}
|
|
344
|
+
// Mid-turn: refuse WITHOUT touching the message. Driving the picker types
|
|
345
|
+
// into claude's input box, which mid-turn would queue "/model" as user
|
|
346
|
+
// text. toastOnly keeps the menu (and its buttons) exactly as-is so the
|
|
347
|
+
// operator just taps again when the agent is idle — no button-less
|
|
348
|
+
// "try again" line that read as a dead menu.
|
|
349
|
+
if (deps.isBusy()) {
|
|
350
|
+
return {
|
|
351
|
+
answer: '⏳ Agent is mid-turn — tap again when it’s idle',
|
|
352
|
+
reply: busyReply(deps),
|
|
353
|
+
toastOnly: true,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const tag = data.slice(MODEL_CALLBACK_SELECT.length)
|
|
358
|
+
const discovered = await deps.discover(deps.getAgentName())
|
|
359
|
+
if (!discovered.ok) {
|
|
360
|
+
// Keep the menu interactive: re-render (falls back to v1 text if even
|
|
361
|
+
// the show path can't discover) with the failure as a banner.
|
|
362
|
+
return {
|
|
363
|
+
answer: 'Picker unavailable',
|
|
364
|
+
reply: await menuWithBanner(
|
|
365
|
+
deps,
|
|
366
|
+
`❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
|
|
367
|
+
),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const target = discovered.options.find((o) => labelTag(o.label) === tag)
|
|
371
|
+
if (!target) {
|
|
372
|
+
// Options changed since the menu rendered — never guess; re-render.
|
|
373
|
+
const fresh = await buildModelMenu(deps)
|
|
374
|
+
return { answer: 'Model list changed — menu refreshed', reply: fresh }
|
|
375
|
+
}
|
|
376
|
+
// NOTE: do NOT short-circuit when target.current is set. The picker's ✔
|
|
377
|
+
// marks claude's DEFAULT FOR NEW SESSIONS, which is a DIFFERENT axis from
|
|
378
|
+
// the model the live session is running (set by --model at launch). Tapping
|
|
379
|
+
// the ✔ row to apply that model to the live session is a legitimate switch
|
|
380
|
+
// — e.g. an agent launched on Fable tapping "Default (Opus)". Skipping it
|
|
381
|
+
// here was the "tapped Default, nothing happened" bug. Always drive the
|
|
382
|
+
// selection; claude harmlessly answers "Kept model as X" if it's already
|
|
383
|
+
// the session model.
|
|
384
|
+
const result = await deps.select(deps.getAgentName(), target.label)
|
|
385
|
+
if (!result.ok) {
|
|
386
|
+
// Switch failed but the agent is reachable — keep the menu so the
|
|
387
|
+
// operator can retry, with the reason as a banner.
|
|
388
|
+
return {
|
|
389
|
+
answer: 'Switch failed — see the menu',
|
|
390
|
+
reply: await menuWithBanner(
|
|
391
|
+
deps,
|
|
392
|
+
`❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
|
|
393
|
+
),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
answer: deps.escapeHtml(result.confirmation),
|
|
399
|
+
reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
|
|
400
|
+
selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Pull the model NAME out of claude's session-switch confirmation so it can
|
|
406
|
+
* be shown in `/status` as the live session model. claude phrases it as
|
|
407
|
+
* "Set model to <name> for this session only" (or "Switched to <name>").
|
|
408
|
+
* Returns null when the confirmation doesn't carry a recognizable name (the
|
|
409
|
+
* caller falls back to the tapped picker label).
|
|
410
|
+
*/
|
|
411
|
+
export function sessionModelFromConfirmation(confirmation: string): string | null {
|
|
412
|
+
const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(
|
|
413
|
+
confirmation.trim(),
|
|
414
|
+
)
|
|
415
|
+
const name = m?.[1]?.trim()
|
|
416
|
+
return name && name.length > 0 ? name : null
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Re-render the live menu with a one-line banner on top. Used by every
|
|
421
|
+
* post-tap outcome (success, already-default, failure) so the menu ALWAYS
|
|
422
|
+
* keeps its buttons and the operator can act again — the consistent
|
|
423
|
+
* "status line + interactive menu" shape the other dashboards use. Falls
|
|
424
|
+
* back to the banner alone if the menu can't be rebuilt right now.
|
|
425
|
+
*/
|
|
426
|
+
async function menuWithBanner(
|
|
427
|
+
deps: ModelMenuDeps & ModelCommandDeps,
|
|
428
|
+
banner: string,
|
|
429
|
+
): Promise<ModelMenuReply> {
|
|
430
|
+
const fresh = await buildModelMenu(deps)
|
|
431
|
+
return {
|
|
432
|
+
text: [banner, '', fresh.text].join('\n'),
|
|
433
|
+
html: true,
|
|
434
|
+
...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
|
|
435
|
+
}
|
|
436
|
+
}
|
|
@@ -285,6 +285,107 @@ describe('inbound-spool — bounded escalation (promise always resolved)', () =>
|
|
|
285
285
|
})
|
|
286
286
|
})
|
|
287
287
|
|
|
288
|
+
describe('inbound-spool — give-up notice coalescing (2026-06-09 marko spam)', () => {
|
|
289
|
+
// Helper: drive a sweep, return the list of postNotice flags per dropped entry.
|
|
290
|
+
function sweepFlags(s: ReturnType<typeof createInboundSpool>): boolean[] {
|
|
291
|
+
const flags: boolean[] = []
|
|
292
|
+
s.sweepEscalations((_e, { postNotice }) => flags.push(postNotice))
|
|
293
|
+
return flags
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
it('a burst of undeliverable entries in one chat posts exactly ONE notice', () => {
|
|
297
|
+
const fs = fakeFs()
|
|
298
|
+
let t = 0
|
|
299
|
+
const s = createInboundSpool({
|
|
300
|
+
path: PATH, fs, now: () => t,
|
|
301
|
+
escalateAfterMs: 100, escalateNoticeCooldownMs: 10_000,
|
|
302
|
+
})
|
|
303
|
+
// Three synthetics, same chat, distinct ids (fresh ts → distinct spoolId,
|
|
304
|
+
// the exact churn shape that produced the spam).
|
|
305
|
+
s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
|
|
306
|
+
s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
|
|
307
|
+
s.put('marko', msg({ messageId: 0, ts: 3, meta: { source: 'cron' } }))
|
|
308
|
+
t = 1000 // all older than the 100ms bound
|
|
309
|
+
const flags = sweepFlags(s)
|
|
310
|
+
expect(flags.length).toBe(3) // all three dropped (promise retracted)
|
|
311
|
+
expect(flags.filter(Boolean).length).toBe(1) // ONE notice posted
|
|
312
|
+
expect(s.liveCount()).toBe(0)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('distinct chats each get their own notice', () => {
|
|
316
|
+
const fs = fakeFs()
|
|
317
|
+
let t = 0
|
|
318
|
+
const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
|
|
319
|
+
s.put('marko', msg({ chatId: 'A', messageId: 1 }))
|
|
320
|
+
s.put('marko', msg({ chatId: 'B', messageId: 2 }))
|
|
321
|
+
t = 1000
|
|
322
|
+
expect(sweepFlags(s).filter(Boolean).length).toBe(2)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('same chat, different forum topics are coalesced independently', () => {
|
|
326
|
+
const fs = fakeFs()
|
|
327
|
+
let t = 0
|
|
328
|
+
const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
|
|
329
|
+
s.put('marko', msg({ chatId: 'A', messageId: 1, meta: { threadId: '3' } }))
|
|
330
|
+
s.put('marko', msg({ chatId: 'A', messageId: 2, meta: { threadId: '4' } }))
|
|
331
|
+
t = 1000
|
|
332
|
+
expect(sweepFlags(s).filter(Boolean).length).toBe(2)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('THE BUG: the coalescing window survives a restart — a re-aged synthetic does not re-spam', () => {
|
|
336
|
+
const fs = fakeFs()
|
|
337
|
+
let t = 0
|
|
338
|
+
const opts = { escalateAfterMs: 100, escalateNoticeCooldownMs: 60_000 }
|
|
339
|
+
// Boot 1: one synthetic ages out → posts the notice.
|
|
340
|
+
const s1 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
|
|
341
|
+
s1.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
|
|
342
|
+
t = 1000
|
|
343
|
+
expect(sweepFlags(s1)).toEqual([true])
|
|
344
|
+
// Restart. A NEW synthetic (fresh ts → fresh id) lands and ages out within
|
|
345
|
+
// the cooldown. Pre-fix this re-posted every cycle across restarts.
|
|
346
|
+
t = 5000
|
|
347
|
+
const s2 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
|
|
348
|
+
s2.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
|
|
349
|
+
t = 6000
|
|
350
|
+
expect(sweepFlags(s2)).toEqual([false]) // dropped, but notice SUPPRESSED
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('compaction preserves the coalescing window (a post-compaction restart does not re-spam)', () => {
|
|
354
|
+
const fs = fakeFs()
|
|
355
|
+
let t = 0
|
|
356
|
+
// Tiny compact threshold so the next append triggers a rewrite.
|
|
357
|
+
const opts = { escalateAfterMs: 100, escalateNoticeCooldownMs: 60_000, compactAtBytes: 1 }
|
|
358
|
+
const s1 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
|
|
359
|
+
s1.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
|
|
360
|
+
t = 1000
|
|
361
|
+
expect(sweepFlags(s1)).toEqual([true]) // posts + appends esc; compaction runs
|
|
362
|
+
// After compaction the file must still carry the esc record → a restart
|
|
363
|
+
// hydrates the window → a new re-aged synthetic stays suppressed.
|
|
364
|
+
t = 5000
|
|
365
|
+
const s2 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
|
|
366
|
+
s2.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
|
|
367
|
+
t = 6000
|
|
368
|
+
expect(sweepFlags(s2)).toEqual([false])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('re-notifies after the burst goes quiet for longer than the cooldown', () => {
|
|
372
|
+
const fs = fakeFs()
|
|
373
|
+
let t = 0
|
|
374
|
+
const s = createInboundSpool({
|
|
375
|
+
path: PATH, fs, now: () => t,
|
|
376
|
+
escalateAfterMs: 100, escalateNoticeCooldownMs: 1000,
|
|
377
|
+
})
|
|
378
|
+
s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
|
|
379
|
+
t = 200
|
|
380
|
+
expect(sweepFlags(s)).toEqual([true]) // first notice
|
|
381
|
+
// Quiet gap longer than the cooldown, then a new stuck synthetic.
|
|
382
|
+
t = 5000
|
|
383
|
+
s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
|
|
384
|
+
t = 5200
|
|
385
|
+
expect(sweepFlags(s)).toEqual([true]) // genuinely new situation → re-notify
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
288
389
|
describe('inbound-spool — robustness', () => {
|
|
289
390
|
it('a failing appendFileSync does not throw and keeps in-memory live state', () => {
|
|
290
391
|
const fs = fakeFs()
|
|
@@ -203,3 +203,182 @@ describe("inject allowlist contract", () => {
|
|
|
203
203
|
expect(INJECT_COMMANDS.has("/model")).toBe(true);
|
|
204
204
|
});
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Picker-driven menu (v2) — buildModelMenu + handleModelMenuCallback
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
import {
|
|
212
|
+
buildModelMenu,
|
|
213
|
+
handleModelMenuCallback,
|
|
214
|
+
modelSelectCallbackData,
|
|
215
|
+
sessionModelFromConfirmation,
|
|
216
|
+
MODEL_CALLBACK_REFRESH,
|
|
217
|
+
type ModelMenuDeps,
|
|
218
|
+
} from "../gateway/model-command.js";
|
|
219
|
+
import { labelTag } from "../../src/agents/model-picker.js";
|
|
220
|
+
|
|
221
|
+
const OPTIONS = [
|
|
222
|
+
{ index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
|
|
223
|
+
{ index: 2, label: "Sonnet", detail: "Sonnet 4.6 · Efficient", current: true },
|
|
224
|
+
{ index: 3, label: "Haiku", detail: "Haiku 4.5 · Fastest", current: false },
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
function makeMenuDeps(overrides: Partial<ModelMenuDeps> = {}) {
|
|
228
|
+
const calls = { discover: 0, select: [] as string[] };
|
|
229
|
+
const base = makeDeps(); // v1 deps (inject/getConfiguredModel/escapeHtml/preBlock)
|
|
230
|
+
const deps = {
|
|
231
|
+
...base.deps,
|
|
232
|
+
discover: async () => {
|
|
233
|
+
calls.discover++;
|
|
234
|
+
return { ok: true as const, options: OPTIONS, currentLabel: "Sonnet" };
|
|
235
|
+
},
|
|
236
|
+
select: async (_a: string, label: string) => {
|
|
237
|
+
calls.select.push(label);
|
|
238
|
+
return { ok: true as const, confirmation: `Set model to ${label} for this session` };
|
|
239
|
+
},
|
|
240
|
+
isBusy: () => false,
|
|
241
|
+
getQuotaBrief: async () => "29% / 5h · 33% / 7d",
|
|
242
|
+
...overrides,
|
|
243
|
+
};
|
|
244
|
+
return { deps, calls, injectCalls: base.calls };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
describe("buildModelMenu", () => {
|
|
248
|
+
it("renders current model, quota brief, and one button per discovered option", async () => {
|
|
249
|
+
const { deps, calls } = makeMenuDeps();
|
|
250
|
+
const menu = await buildModelMenu(deps);
|
|
251
|
+
expect(calls.discover).toBe(1);
|
|
252
|
+
expect(menu.text).toContain("<b>Sonnet</b>");
|
|
253
|
+
expect(menu.text).toContain("29% / 5h · 33% / 7d");
|
|
254
|
+
expect(menu.keyboard).toBeDefined();
|
|
255
|
+
// 3 option rows + refresh row
|
|
256
|
+
expect(menu.keyboard!.length).toBe(4);
|
|
257
|
+
expect(menu.keyboard![1][0].text).toBe("✅ Sonnet");
|
|
258
|
+
expect(menu.keyboard![0][0].text).toBe("Default (recommended)");
|
|
259
|
+
expect(menu.keyboard![3][0].callback_data).toBe(MODEL_CALLBACK_REFRESH);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("every callback_data fits Telegram's 64-byte cap", async () => {
|
|
263
|
+
const { deps } = makeMenuDeps();
|
|
264
|
+
const menu = await buildModelMenu(deps);
|
|
265
|
+
for (const row of menu.keyboard!) {
|
|
266
|
+
for (const btn of row) {
|
|
267
|
+
expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("busy agent → no discovery, no keyboard, explanatory text", async () => {
|
|
273
|
+
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
274
|
+
const menu = await buildModelMenu(deps);
|
|
275
|
+
expect(calls.discover).toBe(0);
|
|
276
|
+
expect(menu.keyboard).toBeUndefined();
|
|
277
|
+
expect(menu.text).toContain("mid-turn");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
|
|
281
|
+
const { deps } = makeMenuDeps({
|
|
282
|
+
discover: async () => ({ ok: false as const, reason: "tmux session not found" }),
|
|
283
|
+
});
|
|
284
|
+
const menu = await buildModelMenu(deps);
|
|
285
|
+
expect(menu.keyboard).toBeUndefined();
|
|
286
|
+
expect(menu.text).toContain("picker unavailable");
|
|
287
|
+
expect(menu.text).toContain("Configured:");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("quota failure never blocks the menu", async () => {
|
|
291
|
+
const { deps } = makeMenuDeps({
|
|
292
|
+
getQuotaBrief: async () => {
|
|
293
|
+
throw new Error("broker down");
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
const menu = await buildModelMenu(deps);
|
|
297
|
+
expect(menu.keyboard).toBeDefined();
|
|
298
|
+
expect(menu.text).not.toContain("Quota:");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("handleModelMenuCallback", () => {
|
|
303
|
+
it("mdl:s:<tag> selects by re-discovered label", async () => {
|
|
304
|
+
const { deps, calls } = makeMenuDeps();
|
|
305
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
306
|
+
expect(calls.select).toEqual(["Haiku"]);
|
|
307
|
+
expect(out.answer).toContain("Set model to Haiku");
|
|
308
|
+
expect(out.reply.text).toContain("✅");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("stale tag (options changed) → never selects, re-renders menu", async () => {
|
|
312
|
+
const { deps, calls } = makeMenuDeps();
|
|
313
|
+
const staleTag = `mdl:s:${labelTag("Removed Model")}`;
|
|
314
|
+
const out = await handleModelMenuCallback(staleTag, deps);
|
|
315
|
+
expect(calls.select).toEqual([]);
|
|
316
|
+
expect(out.answer).toContain("refreshed");
|
|
317
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("tapping the ✔ (default) row STILL drives a switch — ✔ is the new-session default, not the live session model", async () => {
|
|
321
|
+
// OPTIONS marks "Sonnet" current (the ✔). An agent launched on a
|
|
322
|
+
// different model must still be able to apply the ✔ row to its live
|
|
323
|
+
// session — skipping it was the "tapped Default, nothing happened" bug.
|
|
324
|
+
const { deps, calls } = makeMenuDeps();
|
|
325
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
|
|
326
|
+
expect(calls.select).toEqual(["Sonnet"]);
|
|
327
|
+
expect(out.reply.text).toContain("✅");
|
|
328
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("busy agent → toastOnly refusal that leaves the menu untouched", async () => {
|
|
332
|
+
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
333
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
334
|
+
expect(calls.select).toEqual([]);
|
|
335
|
+
expect(out.answer).toContain("mid-turn");
|
|
336
|
+
// toastOnly tells the gateway to NOT edit the menu — buttons survive.
|
|
337
|
+
expect(out.toastOnly).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("selection failure surfaces the reason AND keeps the menu so the operator can retry", async () => {
|
|
341
|
+
const { deps } = makeMenuDeps({
|
|
342
|
+
select: async () => ({ ok: false as const, reason: "cursor verification failed" }),
|
|
343
|
+
});
|
|
344
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
345
|
+
expect(out.answer).toContain("failed");
|
|
346
|
+
expect(out.reply.text).toContain("cursor verification failed");
|
|
347
|
+
// The menu buttons are preserved — a failure no longer collapses the
|
|
348
|
+
// menu to a button-less error (the "nothing happened" bug).
|
|
349
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("a successful switch banners the confirmation, keeps the menu, AND reports the live model for /status", async () => {
|
|
353
|
+
const { deps } = makeMenuDeps({
|
|
354
|
+
select: async () => ({ ok: true as const, confirmation: "Set model to Haiku 4.5 for this session only" }),
|
|
355
|
+
});
|
|
356
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
|
|
357
|
+
expect(out.answer).toContain("Haiku 4.5");
|
|
358
|
+
expect(out.reply.text).toContain("✅");
|
|
359
|
+
expect(out.reply.text).toContain("Set model to Haiku 4.5");
|
|
360
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
361
|
+
// The gateway records this so /status reflects the live session model.
|
|
362
|
+
expect(out.selectedModel).toBe("Haiku 4.5");
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("sessionModelFromConfirmation", () => {
|
|
367
|
+
it("pulls the model name from claude's session-switch confirmation", () => {
|
|
368
|
+
expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
|
|
369
|
+
expect(sessionModelFromConfirmation("Set model to Opus 4.8 (1M context) for this session only")).toBe("Opus 4.8");
|
|
370
|
+
expect(sessionModelFromConfirmation("Switched to Haiku 4.5")).toBe("Haiku 4.5");
|
|
371
|
+
});
|
|
372
|
+
it("returns null when no recognizable name is present", () => {
|
|
373
|
+
expect(sessionModelFromConfirmation("Kept model as Opus 4.8 (default)")).toBeNull();
|
|
374
|
+
expect(sessionModelFromConfirmation("")).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("mdl:r re-renders the dashboard", async () => {
|
|
378
|
+
const { deps, calls } = makeMenuDeps();
|
|
379
|
+
const out = await handleModelMenuCallback(MODEL_CALLBACK_REFRESH, deps);
|
|
380
|
+
expect(out.answer).toBe("Refreshed");
|
|
381
|
+
expect(calls.discover).toBe(1);
|
|
382
|
+
expect(out.reply.keyboard).toBeDefined();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -82,6 +82,17 @@ describe("formatAgentLine", () => {
|
|
|
82
82
|
const out = formatAgentLine({ ...baseMeta, topicName: "Planning", topicEmoji: "🗓" });
|
|
83
83
|
expect(out).toContain("topic: 🗓 Planning");
|
|
84
84
|
});
|
|
85
|
+
it("shows the live session model alongside the configured model when a /model switch is active", () => {
|
|
86
|
+
const out = formatAgentLine({ ...baseMeta, model: "claude-fable-5[1m]", sessionModel: "Opus 4.8 (1M context)" });
|
|
87
|
+
// Both surfaces present + agree: configured AND what's actually running.
|
|
88
|
+
expect(out).toContain("<code>claude-fable-5[1m]</code>");
|
|
89
|
+
expect(out).toContain("live session: <code>Opus 4.8 (1M context)</code>");
|
|
90
|
+
});
|
|
91
|
+
it("omits the session line when no override is active", () => {
|
|
92
|
+
expect(formatAgentLine({ ...baseMeta, sessionModel: null })).not.toContain("live session");
|
|
93
|
+
expect(formatAgentLine({ ...baseMeta, sessionModel: "" })).not.toContain("live session");
|
|
94
|
+
expect(formatAgentLine(baseMeta)).not.toContain("live session");
|
|
95
|
+
});
|
|
85
96
|
it("omits topic when only emoji is set", () => {
|
|
86
97
|
// topicName null → no topic chunk. Keeps the line clean.
|
|
87
98
|
expect(formatAgentLine({ ...baseMeta, topicEmoji: "🗓" })).not.toContain("topic");
|