switchroom 0.18.8 → 0.18.10
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/README.md +2 -2
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the #klanker 2026-07-10 gateway crash.
|
|
3
|
+
*
|
|
4
|
+
* A `sendPhoto` that failed with PHOTO_INVALID_DIMENSIONS took the WHOLE
|
|
5
|
+
* gateway down with an `unhandledRejection`, even though the reply-tool
|
|
6
|
+
* call site `await`s the send and `onToolCall` wraps it in try/catch.
|
|
7
|
+
*
|
|
8
|
+
* Root cause was in `chat-lock.ts`'s `run()`: it stores a SECOND promise
|
|
9
|
+
* (`tracked = next.finally(...)`) in the per-key chain map and returns the
|
|
10
|
+
* separate `next` to the caller. The caller handles `next`'s rejection, but
|
|
11
|
+
* `tracked` — which rejects with the same reason — is only ever handled by
|
|
12
|
+
* the NEXT queued call chaining onto it. On the TAIL call of a lane (the
|
|
13
|
+
* common single-reply case) nothing attaches a handler to `tracked`, so its
|
|
14
|
+
* rejection escapes as an `unhandledRejection` and crashes the process.
|
|
15
|
+
*
|
|
16
|
+
* These tests assert:
|
|
17
|
+
* 1. a rejecting TAIL call does NOT emit an `unhandledRejection`, and
|
|
18
|
+
* 2. the caller STILL receives the real error (so error handling / the
|
|
19
|
+
* photo→document fallback still runs).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import { createChatLock } from '../chat-lock.js'
|
|
24
|
+
|
|
25
|
+
/** Run `body`, capturing any process-level unhandledRejection during it. */
|
|
26
|
+
async function captureUnhandledRejections(
|
|
27
|
+
body: () => Promise<void>,
|
|
28
|
+
): Promise<unknown[]> {
|
|
29
|
+
const seen: unknown[] = []
|
|
30
|
+
const onUnhandled = (reason: unknown) => { seen.push(reason) }
|
|
31
|
+
process.on('unhandledRejection', onUnhandled)
|
|
32
|
+
try {
|
|
33
|
+
await body()
|
|
34
|
+
// Let any orphaned rejections surface: unhandledRejection fires on a
|
|
35
|
+
// later microtask/macrotask tick, so flush both.
|
|
36
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
37
|
+
await Promise.resolve()
|
|
38
|
+
} finally {
|
|
39
|
+
process.off('unhandledRejection', onUnhandled)
|
|
40
|
+
}
|
|
41
|
+
return seen
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('chat-lock run() — tail-call rejection safety', () => {
|
|
45
|
+
it('does NOT emit an unhandledRejection when the tail call rejects', async () => {
|
|
46
|
+
const boom = new Error('PHOTO_INVALID_DIMENSIONS')
|
|
47
|
+
const unhandled = await captureUnhandledRejections(async () => {
|
|
48
|
+
const lock = createChatLock()
|
|
49
|
+
// Single call on this key → it IS the tail. Caller handles the error.
|
|
50
|
+
await expect(lock.run('chat:1', () => Promise.reject(boom))).rejects.toBe(boom)
|
|
51
|
+
})
|
|
52
|
+
expect(unhandled).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('still surfaces the real error to the caller', async () => {
|
|
56
|
+
const lock = createChatLock()
|
|
57
|
+
const boom = new Error('boom')
|
|
58
|
+
await expect(lock.run('k', () => Promise.reject(boom))).rejects.toBe(boom)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('is quiet even when the failing call is the last of a burst', async () => {
|
|
62
|
+
const boom = new Error('PHOTO_INVALID_DIMENSIONS')
|
|
63
|
+
const unhandled = await captureUnhandledRejections(async () => {
|
|
64
|
+
const lock = createChatLock()
|
|
65
|
+
// First call succeeds, second (tail) rejects — mirrors the incident:
|
|
66
|
+
// a document then a photo on the same (chat,thread) lane.
|
|
67
|
+
const okP = lock.run('chat:9', () => Promise.resolve('doc-sent'))
|
|
68
|
+
const failP = lock.run('chat:9', () => Promise.reject(boom))
|
|
69
|
+
await expect(okP).resolves.toBe('doc-sent')
|
|
70
|
+
await expect(failP).rejects.toBe(boom)
|
|
71
|
+
})
|
|
72
|
+
expect(unhandled).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('one failure does not poison later work on the same key', async () => {
|
|
76
|
+
const lock = createChatLock()
|
|
77
|
+
await expect(lock.run('k2', () => Promise.reject(new Error('x')))).rejects.toThrow('x')
|
|
78
|
+
// A subsequent call on the same key must still run and resolve.
|
|
79
|
+
await expect(lock.run('k2', () => Promise.resolve('ok'))).resolves.toBe('ok')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('wrapBot surfaces a bot.api rejection without crashing (tail call)', async () => {
|
|
83
|
+
const boom = new Error('PHOTO_INVALID_DIMENSIONS')
|
|
84
|
+
const unhandled = await captureUnhandledRejections(async () => {
|
|
85
|
+
const lock = createChatLock()
|
|
86
|
+
const bot = {
|
|
87
|
+
api: {
|
|
88
|
+
sendPhoto: (_chatId: string, _file: unknown, _opts?: unknown) =>
|
|
89
|
+
Promise.reject(boom),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
const wrapped = lock.wrapBot(bot)
|
|
93
|
+
await expect(
|
|
94
|
+
(wrapped.api.sendPhoto as (c: string, f: unknown, o?: unknown) => Promise<unknown>)(
|
|
95
|
+
'123', {}, {},
|
|
96
|
+
),
|
|
97
|
+
).rejects.toBe(boom)
|
|
98
|
+
})
|
|
99
|
+
expect(unhandled).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the #3033 bridge crash-breadcrumb contract: uncaughtException /
|
|
3
|
+
* unhandledRejection in the bridge process persist a bounded, greppable
|
|
4
|
+
* line to bridge-crash.log (Claude Code drops MCP stderr after startup,
|
|
5
|
+
* so this file is the only durable trace of why a bridge died).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { appendCrashBreadcrumb } from '../bridge/crash-breadcrumb.js'
|
|
13
|
+
|
|
14
|
+
function tmpLog(): string {
|
|
15
|
+
return join(mkdtempSync(join(tmpdir(), 'crash-bc-')), 'bridge-crash.log')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('appendCrashBreadcrumb', () => {
|
|
19
|
+
it('appends a single line with kind, pid and folded stack', () => {
|
|
20
|
+
const log = tmpLog()
|
|
21
|
+
const err = new Error('boom')
|
|
22
|
+
appendCrashBreadcrumb(log, 'uncaughtException', err, new Date('2026-07-11T01:40:59Z'))
|
|
23
|
+
const content = readFileSync(log, 'utf8')
|
|
24
|
+
const lines = content.trimEnd().split('\n')
|
|
25
|
+
expect(lines).toHaveLength(1)
|
|
26
|
+
expect(lines[0]).toContain('2026-07-11T01:40:59')
|
|
27
|
+
expect(lines[0]).toContain('uncaughtException')
|
|
28
|
+
expect(lines[0]).toContain(`pid=${process.pid}`)
|
|
29
|
+
expect(lines[0]).toContain('Error: boom')
|
|
30
|
+
expect(lines[0]).not.toContain('\n')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles non-Error rejections and appends across calls', () => {
|
|
34
|
+
const log = tmpLog()
|
|
35
|
+
appendCrashBreadcrumb(log, 'unhandledRejection', 'string reason')
|
|
36
|
+
appendCrashBreadcrumb(log, 'unhandledRejection', { odd: true })
|
|
37
|
+
const lines = readFileSync(log, 'utf8').trimEnd().split('\n')
|
|
38
|
+
expect(lines).toHaveLength(2)
|
|
39
|
+
expect(lines[0]).toContain('string reason')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('rotates once past the size cap instead of growing unbounded', () => {
|
|
43
|
+
const log = tmpLog()
|
|
44
|
+
writeFileSync(log, 'x'.repeat(1024 * 1024 + 1))
|
|
45
|
+
appendCrashBreadcrumb(log, 'uncaughtException', new Error('after rotation'))
|
|
46
|
+
expect(existsSync(`${log}.1`)).toBe(true)
|
|
47
|
+
const content = readFileSync(log, 'utf8')
|
|
48
|
+
expect(content).toContain('after rotation')
|
|
49
|
+
expect(content.length).toBeLessThan(5000)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('never throws when the path is unwritable', () => {
|
|
53
|
+
expect(() =>
|
|
54
|
+
appendCrashBreadcrumb('/nonexistent-dir-xyz/bridge-crash.log', 'uncaughtException', new Error('x')),
|
|
55
|
+
).not.toThrow()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -107,7 +107,7 @@ describe("effort-command: handler", () => {
|
|
|
107
107
|
const { deps } = makeDeps({ getConfiguredEffort: () => "medium" });
|
|
108
108
|
const r = await handleEffortCommand({ kind: "show" }, deps);
|
|
109
109
|
expect(r.text).toContain("medium");
|
|
110
|
-
expect(r.text).toMatch(/
|
|
110
|
+
expect(r.text).toMatch(/persists across restarts and deploys/);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it("show falls back to low when effort is unreadable", async () => {
|
|
@@ -121,7 +121,7 @@ describe("effort-command: handler", () => {
|
|
|
121
121
|
const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
|
|
122
122
|
expect(calls).toEqual([{ agent: "carrie", level: "high" }]);
|
|
123
123
|
expect(r.text).toContain("Set effort level to high");
|
|
124
|
-
expect(r.text).toMatch(/
|
|
124
|
+
expect(r.text).toMatch(/persists across restarts and deploys/);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
it("set notes the re-read cost when a confirmation was needed", async () => {
|
|
@@ -198,3 +198,60 @@ describe("effort-command: menu + callback", () => {
|
|
|
198
198
|
expect(out.selectedEffort).toBeUndefined();
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
|
|
202
|
+
// ─── #3039: /effort default + durable-override surfaces ─────────────────────
|
|
203
|
+
|
|
204
|
+
describe("effort-command: /effort default (#3039)", () => {
|
|
205
|
+
it("parses `/effort default` as an explicit clear", () => {
|
|
206
|
+
expect(parseEffortCommand("/effort default")).toEqual({ kind: "default" });
|
|
207
|
+
expect(parseEffortCommand("/effort DEFAULT")).toEqual({ kind: "default" });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("default restores the configured level, THEN clears the durable override (order pins the wrapper undo)", async () => {
|
|
211
|
+
const events: string[] = [];
|
|
212
|
+
const { deps } = makeDeps({
|
|
213
|
+
getConfiguredEffort: () => "medium",
|
|
214
|
+
applyEffort: async (_a, level) => {
|
|
215
|
+
events.push(`apply:${level}`);
|
|
216
|
+
return applyOk(level);
|
|
217
|
+
},
|
|
218
|
+
clearSessionEffort: () => events.push("clear"),
|
|
219
|
+
});
|
|
220
|
+
const r = await handleEffortCommand({ kind: "default" }, deps);
|
|
221
|
+
expect(events).toEqual(["apply:medium", "clear"]);
|
|
222
|
+
expect(r.text).toContain("override cleared");
|
|
223
|
+
expect(r.text).toContain("`medium`");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("default still clears the override when the live apply fails (boots on default from now on)", async () => {
|
|
227
|
+
let cleared = false;
|
|
228
|
+
const { deps } = makeDeps({
|
|
229
|
+
applyEffort: async () => applyFail("apply_unverified"),
|
|
230
|
+
clearSessionEffort: () => { cleared = true; },
|
|
231
|
+
});
|
|
232
|
+
const r = await handleEffortCommand({ kind: "default" }, deps);
|
|
233
|
+
expect(cleared).toBe(true);
|
|
234
|
+
expect(r.text).toContain("override cleared");
|
|
235
|
+
// Honest: does not claim the live session switched.
|
|
236
|
+
expect(r.text).toContain("Couldn't switch the live session right now");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("show surfaces an active session override next to the configured default", async () => {
|
|
240
|
+
const { deps } = makeDeps({ getConfiguredEffort: () => "low", getSessionEffort: () => "xhigh" });
|
|
241
|
+
const r = await handleEffortCommand({ kind: "show" }, deps);
|
|
242
|
+
expect(r.text).toContain("session override: `xhigh`");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("menu marks the durable override as the live level", () => {
|
|
246
|
+
const { deps } = makeDeps({ getConfiguredEffort: () => "low", getSessionEffort: () => "max" });
|
|
247
|
+
const menu = buildEffortMenu(deps);
|
|
248
|
+
const marked = menu.keyboard!.flat().find((b) => b.text.startsWith("✅"));
|
|
249
|
+
expect(marked?.text).toBe("✅ max");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("help text advertises /effort default and the sticky contract", async () => {
|
|
253
|
+
const r = await handleEffortCommand({ kind: "help" }, makeDeps().deps);
|
|
254
|
+
expect(r.text).toContain("/effort default");
|
|
255
|
+
expect(r.text).toContain("persists across restarts and deploys");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -131,10 +131,17 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
|
|
|
131
131
|
return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
// #2996 step 1: the chunk send loop was relocated verbatim from executeReply
|
|
135
|
+
// into outbound-send-path.ts's `sendReplyChunks`, invoked here as
|
|
136
|
+
// `await sendReplyChunks(chunkSendDeps, …)`. This guard's INTENT — the
|
|
137
|
+
// lever-2 card finalize runs BEFORE the reply send — is unchanged; the send
|
|
138
|
+
// marker is now the delegation call rather than the inline `for` loop.
|
|
139
|
+
const SEND_MARKER = 'sendReplyChunks('
|
|
140
|
+
|
|
141
|
+
it('executeReply finalizes (clearActivitySummary) before the reply send, gated on substantive', () => {
|
|
135
142
|
const src = executeReplySrc()
|
|
136
143
|
const clearIdx = src.indexOf('clearActivitySummary(')
|
|
137
|
-
const loopIdx = src.indexOf(
|
|
144
|
+
const loopIdx = src.indexOf(SEND_MARKER)
|
|
138
145
|
expect(clearIdx).toBeGreaterThan(-1)
|
|
139
146
|
expect(loopIdx).toBeGreaterThan(-1)
|
|
140
147
|
expect(clearIdx).toBeLessThan(loopIdx)
|
|
@@ -148,8 +155,8 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
|
|
|
148
155
|
// An ack (non-substantive) falls through and never finalizes early, so the
|
|
149
156
|
// reopen path keeps owning the card (the #2141 ack-then-work feed).
|
|
150
157
|
const replySrc = executeReplySrc()
|
|
151
|
-
// The pre-
|
|
152
|
-
const preLoop = replySrc.split(
|
|
158
|
+
// The pre-send clearActivitySummary must be the substantive-gated one.
|
|
159
|
+
const preLoop = replySrc.split(SEND_MARKER)[0] ?? ''
|
|
153
160
|
const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
|
|
154
161
|
expect(clears).toHaveLength(1)
|
|
155
162
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3031 PR 3 — reactive-path message collapse.
|
|
3
|
+
*
|
|
4
|
+
* The model-unavailable card is deliberately sent BEFORE the fallback
|
|
5
|
+
* outcome is known; on a SUCCESSFUL swap the announcement is folded into an
|
|
6
|
+
* EDIT of that card (single evolving card). CRITICAL promise-honesty
|
|
7
|
+
* constraint (2026-06-06→07 incident): every failure / no-op path must still
|
|
8
|
+
* deliver a SEPARATE message — these tests pin that the collapse decision
|
|
9
|
+
* can never eat a failure notice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
createModelUnavailableCardRegistry,
|
|
15
|
+
decideAnnouncementDelivery,
|
|
16
|
+
foldAnnouncementIntoCard,
|
|
17
|
+
CARD_COLLAPSE_MAX_AGE_MS,
|
|
18
|
+
type ModelUnavailableCardRecord,
|
|
19
|
+
type FallbackDeliveryOutcomeKind,
|
|
20
|
+
} from "../fallback-card-collapse.js";
|
|
21
|
+
|
|
22
|
+
const NOW = 1_780_000_000_000;
|
|
23
|
+
|
|
24
|
+
function rec(overrides: Partial<ModelUnavailableCardRecord> = {}): ModelUnavailableCardRecord {
|
|
25
|
+
return {
|
|
26
|
+
messageId: 4242,
|
|
27
|
+
text: "⚠️ Model unavailable — auto-failover in progress",
|
|
28
|
+
atMs: NOW - 5_000,
|
|
29
|
+
promisedFallback: true,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("createModelUnavailableCardRegistry", () => {
|
|
35
|
+
it("take returns a fresh fallback-promising card exactly once (one edit per card)", () => {
|
|
36
|
+
const reg = createModelUnavailableCardRegistry();
|
|
37
|
+
reg.record("123", rec());
|
|
38
|
+
const first = reg.take("123", NOW);
|
|
39
|
+
expect(first?.messageId).toBe(4242);
|
|
40
|
+
// One-shot: a second take finds nothing (a card absorbs one announcement).
|
|
41
|
+
expect(reg.take("123", NOW)).toBeNull();
|
|
42
|
+
expect(reg.size()).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("a stale card is not editable (belongs to a previous incident) and is cleared", () => {
|
|
46
|
+
const reg = createModelUnavailableCardRegistry();
|
|
47
|
+
reg.record("123", rec({ atMs: NOW - CARD_COLLAPSE_MAX_AGE_MS - 1 }));
|
|
48
|
+
expect(reg.take("123", NOW)).toBeNull();
|
|
49
|
+
expect(reg.size()).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("a card that never promised auto-failover never absorbs the announcement", () => {
|
|
53
|
+
const reg = createModelUnavailableCardRegistry();
|
|
54
|
+
reg.record("123", rec({ promisedFallback: false }));
|
|
55
|
+
expect(reg.take("123", NOW)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("cards are per-chat: taking one chat's card leaves the other's intact", () => {
|
|
59
|
+
const reg = createModelUnavailableCardRegistry();
|
|
60
|
+
reg.record("123", rec({ messageId: 1 }));
|
|
61
|
+
reg.record("456", rec({ messageId: 2 }));
|
|
62
|
+
expect(reg.take("123", NOW)?.messageId).toBe(1);
|
|
63
|
+
expect(reg.take("456", NOW)?.messageId).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("a newer card replaces the prior record for the same chat", () => {
|
|
67
|
+
const reg = createModelUnavailableCardRegistry();
|
|
68
|
+
reg.record("123", rec({ messageId: 1 }));
|
|
69
|
+
reg.record("123", rec({ messageId: 2 }));
|
|
70
|
+
expect(reg.take("123", NOW)?.messageId).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("decideAnnouncementDelivery — promise-honesty on every error path", () => {
|
|
75
|
+
it("edits ONLY on a successful swap with a fresh promising card", () => {
|
|
76
|
+
expect(decideAnnouncementDelivery("switched", rec())).toBe("edit");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("sends separately on a successful swap when no card was recorded (e.g. card-less quota_wall_detected trigger)", () => {
|
|
80
|
+
expect(decideAnnouncementDelivery("switched", null)).toBe("send");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("EVERY failure / no-op outcome is a separate send — even with a fresh card on record", () => {
|
|
84
|
+
const failureKinds: FallbackDeliveryOutcomeKind[] = [
|
|
85
|
+
"all-blocked",
|
|
86
|
+
"no-old-active",
|
|
87
|
+
"no-eligible-target",
|
|
88
|
+
"error",
|
|
89
|
+
];
|
|
90
|
+
for (const kind of failureKinds) {
|
|
91
|
+
expect(decideAnnouncementDelivery(kind, rec()), `kind=${kind}`).toBe("send");
|
|
92
|
+
expect(decideAnnouncementDelivery(kind, null), `kind=${kind} (no card)`).toBe("send");
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("foldAnnouncementIntoCard", () => {
|
|
98
|
+
it("keeps the original card text and appends the announcement (single evolving card)", () => {
|
|
99
|
+
const folded = foldAnnouncementIntoCard("CARD", "ANNOUNCEMENT");
|
|
100
|
+
expect(folded.startsWith("CARD")).toBe(true);
|
|
101
|
+
expect(folded).toContain("ANNOUNCEMENT");
|
|
102
|
+
expect(folded.indexOf("CARD")).toBeLessThan(folded.indexOf("ANNOUNCEMENT"));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess probe for the SWITCHROOM_DELIVERY_MACHINE_CUTOVER kill switch.
|
|
3
|
+
*
|
|
4
|
+
* The cutover env var is read at MODULE LOAD in both
|
|
5
|
+
* `inbound-delivery-machine-dispatch.ts` and
|
|
6
|
+
* `inbound-delivery-machine-shadow.ts`, so an in-process test can't flip it
|
|
7
|
+
* after import (and bun's `vi` shim has no resetModules/stubEnv). The
|
|
8
|
+
* cutover-flip test spawns this script under `bun` with the env it wants
|
|
9
|
+
* and asserts the JSON verdict printed on stdout.
|
|
10
|
+
*
|
|
11
|
+
* Output: one JSON line —
|
|
12
|
+
* {
|
|
13
|
+
* dispatchEnabled, cutoverEnabled, shadowEnabled,
|
|
14
|
+
* sendCalls, buffered, setTurnStartedCalls, deliverResults
|
|
15
|
+
* }
|
|
16
|
+
* after driving a fresh-turn inbound effect set through dispatchEffects
|
|
17
|
+
* against a recording fake ctx.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { dispatchEffects, isDispatchEnabled } from '../../gateway/inbound-delivery-machine-dispatch'
|
|
21
|
+
import {
|
|
22
|
+
isDeliveryCutoverEnabled,
|
|
23
|
+
__shadowEnabledForTests,
|
|
24
|
+
} from '../../gateway/inbound-delivery-machine-shadow'
|
|
25
|
+
import { createPendingInboundBuffer } from '../../gateway/pending-inbound-buffer'
|
|
26
|
+
import { createPendingPermissionBuffer } from '../../gateway/pending-permission-decisions'
|
|
27
|
+
import { initialState, transition, type ChatKey } from '../../gateway/inbound-delivery-machine'
|
|
28
|
+
|
|
29
|
+
const KEY = '111:_' as ChatKey
|
|
30
|
+
const msg = { type: 'inbound', chatId: '111', messageId: 42, text: 'hello' }
|
|
31
|
+
|
|
32
|
+
const alive = transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
|
|
33
|
+
const { effects } = transition(alive, {
|
|
34
|
+
kind: 'inbound',
|
|
35
|
+
key: KEY,
|
|
36
|
+
msg: { msgId: 42, isSteering: false, payload: msg },
|
|
37
|
+
at: 2000,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
let sendCalls = 0
|
|
41
|
+
const buffer = createPendingInboundBuffer()
|
|
42
|
+
let setTurnStartedCalls = 0
|
|
43
|
+
const deliverResults: boolean[] = []
|
|
44
|
+
|
|
45
|
+
dispatchEffects(effects, {
|
|
46
|
+
selfAgent: 'probe-agent',
|
|
47
|
+
ipcServer: {
|
|
48
|
+
sendToAgent: () => {
|
|
49
|
+
sendCalls++
|
|
50
|
+
return true
|
|
51
|
+
},
|
|
52
|
+
} as never,
|
|
53
|
+
pendingInboundBuffer: buffer,
|
|
54
|
+
inboundSpool: null,
|
|
55
|
+
pendingPermissionBuffer: createPendingPermissionBuffer(),
|
|
56
|
+
log: () => {},
|
|
57
|
+
onSetTurnStarted: () => {
|
|
58
|
+
setTurnStartedCalls++
|
|
59
|
+
},
|
|
60
|
+
onDeliverResult: (_k, ok) => {
|
|
61
|
+
deliverResults.push(ok)
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
process.stdout.write(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
dispatchEnabled: isDispatchEnabled(),
|
|
68
|
+
cutoverEnabled: isDeliveryCutoverEnabled(),
|
|
69
|
+
shadowEnabled: __shadowEnabledForTests(),
|
|
70
|
+
sendCalls,
|
|
71
|
+
buffered: buffer.drain('probe-agent').length,
|
|
72
|
+
setTurnStartedCalls,
|
|
73
|
+
deliverResults,
|
|
74
|
+
}) + '\n',
|
|
75
|
+
)
|
|
@@ -35,8 +35,12 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
it('reply: scrubs at entry, before the stderr preview log', () => {
|
|
38
|
+
// #2996: the entry pipeline (normalize → redact → punctuation/bold →
|
|
39
|
+
// voice-scrub) is extracted into outbound-send-path.ts. The reply path now
|
|
40
|
+
// delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
|
|
41
|
+
// — the injected redactor still runs at entry, before the stderr preview.
|
|
38
42
|
const start = src.indexOf('async function executeReply(')
|
|
39
|
-
const redactIdx = src.indexOf(`
|
|
43
|
+
const redactIdx = src.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
|
|
40
44
|
const previewIdx = src.indexOf('reply: invoked chatId=', start)
|
|
41
45
|
expect(start).toBeGreaterThan(0)
|
|
42
46
|
expect(redactIdx).toBeGreaterThan(start)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural pins for the #3018 fixes to the mid-turn ack-queue-apply-confirm
|
|
3
|
+
* wiring in gateway.ts (/model + /effort, #3017).
|
|
4
|
+
*
|
|
5
|
+
* The behaviour lives in un-exported inline closures (the pendingStateReaper
|
|
6
|
+
* interval, enqueueSessionCommand, drainPendingSessionCommand, and the
|
|
7
|
+
* shutdown() handler), so — mirroring gateway-session-model-relaunch.test.ts —
|
|
8
|
+
* we assert on the source structure. The pure contract (per-kind slots,
|
|
9
|
+
* drainCapDecision, shutdownResolutionActions) is unit-tested in
|
|
10
|
+
* pending-session-command.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { dirname, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
20
|
+
|
|
21
|
+
describe('gateway: reaper drain-cap defers while a turn is in flight (#3018 finding 1)', () => {
|
|
22
|
+
it('feeds turnInFlightForGate() into drainCapDecision and only forces on the force branch', () => {
|
|
23
|
+
const idx = GATEWAY_SRC.indexOf('pendingCmdDrainCapDecision(')
|
|
24
|
+
expect(idx).toBeGreaterThan(0)
|
|
25
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1200)
|
|
26
|
+
// The decision reads the live turn gate…
|
|
27
|
+
expect(win).toContain('turnInFlightForGate()')
|
|
28
|
+
// …has an explicit defer branch that does NOT drain…
|
|
29
|
+
const deferIdx = win.indexOf("'defer-turn-in-flight'")
|
|
30
|
+
expect(deferIdx).toBeGreaterThan(0)
|
|
31
|
+
const forceIdx = win.indexOf("cmdDecision === 'force'")
|
|
32
|
+
expect(forceIdx).toBeGreaterThan(deferIdx)
|
|
33
|
+
// …and the ONLY drain call in the reaper block sits after the force check.
|
|
34
|
+
const drainIdx = win.indexOf('void drainPendingSessionCommand()')
|
|
35
|
+
expect(drainIdx).toBeGreaterThan(forceIdx)
|
|
36
|
+
expect(win.slice(0, forceIdx)).not.toContain('drainPendingSessionCommand()')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('gateway: post-enqueue idle kick (#3018 finding 5)', () => {
|
|
41
|
+
it('enqueueSessionCommand drains immediately when the session went idle between the busy check and the enqueue', () => {
|
|
42
|
+
const fnIdx = GATEWAY_SRC.indexOf('function enqueueSessionCommand(')
|
|
43
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
44
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 1500)
|
|
45
|
+
expect(win).toContain('if (!turnInFlightForGate()) void drainPendingSessionCommand()')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('gateway: shutdown resolves queued ack cards (#3018 finding 3)', () => {
|
|
50
|
+
it('shutdown() empties the slots via shutdownResolutionActions, PERSISTS each typed choice, and edits each card, before the force-exit timer', () => {
|
|
51
|
+
const fnIdx = GATEWAY_SRC.indexOf('async function shutdown(signal: string)')
|
|
52
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
53
|
+
const win = GATEWAY_SRC.slice(fnIdx, GATEWAY_SRC.indexOf('forceExitTimer', fnIdx))
|
|
54
|
+
const resolveIdx = win.indexOf('pendingCmdShutdownResolutionActions(pendingSessionCommand')
|
|
55
|
+
expect(resolveIdx).toBeGreaterThan(0)
|
|
56
|
+
// #3039: the queued choice is carried across the bounce via the durable
|
|
57
|
+
// carriers, not dropped with a "re-issue" note.
|
|
58
|
+
expect(win.indexOf('persistQueuedCommandForRestart(', resolveIdx)).toBeGreaterThan(resolveIdx)
|
|
59
|
+
expect(win.indexOf('editPendingCommandCard(', resolveIdx)).toBeGreaterThan(resolveIdx)
|
|
60
|
+
// Bounded: raced against a timeout so a wedged Telegram API can't block shutdown.
|
|
61
|
+
expect(win.slice(resolveIdx)).toContain('Promise.race')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('gateway: drain never confirms a busy refusal and never drops the batch (#3039, #3042 blocker 1)', () => {
|
|
66
|
+
it('drainPendingSessionCommand routes takeAll() through the unit-tested drainTakenCommands with the loss-safe IO', () => {
|
|
67
|
+
const fnIdx = GATEWAY_SRC.indexOf('async function drainPendingSessionCommand(')
|
|
68
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
69
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
|
|
70
|
+
// Iteration + loss-safety invariants live in pending-session-command.ts
|
|
71
|
+
// (drainTakenCommands, functionally tested); the gateway only supplies IO.
|
|
72
|
+
expect(win).toContain('pendingCmdDrainTaken(pendingSessionCommand.takeAll()')
|
|
73
|
+
expect(win).toContain('turnInFlightForGate()')
|
|
74
|
+
expect(win).toContain('isBusyRefusal: isBusyRefusalText')
|
|
75
|
+
expect(win).toContain('reEnqueue: reEnqueueUnlessSuperseded')
|
|
76
|
+
// The pending-restart branch persists rather than telling the user to re-issue.
|
|
77
|
+
expect(win).toContain('pendingCmdResolveForRestart(cmd')
|
|
78
|
+
expect(win).toContain('persistQueuedCommandForRestart(')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('gateway: unconfirmed queued model tokens are gated before durable persist (#3042 blocker 2a)', () => {
|
|
83
|
+
it('persistQueuedCommandForRestart refuses offline-unverifiable tokens', () => {
|
|
84
|
+
const fnIdx = GATEWAY_SRC.indexOf('function persistQueuedCommandForRestart(')
|
|
85
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
86
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 2500)
|
|
87
|
+
expect(win).toContain('isOfflineTrustedModelToken(action.arg)')
|
|
88
|
+
expect(win).toContain('NOT saved')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('gateway: /restart keeps the session-model override (#3039)', () => {
|
|
93
|
+
it('the /restart chat command stamps keep, never revert', () => {
|
|
94
|
+
expect(GATEWAY_SRC).toContain("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
|
|
95
|
+
expect(GATEWAY_SRC).not.toContain("writeRelaunchModelIntent(smDir, 'revert'")
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('gateway: /effort persistence choke point (#3039)', () => {
|
|
100
|
+
it('buildEffortDeps persists a confirmed apply to .session-effort and wires clearSessionEffort', () => {
|
|
101
|
+
const fnIdx = GATEWAY_SRC.indexOf('function buildEffortDeps(')
|
|
102
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
103
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 2500)
|
|
104
|
+
expect(win).toContain('writeSessionEffortFile(')
|
|
105
|
+
expect(win).toContain('clearSessionEffortFile(')
|
|
106
|
+
expect(win).toContain('readSessionEffortFile(')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('gateway: keep-intent stamp narrowing (#3018 finding 4)', () => {
|
|
111
|
+
it('the shutdown keep-intent stamp carries the gateway-shutdown reason prefix', () => {
|
|
112
|
+
expect(GATEWAY_SRC).toContain(
|
|
113
|
+
"writeRelaunchModelIntent(smDir, 'keep', `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown",
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('boot clears a stale gateway-shutdown-stamped intent (gateway-only bounce never runs start.sh)', () => {
|
|
118
|
+
const idx = GATEWAY_SRC.indexOf('clearStaleGatewayShutdownIntent(bootSmDir)')
|
|
119
|
+
expect(idx).toBeGreaterThan(0)
|
|
120
|
+
// The cleanup runs at module top-level (gateway boot), BEFORE the shutdown
|
|
121
|
+
// handler could stamp a fresh one for THIS process's own exit.
|
|
122
|
+
expect(idx).toBeLessThan(GATEWAY_SRC.indexOf('async function shutdown(signal: string)'))
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -128,8 +128,14 @@ describe('gateway secret-detect intercept — structural wiring', () => {
|
|
|
128
128
|
})
|
|
129
129
|
|
|
130
130
|
it('staging follow-up commands (stash/ignore/rename/forget) are wired', () => {
|
|
131
|
+
// Guarded property: the staging follow-up parser lives INSIDE
|
|
132
|
+
// handleInbound (so it runs on the inbound-text path, gated by the same
|
|
133
|
+
// allowFrom check). The window is a generous bound on handleInbound's
|
|
134
|
+
// pre-staging body, not a precise offset — it has grown before (#3020's
|
|
135
|
+
// stop-keyword block pushed it past the old 30k) and may grow again;
|
|
136
|
+
// bump it when new early-return branches land above the staging block.
|
|
131
137
|
const handleInboundIdx = src.indexOf('async function handleInbound(')
|
|
132
|
-
const tail = src.slice(handleInboundIdx, handleInboundIdx +
|
|
138
|
+
const tail = src.slice(handleInboundIdx, handleInboundIdx + 50000)
|
|
133
139
|
expect(tail).toMatch(/\(stash\|ignore\|rename\|forget\)/)
|
|
134
140
|
expect(tail).toMatch(/secretStaging\.latestForChat\(chat_id\)/)
|
|
135
141
|
})
|