switchroom 0.15.41 → 0.15.43

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.
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import {
3
+ buildContextOccupancy,
4
+ writeContextOccupancySnapshot,
5
+ TIGHT_FRACTION,
6
+ } from '../gateway/context-occupancy.js'
7
+
8
+ describe('buildContextOccupancy', () => {
9
+ it('computes headroom + pct + ok state under the cap', () => {
10
+ const s = buildContextOccupancy(47000, 300000, 123)
11
+ expect(s).toMatchObject({ occupancy: 47000, cap: 300000, headroom: 253000, state: 'ok', computedAt: 123 })
12
+ expect(s.pct).toBeCloseTo(0.1567, 3)
13
+ })
14
+
15
+ it('flags "tight" at/above TIGHT_FRACTION of the cap', () => {
16
+ const atThreshold = buildContextOccupancy(Math.ceil(300000 * TIGHT_FRACTION), 300000, 1)
17
+ expect(atThreshold.state).toBe('tight')
18
+ expect(buildContextOccupancy(250000, 300000, 1).state).toBe('tight') // 83% ≥ 80%
19
+ expect(buildContextOccupancy(231000, 300000, 1).state).toBe('ok') // 77% < 80%
20
+ expect(buildContextOccupancy(239999, 300000, 1).state).toBe('ok') // just under 80%
21
+ })
22
+
23
+ it('no cap → ok, null ratio (occupancy known, no ceiling)', () => {
24
+ const s = buildContextOccupancy(50000, null, 1)
25
+ expect(s).toMatchObject({ occupancy: 50000, cap: null, headroom: null, pct: null, state: 'ok' })
26
+ expect(buildContextOccupancy(50000, 0, 1).cap).toBeNull() // cap<=0 treated as none
27
+ })
28
+
29
+ it('unmeasurable occupancy → unknown', () => {
30
+ expect(buildContextOccupancy(NaN, 300000, 1).state).toBe('unknown')
31
+ expect(buildContextOccupancy(-5, 300000, 1).state).toBe('unknown')
32
+ })
33
+ })
34
+
35
+ describe('writeContextOccupancySnapshot', () => {
36
+ it('writes <stateDir>/context-occupancy.json via injected fs', () => {
37
+ let path = ''
38
+ let data = ''
39
+ writeContextOccupancySnapshot('/state/agent', buildContextOccupancy(47000, 300000, 1), {
40
+ mkdir: () => {},
41
+ writeFile: (p, d) => { path = p; data = d },
42
+ })
43
+ expect(path).toBe('/state/agent/context-occupancy.json')
44
+ expect(JSON.parse(data).occupancy).toBe(47000)
45
+ })
46
+
47
+ it('never throws when the write fails (best-effort)', () => {
48
+ expect(() =>
49
+ writeContextOccupancySnapshot('/x', buildContextOccupancy(1, 2, 1), {
50
+ mkdir: () => {},
51
+ writeFile: () => { throw new Error('EACCES') },
52
+ }),
53
+ ).not.toThrow()
54
+ })
55
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ decideIdleClear,
4
+ idleDurationToMs,
5
+ DEFAULT_IDLE_CLEAR_MS,
6
+ type IdleClearState,
7
+ } from '../gateway/idle-clear.js'
8
+
9
+ const H = 3_600_000
10
+ function state(p: Partial<IdleClearState>): IdleClearState {
11
+ return { lastActivityAt: 0, idleClearMs: 3 * H, alreadyCleared: false, turnInFlight: false, ...p }
12
+ }
13
+
14
+ describe('decideIdleClear', () => {
15
+ it('fires once the idle window has elapsed', () => {
16
+ expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
17
+ expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H + 1).clear).toBe(true)
18
+ })
19
+
20
+ it('does NOT fire before the window', () => {
21
+ expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H - 1).clear).toBe(false)
22
+ })
23
+
24
+ it('never fires mid-turn (turnInFlight)', () => {
25
+ expect(decideIdleClear(state({ lastActivityAt: 0, turnInFlight: true }), 10 * H).clear).toBe(false)
26
+ })
27
+
28
+ it('fires once per idle period (alreadyCleared guard)', () => {
29
+ expect(decideIdleClear(state({ lastActivityAt: 0, alreadyCleared: true }), 10 * H).clear).toBe(false)
30
+ })
31
+
32
+ it('is disabled when idleClearMs <= 0', () => {
33
+ expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: 0 }), 10 * H).clear).toBe(false)
34
+ expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: -1 }), 10 * H).clear).toBe(false)
35
+ })
36
+
37
+ it('re-arms after activity (fresh lastActivityAt + alreadyCleared=false → waits again)', () => {
38
+ // Simulated: after a clear, activity resets lastActivityAt to `now` and the flag.
39
+ const now = 100 * H
40
+ const reArmed = state({ lastActivityAt: now, alreadyCleared: false })
41
+ expect(decideIdleClear(reArmed, now + 3 * H - 1).clear).toBe(false) // not yet
42
+ expect(decideIdleClear(reArmed, now + 3 * H).clear).toBe(true) // again, one window later
43
+ })
44
+ })
45
+
46
+ describe('idleDurationToMs', () => {
47
+ it('parses s/m/h', () => {
48
+ expect(idleDurationToMs('3h')).toBe(3 * H)
49
+ expect(idleDurationToMs('90m')).toBe(90 * 60_000)
50
+ expect(idleDurationToMs('7200s')).toBe(7_200_000)
51
+ expect(idleDurationToMs('0s')).toBe(0) // disable sentinel
52
+ })
53
+ it('returns null on malformed input (caller falls back to default)', () => {
54
+ expect(idleDurationToMs('3')).toBeNull()
55
+ expect(idleDurationToMs('3d')).toBeNull()
56
+ expect(idleDurationToMs('abc')).toBeNull()
57
+ expect(idleDurationToMs('')).toBeNull()
58
+ })
59
+ it('default is 3h', () => {
60
+ expect(DEFAULT_IDLE_CLEAR_MS).toBe(3 * H)
61
+ })
62
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ recoverFromPollStall,
4
+ POLL_STALL_EXIT_CODE,
5
+ } from '../gateway/poll-stall-recovery.js'
6
+
7
+ describe('recoverFromPollStall', () => {
8
+ it('exits with code 1 — and NEVER 78 (78 = permanent quarantine)', () => {
9
+ const codes: number[] = []
10
+ recoverFromPollStall({ exit: (c) => { codes.push(c) }, log: () => {} })
11
+ expect(codes).toEqual([1])
12
+ expect(POLL_STALL_EXIT_CODE).toBe(1)
13
+ expect(codes).not.toContain(78) // the sharp edge: quarantine on a transient stall
14
+ })
15
+
16
+ it('logs a recovery line carrying the agent name + the reason (no stop() await)', () => {
17
+ const lines: string[] = []
18
+ recoverFromPollStall({ exit: () => {}, log: (m) => { lines.push(m) }, agentName: 'carrie' })
19
+ expect(lines).toHaveLength(1)
20
+ expect(lines[0]).toContain('stall_recovery')
21
+ expect(lines[0]).toContain('agent=carrie')
22
+ expect(lines[0]).toContain('code=1')
23
+ // documents WHY we don't await stop() — guards against a future revert
24
+ expect(lines[0]).toMatch(/backoff|stop\(\)/)
25
+ })
26
+
27
+ it('exits exactly once', () => {
28
+ let calls = 0
29
+ recoverFromPollStall({ exit: () => { calls++ }, log: () => {} })
30
+ expect(calls).toBe(1)
31
+ })
32
+ })
@@ -12,7 +12,6 @@ import {
12
12
  switchroomHelpCommandNames,
13
13
  restartAckText,
14
14
  newSessionAckText,
15
- resetSessionAckText,
16
15
  TELEGRAM_BASE_COMMANDS,
17
16
  TELEGRAM_SWITCHROOM_COMMANDS,
18
17
  TELEGRAM_MENU_COMMANDS,
@@ -137,7 +136,8 @@ describe("helpText", () => {
137
136
  expect(out).toContain("/deny");
138
137
  expect(out).toContain("/pending");
139
138
  expect(out).toContain("/new");
140
- expect(out).toContain("/reset");
139
+ expect(out).toContain("/compact");
140
+ expect(out).toContain("/clear");
141
141
  });
142
142
  it("points at the richer /commands", () => {
143
143
  expect(helpText("assistant")).toContain("/commands");
@@ -347,9 +347,11 @@ describe("switchroomHelpText + switchroomHelpCommandNames", () => {
347
347
  expect(out).toContain("<b>Auth &amp; config</b>");
348
348
  });
349
349
  it("the name array contains the Sprint 2/3 additions", () => {
350
- for (const needed of ["new", "reset", "approve", "deny", "pending"]) {
350
+ for (const needed of ["new", "compact", "clear", "approve", "deny", "pending"]) {
351
351
  expect(switchroomHelpCommandNames).toContain(needed);
352
352
  }
353
+ // /reset was removed (it was a pure alias of /new).
354
+ expect(switchroomHelpCommandNames).not.toContain("reset");
353
355
  });
354
356
  });
355
357
 
@@ -370,9 +372,11 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
370
372
  it("menu includes the session-control commands (the most-used trio)", () => {
371
373
  const names = TELEGRAM_MENU_COMMANDS.map(c => c.command);
372
374
  // These MUST be in the menu — they're the primary mobile UX flows
373
- for (const must of ["new", "reset", "approve", "deny", "pending", "restart", "logs", "commands"]) {
375
+ for (const must of ["new", "compact", "clear", "approve", "deny", "pending", "restart", "logs", "commands"]) {
374
376
  expect(names, `missing /${must} from Telegram menu`).toContain(must);
375
377
  }
378
+ // /reset removed (alias of /new).
379
+ expect(names, "/reset should be gone from the menu").not.toContain("reset");
376
380
  });
377
381
 
378
382
  it("menu drops the ops primitives that cluttered the old catalogue", () => {
@@ -451,7 +455,7 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
451
455
  });
452
456
  });
453
457
 
454
- describe("restart / new / reset ack text", () => {
458
+ describe("restart / new ack text", () => {
455
459
  it("restartAckText is consistent", () => {
456
460
  expect(restartAckText("assistant")).toBe("🔄 Restarting <b>assistant</b>…");
457
461
  });
@@ -463,13 +467,8 @@ describe("restart / new / reset ack text", () => {
463
467
  expect(newSessionAckText("assistant", false))
464
468
  .toBe("🆕 Started fresh session for <b>assistant</b> · restarting…");
465
469
  });
466
- it("resetSessionAckText with flush", () => {
467
- expect(resetSessionAckText("assistant", true))
468
- .toBe("🔄 Reset session for <b>assistant</b> · flushed handoff · restarting…");
469
- });
470
- it("HTML-escapes agent name in all three", () => {
470
+ it("HTML-escapes agent name in both", () => {
471
471
  expect(restartAckText("<x>")).toContain("&lt;x&gt;");
472
472
  expect(newSessionAckText("<x>", true)).toContain("&lt;x&gt;");
473
- expect(resetSessionAckText("<x>", true)).toContain("&lt;x&gt;");
474
473
  });
475
474
  });
@@ -170,7 +170,7 @@ export function helpText(agentName: string): string {
170
170
  ``,
171
171
  `This bot is the <b>${escapeHtml(agentName)}</b> agent. Text and photos route through to it; replies, reactions and progress cards come back.`,
172
172
  ``,
173
- `Tool approvals surface as inline buttons (✅ / ❌) or via <code>/approve</code>, <code>/deny</code>, <code>/pending</code>. Start a fresh session with <code>/new</code> or <code>/reset</code>.`,
173
+ `Tool approvals surface as inline buttons (✅ / ❌) or via <code>/approve</code>, <code>/deny</code>, <code>/pending</code>. Start a fresh session with <code>/new</code>, or trim/clear context with <code>/compact</code> / <code>/clear</code>.`,
174
174
  ``,
175
175
  `<code>/start</code> — pairing instructions`,
176
176
  `<code>/status</code> — agent, model, auth`,
@@ -265,7 +265,7 @@ export function statusUnpairedText(): string {
265
265
  */
266
266
  export const switchroomHelpCommandNames = [
267
267
  // Session & approvals
268
- "new", "reset", "approve", "deny", "pending", "interrupt",
268
+ "new", "compact", "clear", "approve", "deny", "pending", "interrupt",
269
269
  // Agents
270
270
  "agents", "agentstart", "stop", "restart", "logs", "memory",
271
271
  // Auth & config — consolidated onto the `/auth` dashboard.
@@ -298,7 +298,8 @@ export const TELEGRAM_MENU_COMMANDS = [
298
298
  { command: "status", description: "Agent, model, auth" },
299
299
  // Session control (most-used)
300
300
  { command: "new", description: "Fresh session (flush handoff, restart)" },
301
- { command: "reset", description: "Alias of /new" },
301
+ { command: "compact", description: "Compact context (summarize, keep the thread)" },
302
+ { command: "clear", description: "Clear context (fresh slate; memory in Hindsight)" },
302
303
  // Inline approvals
303
304
  { command: "approve", description: "Approve pending tool permission" },
304
305
  { command: "deny", description: "Deny pending tool permission" },
@@ -312,8 +313,10 @@ export const TELEGRAM_MENU_COMMANDS = [
312
313
  // #725 Phase 2 — inject a Claude Code REPL slash command into the agent's
313
314
  // tmux pane (allowlisted: /cost, /status, /model, /clear, /compact,
314
315
  // /memory, /hooks). Requires the tmux supervisor (the default — refused
315
- // when the agent has experimental.legacy_pty=true).
316
- { command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
316
+ // when the agent has experimental.legacy_pty=true). NOT in the slash-menu
317
+ // (kept the 20-entry mobile cap; the common injects /compact, /clear,
318
+ // /model, /effort are first-class menu commands). Still typable + in
319
+ // /commands.
317
320
  // /model — show or switch the Claude model (session-scoped; rides the
318
321
  // same inject primitive as `/inject /model` but with a typed argument,
319
322
  // so it never opens the undriveable no-arg picker modal).
@@ -356,7 +359,8 @@ export function switchroomHelpText(agentName: string): string {
356
359
  ``,
357
360
  `<b>Session &amp; approvals</b>`,
358
361
  `<code>/new</code> — fresh session (flush handoff, restart)`,
359
- `<code>/reset</code> — alias of /new`,
362
+ `<code>/compact</code> — compact context (summarize, keep the thread)`,
363
+ `<code>/clear</code> — clear context (fresh slate; memory in Hindsight)`,
360
364
  `<code>/approve [id]</code> — approve pending tool permission`,
361
365
  `<code>/deny [id]</code> — deny pending tool permission`,
362
366
  `<code>/pending</code> — list pending permission prompts`,
@@ -404,7 +408,7 @@ export function switchroomHelpText(agentName: string): string {
404
408
  }
405
409
 
406
410
  /**
407
- * Ack shown when a self-targeting /restart (or /new, /reset) kicks off.
411
+ * Ack shown when a self-targeting /restart (or /new) kicks off.
408
412
  * Centralized so gateway and monolith agree on wording.
409
413
  */
410
414
  export function restartAckText(agentName: string): string {
@@ -415,8 +419,3 @@ export function newSessionAckText(agentName: string, flushedHandoff: boolean): s
415
419
  const tail = flushedHandoff ? " · flushed handoff" : "";
416
420
  return `🆕 Started fresh session for <b>${escapeHtml(agentName)}</b>${tail} · restarting…`;
417
421
  }
418
-
419
- export function resetSessionAckText(agentName: string, flushedHandoff: boolean): string {
420
- const tail = flushedHandoff ? " · flushed handoff" : "";
421
- return `🔄 Reset session for <b>${escapeHtml(agentName)}</b>${tail} · restarting…`;
422
- }