switchroom 0.19.48 → 0.20.1
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/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- 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 +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -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 +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- 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 +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -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 +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -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/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- 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',
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
computeReplyChunks,
|
|
19
19
|
resplitOversizeChunk,
|
|
20
20
|
chunkText,
|
|
21
|
+
resolveMirrorAntecedentKey,
|
|
21
22
|
} from '../gateway/outbound-send-path.js'
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -413,3 +414,26 @@ describe('outbound-send-path — temporal pass wiring (#3501)', () => {
|
|
|
413
414
|
expect(viaSeam).toBe(viaModule)
|
|
414
415
|
})
|
|
415
416
|
})
|
|
417
|
+
|
|
418
|
+
// ── Buzz-mirror antecedent gating (#4300 / #4301) ──────────────────────────
|
|
419
|
+
describe('resolveMirrorAntecedentKey — Buzz-mirror antecedent gating', () => {
|
|
420
|
+
it('#4300: replyMode "off" stamps NO antecedent → Buzz mirror stays flat', () => {
|
|
421
|
+
// With reply-mode off the Telegram copy renders no reply, so the Buzz copy
|
|
422
|
+
// must not thread either — otherwise the surfaces diverge.
|
|
423
|
+
expect(resolveMirrorAntecedentKey('555', 100, 'off')).toBeUndefined()
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('stamps the antecedent for reply-rendering modes (first / all)', () => {
|
|
427
|
+
expect(resolveMirrorAntecedentKey('555', 100, 'first')).toBe('555:100')
|
|
428
|
+
expect(resolveMirrorAntecedentKey('555', 100, 'all')).toBe('555:100')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('#4301: a non-numeric reply_to (NaN) stamps NO antecedent (no chat:NaN key)', () => {
|
|
432
|
+
expect(resolveMirrorAntecedentKey('555', Number('not-a-number'), 'first')).toBeUndefined()
|
|
433
|
+
expect(resolveMirrorAntecedentKey('555', NaN, 'all')).toBeUndefined()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('stamps NO antecedent when there is no reply_to', () => {
|
|
437
|
+
expect(resolveMirrorAntecedentKey('555', undefined, 'first')).toBeUndefined()
|
|
438
|
+
})
|
|
439
|
+
})
|