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.
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9207 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +63 -4
- package/dist/host-control/main.js +20 -3
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +78 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +1149 -247
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +73 -81
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- 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 —
|
|
146
|
-
it('unknown object with no status defaults to unknown-4xx', () => {
|
|
147
|
-
|
|
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-
|
|
151
|
-
expect(classifyClaudeError('')).toBe('unknown-
|
|
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
|
|
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
|
-
|
|
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
|
|
1446
|
-
"
|
|
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
|
|