switchroom 0.19.48 → 0.20.0

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 (50) hide show
  1. package/dist/agent-scheduler/index.js +18 -1
  2. package/dist/auth-broker/index.js +19 -2
  3. package/dist/buzz-gateway/index.js +9207 -0
  4. package/dist/cli/notion-write-pretool.mjs +18 -1
  5. package/dist/cli/switchroom.js +63 -4
  6. package/dist/host-control/main.js +20 -3
  7. package/dist/vault/approvals/kernel-server.js +19 -2
  8. package/dist/vault/broker/server.js +19 -2
  9. package/package.json +4 -3
  10. package/profiles/_base/start.sh.hbs +78 -1
  11. package/profiles/default/CLAUDE.md.hbs +1 -1
  12. package/skills/dev-protocol/SKILL.md +30 -1
  13. package/skills/switchroom-architecture/SKILL.md +5 -0
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1149 -247
  17. package/telegram-plugin/dist/server.js +7 -4
  18. package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
  19. package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
  20. package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
  21. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  22. package/telegram-plugin/gateway/channel-route.ts +272 -0
  23. package/telegram-plugin/gateway/gateway.ts +73 -81
  24. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  25. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  27. package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
  28. package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
  29. package/telegram-plugin/gateway/stream-render.ts +21 -0
  30. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  31. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  32. package/telegram-plugin/history.ts +15 -0
  33. package/telegram-plugin/llm-error-present.ts +9 -4
  34. package/telegram-plugin/model-unavailable.ts +4 -0
  35. package/telegram-plugin/operator-events.fixtures.json +12 -12
  36. package/telegram-plugin/operator-events.ts +81 -9
  37. package/telegram-plugin/session-tail.ts +7 -1
  38. package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
  39. package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
  40. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  41. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  42. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  43. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  44. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  45. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  46. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  47. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  48. package/telegram-plugin/voice-normalize-text.ts +5 -0
  49. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  50. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,269 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { mkdtempSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { createConnection, type Socket } from "net";
6
+ import { createIpcServer, type IpcServer, type IpcClient } from "../gateway/ipc-server.js";
7
+ import type { OutboundToBuzzMessage } from "../gateway/ipc-protocol.js";
8
+
9
+ /**
10
+ * Buzz co-channel Phase 2b — S7 role-disjointness, enforced as a GATEWAY CODE
11
+ * mechanism (not sidecar self-discipline). These are real-Unix-socket tests: a
12
+ * raw net client sends hand-crafted frames so we can exercise the adversarial
13
+ * orderings a well-behaved sidecar would never emit.
14
+ *
15
+ * The invariants (ipc-server.ts handleHelloBuzzPeer / handleRegister):
16
+ * - a hello_buzz_peer parks the connection as the single Buzz peer, NEVER in
17
+ * agentIndex (getClient stays undefined; the peer's agentName is null);
18
+ * - register on a peer connection is refused (close+drop);
19
+ * - hello on an already-registered connection is refused (close+drop);
20
+ * - the peer rides the watchdog's agentName===null exemption (never evicted);
21
+ * - the peer slot is released on disconnect (identity-checked).
22
+ */
23
+
24
+ function tmpSocket(): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "ipc-buzz-peer-"));
26
+ return join(dir, "test.sock");
27
+ }
28
+ function wait(ms: number): Promise<void> {
29
+ return new Promise((r) => setTimeout(r, ms));
30
+ }
31
+ const OUTBOUND: OutboundToBuzzMessage = {
32
+ type: "outbound_to_buzz",
33
+ correlationId: "c1",
34
+ agentName: "klanker",
35
+ channelId: "chan",
36
+ payload: { kind: "message", text: "x" },
37
+ };
38
+
39
+ describe("ipc-server — Buzz peer role-disjointness (S7)", () => {
40
+ const servers: IpcServer[] = [];
41
+ const sockets: Socket[] = [];
42
+
43
+ afterEach(async () => {
44
+ for (const s of sockets) { try { s.destroy(); } catch { /* ignore */ } }
45
+ sockets.length = 0;
46
+ for (const srv of servers) await srv.close();
47
+ servers.length = 0;
48
+ });
49
+
50
+ function makeServer(overrides: Partial<Parameters<typeof createIpcServer>[0]> = {}) {
51
+ const registered: IpcClient[] = [];
52
+ const server = createIpcServer({
53
+ socketPath: overrides.socketPath ?? tmpSocket(),
54
+ onClientRegistered: (c) => registered.push(c),
55
+ onClientDisconnected: () => {},
56
+ onToolCall: async (_c, m) => ({ type: "tool_call_result", id: m.id, success: true }),
57
+ onSessionEvent: () => {},
58
+ onPermissionRequest: () => {},
59
+ onHeartbeat: () => {},
60
+ onScheduleRestart: () => {},
61
+ ...overrides,
62
+ });
63
+ servers.push(server);
64
+ return { server, registered };
65
+ }
66
+
67
+ /** Open a raw client, resolve once connected. Detects server-side close. */
68
+ function rawClient(socketPath: string) {
69
+ const sock = createConnection(socketPath);
70
+ sockets.push(sock);
71
+ let closedByServer = false;
72
+ sock.on("close", () => { closedByServer = true; });
73
+ sock.on("error", () => { /* server may reset on refuse */ });
74
+ const send = (obj: unknown) => sock.write(JSON.stringify(obj) + "\n");
75
+ const ready = new Promise<void>((res) => sock.on("connect", () => res()));
76
+ return { sock, send, ready, wasClosed: () => closedByServer };
77
+ }
78
+
79
+ it("parks a hello_buzz_peer as the Buzz peer, NOT in agentIndex", async () => {
80
+ const path = tmpSocket();
81
+ const { server } = makeServer({ socketPath: path });
82
+ const c = rawClient(path);
83
+ await c.ready;
84
+ c.send({ type: "hello_buzz_peer", agentName: "klanker" });
85
+ await wait(60);
86
+
87
+ // Addressable as the peer, but never as an agent bridge.
88
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
89
+ expect(server.getClient("klanker")).toBeUndefined();
90
+ });
91
+
92
+ it("REFUSES a register after hello_buzz_peer (peer must never claim a slot)", async () => {
93
+ const path = tmpSocket();
94
+ const { server } = makeServer({ socketPath: path });
95
+ const c = rawClient(path);
96
+ await c.ready;
97
+ c.send({ type: "hello_buzz_peer", agentName: "klanker" });
98
+ await wait(40);
99
+ c.send({ type: "register", agentName: "klanker" });
100
+ await wait(80);
101
+
102
+ // The offending connection was closed+dropped; no agent slot was created.
103
+ expect(c.wasClosed()).toBe(true);
104
+ expect(server.getClient("klanker")).toBeUndefined();
105
+ });
106
+
107
+ it("REFUSES a hello_buzz_peer after register (bridge must never become the peer)", async () => {
108
+ const path = tmpSocket();
109
+ const { server } = makeServer({ socketPath: path });
110
+ const c = rawClient(path);
111
+ await c.ready;
112
+ c.send({ type: "register", agentName: "klanker" });
113
+ await wait(40);
114
+ expect(server.getClient("klanker")).toBeDefined(); // registered as a bridge
115
+ c.send({ type: "hello_buzz_peer", agentName: "klanker" });
116
+ await wait(80);
117
+
118
+ // The connection is closed+dropped; no Buzz peer was installed.
119
+ expect(c.wasClosed()).toBe(true);
120
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(false);
121
+ });
122
+
123
+ it("EXEMPTS the peer from the heartbeat watchdog (agentName===null)", async () => {
124
+ const path = tmpSocket();
125
+ // Aggressive watchdog: 200ms timeout, no heartbeats from the peer.
126
+ const { server } = makeServer({ socketPath: path, heartbeatTimeoutMs: 200 });
127
+ const c = rawClient(path);
128
+ await c.ready;
129
+ c.send({ type: "hello_buzz_peer", agentName: "klanker" });
130
+ await wait(60);
131
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
132
+
133
+ // Well past the timeout with no heartbeat — a registered bridge would be
134
+ // evicted, but the peer (agentName===null) is exempt and survives.
135
+ await wait(500);
136
+ expect(c.wasClosed()).toBe(false);
137
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
138
+ });
139
+
140
+ it("releases the peer slot on disconnect (sendToBuzzPeer → false)", async () => {
141
+ const path = tmpSocket();
142
+ const { server } = makeServer({ socketPath: path });
143
+ const c = rawClient(path);
144
+ await c.ready;
145
+ c.send({ type: "hello_buzz_peer", agentName: "klanker" });
146
+ await wait(60);
147
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
148
+
149
+ c.sock.destroy();
150
+ await wait(100);
151
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(false);
152
+ });
153
+
154
+ it("forwards outbound_to_buzz to the peer and delivers buzz_publish_result to the handler", async () => {
155
+ const path = tmpSocket();
156
+ const onBuzzPublishResult = vi.fn();
157
+ const { server } = makeServer({ socketPath: path, onBuzzPublishResult });
158
+ const c = rawClient(path);
159
+ const received: unknown[] = [];
160
+ c.sock.on("data", (d: Buffer) => {
161
+ for (const line of d.toString().split("\n")) {
162
+ if (line.trim()) received.push(JSON.parse(line));
163
+ }
164
+ });
165
+ await c.ready;
166
+ c.send({ type: "hello_buzz_peer", agentName: "klanker" });
167
+ await wait(60);
168
+
169
+ // Hub → peer.
170
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
171
+ await wait(40);
172
+ expect(received).toContainEqual(expect.objectContaining({ type: "outbound_to_buzz", correlationId: "c1" }));
173
+
174
+ // Peer → hub.
175
+ c.send({ type: "buzz_publish_result", correlationId: "c1", ok: true, eventId: "evt-x" });
176
+ await wait(60);
177
+ expect(onBuzzPublishResult).toHaveBeenCalledTimes(1);
178
+ expect(onBuzzPublishResult.mock.calls[0][1]).toMatchObject({ correlationId: "c1", ok: true, eventId: "evt-x" });
179
+ });
180
+
181
+ // ── MAJOR-1a: confused-deputy close on the agent→peer surface ─────────────
182
+ it("IGNORES buzz_publish_result from a NON-peer connection (MAJOR-1a)", async () => {
183
+ const path = tmpSocket();
184
+ const onBuzzPublishResult = vi.fn();
185
+ makeServer({ socketPath: path, onBuzzPublishResult });
186
+
187
+ // (1) A fresh anonymous client that never announced hello_buzz_peer forges a
188
+ // publish result carrying a valid-looking correlationId + a foreign eventId.
189
+ const impostor = rawClient(path);
190
+ await impostor.ready;
191
+ impostor.send({ type: "buzz_publish_result", correlationId: "forged-1", ok: true, eventId: "attacker-evt" });
192
+ await wait(80);
193
+ expect(onBuzzPublishResult).not.toHaveBeenCalled();
194
+
195
+ // (2) A registered AGENT bridge (also not the peer) is likewise refused.
196
+ const bridge = rawClient(path);
197
+ await bridge.ready;
198
+ bridge.send({ type: "register", agentName: "klanker" });
199
+ await wait(40);
200
+ bridge.send({ type: "buzz_publish_result", correlationId: "forged-2", ok: true, eventId: "attacker-evt-2" });
201
+ await wait(80);
202
+ expect(onBuzzPublishResult).not.toHaveBeenCalled();
203
+
204
+ // (3) Contrast — the REAL peer's result DOES reach the handler, proving the
205
+ // guard blocks impostors specifically, not the mechanism wholesale.
206
+ const peer = rawClient(path);
207
+ await peer.ready;
208
+ peer.send({ type: "hello_buzz_peer", agentName: "klanker" });
209
+ await wait(40);
210
+ peer.send({ type: "buzz_publish_result", correlationId: "real-1", ok: true, eventId: "evt-real" });
211
+ await wait(80);
212
+ expect(onBuzzPublishResult).toHaveBeenCalledTimes(1);
213
+ expect(onBuzzPublishResult.mock.calls[0][1]).toMatchObject({ correlationId: "real-1" });
214
+ });
215
+
216
+ // ── MAJOR-1b: a fresh hello cannot displace a LIVE peer ───────────────────
217
+ it("REFUSES a second hello_buzz_peer while a LIVE peer is connected (MAJOR-1b)", async () => {
218
+ const path = tmpSocket();
219
+ const onBuzzPublishResult = vi.fn();
220
+ const { server } = makeServer({ socketPath: path, onBuzzPublishResult });
221
+
222
+ const peer1 = rawClient(path);
223
+ await peer1.ready;
224
+ peer1.send({ type: "hello_buzz_peer", agentName: "klanker" });
225
+ await wait(60);
226
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
227
+
228
+ // An impostor tries to seize the peer slot with a fresh hello, then forge a
229
+ // publish result — the attack that would poison msgToBuzz.
230
+ const impostor = rawClient(path);
231
+ await impostor.ready;
232
+ impostor.send({ type: "hello_buzz_peer", agentName: "klanker" });
233
+ await wait(60);
234
+ impostor.send({ type: "buzz_publish_result", correlationId: "forged", ok: true, eventId: "attacker-evt" });
235
+ await wait(80);
236
+
237
+ // The impostor was refused (close+drop); the real peer is UNDISTURBED and
238
+ // still the addressed peer; the forged result never reached the handler.
239
+ expect(impostor.wasClosed()).toBe(true);
240
+ expect(peer1.wasClosed()).toBe(false);
241
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
242
+ expect(onBuzzPublishResult).not.toHaveBeenCalled();
243
+ });
244
+
245
+ it("ALLOWS a legitimate reconnect after the prior peer connection CLOSES (MAJOR-1b)", async () => {
246
+ const path = tmpSocket();
247
+ const { server } = makeServer({ socketPath: path });
248
+
249
+ const peer1 = rawClient(path);
250
+ await peer1.ready;
251
+ peer1.send({ type: "hello_buzz_peer", agentName: "klanker" });
252
+ await wait(60);
253
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
254
+
255
+ // The sidecar's socket drops (crash/restart); removeClient nulls the slot.
256
+ peer1.sock.destroy();
257
+ await wait(120);
258
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(false);
259
+
260
+ // A fresh reconnect + hello must succeed — the refuse-only-while-alive rule
261
+ // must not brick a real reconnect.
262
+ const peer2 = rawClient(path);
263
+ await peer2.ready;
264
+ peer2.send({ type: "hello_buzz_peer", agentName: "klanker" });
265
+ await wait(60);
266
+ expect(peer2.wasClosed()).toBe(false);
267
+ expect(server.sendToBuzzPeer(OUTBOUND)).toBe(true);
268
+ });
269
+ });
@@ -107,6 +107,42 @@ describe('detectErrorInTranscriptLine — error detection', () => {
107
107
  expect(result!.terminal).toBe(true)
108
108
  })
109
109
 
110
+ it('marks an in-flight transport-transient retry NOT terminal (does not count toward escalation)', () => {
111
+ // A flaky request Claude Code is internally retrying (retryAttempt <
112
+ // maxRetries), surfaced as a bare `server_error` with no status. Pre-fix
113
+ // `transient` was false for transport-transient, so it was UNCONDITIONALLY
114
+ // terminal → a single recoverable request retried 3x could cross the ≥3
115
+ // escalation threshold and fire the operator "Repeated stream failures"
116
+ // card. It must be transient + in-flight (suppressed), counting nothing.
117
+ const line = JSON.stringify({
118
+ type: 'system',
119
+ subtype: 'api_error',
120
+ error: { type: 'server_error', message: 'Connection closed mid-response' },
121
+ retryAttempt: 2,
122
+ maxRetries: 10,
123
+ })
124
+ const result = detectErrorInTranscriptLine(line)
125
+ expect(result!.kind).toBe('transport-transient')
126
+ expect(result!.transient).toBe(true)
127
+ // 2 < 10 — still retrying → in-flight → the caller suppresses it (not counted).
128
+ expect(result!.terminal).toBe(false)
129
+ })
130
+
131
+ it('marks an exhausted transport-transient retry terminal (counts toward escalation)', () => {
132
+ const line = JSON.stringify({
133
+ type: 'system',
134
+ subtype: 'api_error',
135
+ error: { type: 'server_error', message: 'Connection closed mid-response' },
136
+ retryAttempt: 10,
137
+ maxRetries: 10,
138
+ })
139
+ const result = detectErrorInTranscriptLine(line)
140
+ expect(result!.kind).toBe('transport-transient')
141
+ expect(result!.transient).toBe(true)
142
+ // retries exhausted → terminal → escalates.
143
+ expect(result!.terminal).toBe(true)
144
+ })
145
+
110
146
  it('marks non-transient errors terminal (always escalate)', () => {
111
147
  const line = JSON.stringify({
112
148
  type: 'api_error',
@@ -156,6 +192,33 @@ describe('detectErrorInTranscriptLine — error detection', () => {
156
192
  expect(result!.detail).toContain('hit your limit')
157
193
  })
158
194
 
195
+ // Regression — the transport-abort bug this PR fixes. On a mid-response stream
196
+ // abort Claude Code emits `isApiErrorMessage:true` with `error:"server_error"`
197
+ // and NO `apiErrorStatus`. Pre-fix that had no transport branch and fell
198
+ // through to the status fallback, FABRICATING `unknown-4xx` — a card carrying a
199
+ // wrong Reauth button, broadcast to every chat. It must classify
200
+ // transport-transient (terminal, non-transient) instead.
201
+ it('classifies the verbatim server_error mid-stream abort as transport-transient (not unknown-4xx)', () => {
202
+ // The exact on-disk shape: isApiErrorMessage, error "server_error", NO status.
203
+ const line = JSON.stringify({
204
+ type: 'assistant',
205
+ message: {
206
+ role: 'assistant',
207
+ model: '<synthetic>',
208
+ content: [{ type: 'text', text: 'API Error: Connection closed mid-response' }],
209
+ },
210
+ error: 'server_error',
211
+ isApiErrorMessage: true,
212
+ })
213
+ const result = detectErrorInTranscriptLine(line)
214
+ expect(result).not.toBeNull()
215
+ expect(result!.kind).toBe('transport-transient')
216
+ expect(result!.kind).not.toBe('unknown-4xx')
217
+ // Claude writes this shape only after its own retries are exhausted → terminal.
218
+ expect(result!.terminal).toBe(true)
219
+ expect(result!.transient).toBe(false)
220
+ })
221
+
159
222
  // Regression — the carrie incident (2026-07-12). Anthropic also emits a
160
223
  // 429 for a TRANSIENT per-account burst / RPM throttle whose wording
161
224
  // explicitly negates the account-quota reading ("would exceed your
@@ -142,13 +142,61 @@ describe('classifyClaudeError — safety: must never throw', () => {
142
142
  }
143
143
  })
144
144
 
145
- describe('classifyClaudeError — unknown-4xx is the default fallback', () => {
146
- it('unknown object with no status defaults to unknown-4xx', () => {
147
- expect(classifyClaudeError({ totally: 'unrecognised' })).toBe('unknown-4xx')
145
+ describe('classifyClaudeError — no-status default is the neutral 5xx (never a fabricated 4xx)', () => {
146
+ it('unknown object with no status defaults to unknown-5xx, NOT unknown-4xx', () => {
147
+ // Regression: an unrecognized shape with no HTTP status used to fabricate
148
+ // `unknown-4xx`, whose card carried a wrong Reauth remedy broadcast to every
149
+ // chat. With no status the honest neutral default is unknown-5xx.
150
+ expect(classifyClaudeError({ totally: 'unrecognised' })).toBe('unknown-5xx')
151
+ expect(classifyClaudeError({ totally: 'unrecognised' })).not.toBe('unknown-4xx')
148
152
  })
149
153
 
150
- it('empty string → unknown-4xx', () => {
151
- expect(classifyClaudeError('')).toBe('unknown-4xx')
154
+ it('empty string → unknown-5xx', () => {
155
+ expect(classifyClaudeError('')).toBe('unknown-5xx')
156
+ })
157
+
158
+ it('still honors an explicit 4xx status', () => {
159
+ expect(classifyClaudeError({ status: 404 })).toBe('unknown-4xx')
160
+ })
161
+ })
162
+
163
+ describe('classifyClaudeError — transport-transient (mid-response stream abort)', () => {
164
+ it('classifies a bare server_error type as transport-transient', () => {
165
+ expect(classifyClaudeError({ type: 'server_error' })).toBe('transport-transient')
166
+ })
167
+
168
+ it('classifies server_error via error_code / code / nested error.type', () => {
169
+ expect(classifyClaudeError({ error_code: 'server_error' })).toBe('transport-transient')
170
+ expect(classifyClaudeError({ code: 'server_error' })).toBe('transport-transient')
171
+ expect(classifyClaudeError({ error: { type: 'server_error' } })).toBe('transport-transient')
172
+ })
173
+
174
+ it('classifies api_error (Anthropic HTTP 500) as transport-transient', () => {
175
+ expect(classifyClaudeError({ type: 'api_error' })).toBe('transport-transient')
176
+ })
177
+
178
+ it('classifies api_error/server_error carrying a 5xx status as transport-transient', () => {
179
+ expect(classifyClaudeError({ type: 'api_error', status: 500 })).toBe('transport-transient')
180
+ expect(classifyClaudeError({ type: 'server_error', status: 503 })).toBe('transport-transient')
181
+ })
182
+
183
+ it('does NOT swallow a status-bearing 4xx api_error (LiteLLM-wrapped 401) as transport-transient', () => {
184
+ // LiteLLM stamps a generic `type: "api_error"` on WRAPPED upstream faults
185
+ // that carry a 4xx status; session-tail threads that status through. The
186
+ // transport branch is guarded to status-less/5xx shapes, so the HTTP status
187
+ // wins and this surfaces an operator card (unknown-4xx) — NOT the silent
188
+ // transport-transient path. Branch-order regression guard: unlike the bare
189
+ // `{status:404}` test above, this exercises a TYPED api_error + 4xx status.
190
+ expect(classifyClaudeError({ type: 'api_error', status: 401 })).toBe('unknown-4xx')
191
+ expect(classifyClaudeError({ type: 'api_error', status: 401 })).not.toBe('transport-transient')
192
+ expect(classifyClaudeError({ type: 'server_error', status: 429 })).toBe('unknown-4xx')
193
+ })
194
+
195
+ it('does NOT fire on an unrelated error that merely mentions "server error" in prose', () => {
196
+ // Exact type/code equality only — never a message substring — so a genuine
197
+ // auth fault mislabeled with server-error prose still classifies as auth.
198
+ expect(classifyClaudeError({ type: 'authentication_error', message: 'server error while validating' }))
199
+ .toBe('credentials-invalid')
152
200
  })
153
201
  })
154
202
 
@@ -238,10 +286,25 @@ describe('renderOperatorEvent — agent-restarted-unexpectedly', () => {
238
286
  })
239
287
 
240
288
  describe('renderOperatorEvent — unknown-4xx', () => {
241
- it('surfaces "API error (4xx)" with dismiss button', () => {
289
+ it('surfaces "API error (4xx)" with a Dismiss button and NO Reauth button', () => {
242
290
  const { text, keyboard } = renderOperatorEvent(makeEvent('unknown-4xx'))
243
291
  expect(text).toContain('4xx')
244
- expect(keyboard.inline_keyboard.flat().some(b => b.callback_data?.includes('dismiss'))).toBe(true)
292
+ const buttons = keyboard.inline_keyboard.flat()
293
+ expect(buttons.some(b => b.callback_data?.includes('dismiss'))).toBe(true)
294
+ // Reauth was REMOVED — it is the wrong remedy for a catch-all client error,
295
+ // and Reauth buttons now live ONLY on credentials-* cards. Assert the OUTCOME.
296
+ expect(buttons.some(b => b.callback_data?.includes('op:reauth'))).toBe(false)
297
+ expect(buttons.some(b => b.callback_data?.includes('reauth'))).toBe(false)
298
+ })
299
+ })
300
+
301
+ describe('renderOperatorEvent — transport-transient (defensive fallback)', () => {
302
+ it('is calm and Dismiss-only — NEVER a Reauth button', () => {
303
+ const { text, keyboard } = renderOperatorEvent(makeEvent('transport-transient'))
304
+ expect(text).toContain('transport')
305
+ const buttons = keyboard.inline_keyboard.flat()
306
+ expect(buttons.some(b => b.callback_data?.includes('dismiss'))).toBe(true)
307
+ expect(buttons.some(b => b.callback_data?.includes('reauth'))).toBe(false)
245
308
  })
246
309
  })
247
310
 
@@ -289,6 +352,7 @@ describe('renderOperatorEvent — all kinds produce valid keyboard structure', (
289
352
  'rate-limited',
290
353
  'agent-crashed',
291
354
  'agent-restarted-unexpectedly',
355
+ 'transport-transient',
292
356
  'unknown-4xx',
293
357
  'unknown-5xx',
294
358
  'config-warning',
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import {
3
+ emitTransportTransientEvent,
4
+ flushDeferredUserNotices,
5
+ noteTransportTransientAndShouldEscalate,
6
+ renderTransportEscalationCard,
7
+ resetTransportTransientEscalation,
8
+ type UserFailureNoticeDeps,
9
+ } from '../gateway/user-failure-notices.js'
10
+ import type { OperatorEvent } from '../operator-events.js'
11
+ import type { PendingUserNotice } from '../pending-user-notice.js'
12
+
13
+ // ─── Fake deps: capture every side effect, drive time explicitly ─────────────
14
+
15
+ interface Capture {
16
+ recorded: OperatorEvent[]
17
+ scheduled: Array<{ chatIds: string[]; agent: string; kind: string; key: string | undefined; atMs: number }>
18
+ sent: Array<{ chatId: string; text: string; hasKeyboard: boolean }>
19
+ logs: string[]
20
+ /** Notices the fake gate will release on the next resolveNotices call. */
21
+ resolvedQueue: PendingUserNotice[]
22
+ lastResolve?: { delivered: boolean; key: string }
23
+ }
24
+
25
+ function makeDeps(over?: {
26
+ allowFrom?: string[]
27
+ liveTurnKey?: string | undefined
28
+ now?: number
29
+ }): { deps: UserFailureNoticeDeps; cap: Capture } {
30
+ const cap: Capture = { recorded: [], scheduled: [], sent: [], logs: [], resolvedQueue: [] }
31
+ const deps: UserFailureNoticeDeps = {
32
+ now: () => over?.now ?? 1_000,
33
+ allowFrom: () => over?.allowFrom ?? ['op', 'user-a', 'user-b'],
34
+ liveTurnKey: () => ('liveTurnKey' in (over ?? {}) ? over!.liveTurnKey : 'chat:topic'),
35
+ record: (e) => cap.recorded.push(e),
36
+ scheduleUserNotice: (i) => cap.scheduled.push(i),
37
+ resolveNotices: (delivered, key) => {
38
+ cap.lastResolve = { delivered, key }
39
+ // Emulate the real gate: a delivered reply drops everything.
40
+ return delivered ? [] : cap.resolvedQueue
41
+ },
42
+ send: (chatId, text, keyboard) => cap.sent.push({ chatId, text, hasKeyboard: keyboard != null }),
43
+ log: (m) => cap.logs.push(m),
44
+ }
45
+ return { deps, cap }
46
+ }
47
+
48
+ function ev(overrides?: Partial<OperatorEvent>): OperatorEvent {
49
+ return {
50
+ kind: 'transport-transient',
51
+ agent: 'gymbro',
52
+ detail: 'API Error: Connection closed mid-response',
53
+ suggestedActions: [],
54
+ firstSeenAt: new Date('2026-08-02T00:00:00Z'),
55
+ ...overrides,
56
+ }
57
+ }
58
+
59
+ beforeEach(() => resetTransportTransientEscalation())
60
+
61
+ // ─── emitTransportTransientEvent — outcomes ──────────────────────────────────
62
+
63
+ describe('emitTransportTransientEvent', () => {
64
+ it('records history and schedules a deferred user notice, but sends NO card (single event)', () => {
65
+ const { deps, cap } = makeDeps()
66
+ emitTransportTransientEvent(ev(), deps)
67
+ // history recorded for /status
68
+ expect(cap.recorded).toHaveLength(1)
69
+ expect(cap.recorded[0].kind).toBe('transport-transient')
70
+ // user notice scheduled to ALL allowlist chats, keyed to the live turn
71
+ expect(cap.scheduled).toHaveLength(1)
72
+ expect(cap.scheduled[0].chatIds).toEqual(['op', 'user-a', 'user-b'])
73
+ expect(cap.scheduled[0].key).toBe('chat:topic')
74
+ // NO broadcast / escalation card for a single event
75
+ expect(cap.sent).toHaveLength(0)
76
+ })
77
+
78
+ it('records even when there is no allowlist, but schedules nothing', () => {
79
+ const { deps, cap } = makeDeps({ allowFrom: [] })
80
+ emitTransportTransientEvent(ev(), deps)
81
+ expect(cap.recorded).toHaveLength(1)
82
+ expect(cap.scheduled).toHaveLength(0)
83
+ expect(cap.sent).toHaveLength(0)
84
+ })
85
+
86
+ it('carries an undefined notice key when there is no live turn', () => {
87
+ const { deps, cap } = makeDeps({ liveTurnKey: undefined })
88
+ emitTransportTransientEvent(ev(), deps)
89
+ expect(cap.scheduled[0].key).toBeUndefined()
90
+ })
91
+ })
92
+
93
+ // ─── Escalation: >=3 within the window → exactly ONE operator-only card ───────
94
+
95
+ describe('transport-transient escalation bound', () => {
96
+ it('sends exactly ONE operator-only Dismiss-only card at the 3rd event in the window', () => {
97
+ const { deps, cap } = makeDeps()
98
+ emitTransportTransientEvent(ev(), deps) // 1 — no card
99
+ emitTransportTransientEvent(ev(), deps) // 2 — no card
100
+ expect(cap.sent).toHaveLength(0)
101
+ emitTransportTransientEvent(ev(), deps) // 3 — ONE card
102
+ expect(cap.sent).toHaveLength(1)
103
+ // operator-only: goes to the allowlist HEAD, and carries a keyboard (Dismiss)
104
+ expect(cap.sent[0].chatId).toBe('op')
105
+ expect(cap.sent[0].hasKeyboard).toBe(true)
106
+ expect(cap.sent[0].text).toContain('Repeated stream failures')
107
+ // and only ONE — a 4th event in the same (reset) window does not re-fire yet
108
+ emitTransportTransientEvent(ev(), deps) // 4
109
+ expect(cap.sent).toHaveLength(1)
110
+ })
111
+
112
+ it('does not escalate when the events fall outside the window', () => {
113
+ // Two events far apart never reach threshold-3-within-window.
114
+ expect(noteTransportTransientAndShouldEscalate('gymbro', 0)).toBe(false)
115
+ expect(noteTransportTransientAndShouldEscalate('gymbro', 60 * 60_000)).toBe(false)
116
+ expect(noteTransportTransientAndShouldEscalate('gymbro', 120 * 60_000)).toBe(false)
117
+ })
118
+
119
+ it('counts per-agent — one agent bursting does not escalate another', () => {
120
+ expect(noteTransportTransientAndShouldEscalate('a', 0)).toBe(false)
121
+ expect(noteTransportTransientAndShouldEscalate('a', 1)).toBe(false)
122
+ expect(noteTransportTransientAndShouldEscalate('b', 2)).toBe(false)
123
+ // a's 3rd crosses; b has only seen 1
124
+ expect(noteTransportTransientAndShouldEscalate('a', 3)).toBe(true)
125
+ expect(noteTransportTransientAndShouldEscalate('b', 4)).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('renderTransportEscalationCard', () => {
130
+ it('is Dismiss-only with NO Reauth button', () => {
131
+ const { keyboard, text } = renderTransportEscalationCard('gymbro')
132
+ const buttons = keyboard.inline_keyboard.flat()
133
+ expect(buttons.some((b) => b.callback_data?.includes('dismiss'))).toBe(true)
134
+ expect(buttons.some((b) => b.callback_data?.includes('reauth'))).toBe(false)
135
+ expect(text).toContain('gymbro')
136
+ })
137
+ })
138
+
139
+ // ─── flushDeferredUserNotices — turn-outcome gate ────────────────────────────
140
+
141
+ describe('flushDeferredUserNotices', () => {
142
+ it('sends nothing when the turn delivered a reply (notice dropped)', () => {
143
+ const { deps, cap } = makeDeps()
144
+ cap.resolvedQueue = [{ chatIds: ['user-a'], text: 'notice', agent: 'gymbro', kind: 'transport-transient', atMs: 1, key: 'k' }]
145
+ flushDeferredUserNotices(/* turnDeliveredReply */ true, 'k', deps)
146
+ expect(cap.lastResolve).toEqual({ delivered: true, key: 'k' })
147
+ expect(cap.sent).toHaveLength(0)
148
+ })
149
+
150
+ it('flushes the plain notice (no keyboard) when the turn ended reply-less', () => {
151
+ const { deps, cap } = makeDeps()
152
+ cap.resolvedQueue = [{ chatIds: ['user-a', 'user-b'], text: 'notice', agent: 'gymbro', kind: 'transport-transient', atMs: 1, key: 'k' }]
153
+ flushDeferredUserNotices(/* turnDeliveredReply */ false, 'k', deps)
154
+ expect(cap.sent.map((s) => s.chatId)).toEqual(['user-a', 'user-b'])
155
+ // plain user notice — never a card/keyboard
156
+ expect(cap.sent.every((s) => s.hasKeyboard === false)).toBe(true)
157
+ expect(cap.sent.every((s) => s.text === 'notice')).toBe(true)
158
+ })
159
+
160
+ it('does nothing when no notices resolve', () => {
161
+ const { deps, cap } = makeDeps()
162
+ flushDeferredUserNotices(false, 'k', deps)
163
+ expect(cap.sent).toHaveLength(0)
164
+ })
165
+ })
@@ -431,6 +431,11 @@ export function normalizeForSpeech(input: string): string {
431
431
  s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
432
432
 
433
433
  // 3. Links [text](url) → text ; drop the URL entirely.
434
+ // FUTURE (not built): the Kokoro sidecar's misaki G2P (docker/voice-sidecar/
435
+ // server.py) accepts a per-word phoneme override via `[word](/phoneme/)`
436
+ // markup. Wiring a caller-supplied pronunciation override end-to-end would
437
+ // mean detecting that `/…/` form HERE and passing it through instead of
438
+ // collapsing it to the link text below. Deliberately left as a hook.
434
439
  s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
435
440
 
436
441
  // 4. Autolinks <https://…> and bare URLs → "a link" (never spell a URL).
@@ -136,6 +136,10 @@ _VERIFY_BLOCK_REASON = (
136
136
  "UNLESS an equivalent active directive already exists (see the "
137
137
  "<active_directives> block for this turn) — in that case it is already "
138
138
  "saved; do NOT create a duplicate, just finish.\n"
139
+ "If the rule can be enforced deterministically — a settings.json hook, "
140
+ "a permission rule, a skill/script edit, or a config change — prefer "
141
+ "that (instead of, or in addition to, the directive) and say which you "
142
+ "did; reserve a directive for judgment rules code cannot enforce.\n"
139
143
  "If, on reflection, it was only a one-off instruction for this task, do "
140
144
  "NOT create a directive — just finish your reply normally.\n"
141
145
  "This verification fires once per turn.\n"
@@ -1442,8 +1442,13 @@ _DIRECTIVE_CAPTURE_NUDGE = (
1442
1442
  "(verbatim, in the user’s own words) BEFORE you answer, so the "
1443
1443
  "correction survives future sessions. UNLESS an equivalent active "
1444
1444
  "directive already exists (see any <active_directives> block above) — "
1445
- "in that case it is already saved; do NOT create a duplicate. If it’s "
1446
- "only a one-off instruction, ignore this note and just answer.\n"
1445
+ "in that case it is already saved; do NOT create a duplicate. If the "
1446
+ "rule can be enforced deterministically a settings.json hook, a "
1447
+ "permission rule, a skill/script edit, or a config change — prefer "
1448
+ "that (instead of, or in addition to, the directive) and say which "
1449
+ "you did; reserve a directive for judgment rules code can’t enforce. "
1450
+ "If it’s only a one-off instruction, ignore this note and just "
1451
+ "answer.\n"
1447
1452
  "</directive_capture_check>"
1448
1453
  )
1449
1454