switchroom 0.18.9 → 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/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 +1831 -263
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +1435 -211
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -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/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- 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/fallback-card-collapse.test.ts +104 -0
- 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/model-command.test.ts +46 -3
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -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/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
|
+
});
|
|
@@ -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,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
|
})
|
|
@@ -114,14 +114,14 @@ describe('gateway: intent writers on the restart verbs', () => {
|
|
|
114
114
|
expect(clearIdx).toBeGreaterThan(markerIdx)
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
it('/restart stamps
|
|
117
|
+
it('/restart stamps a KEEP intent before dispatch (#3039: a restart is not "clear my model")', () => {
|
|
118
118
|
const idx = GATEWAY_SRC.indexOf("stampUserRestartReason('user: /restart from chat')")
|
|
119
119
|
expect(idx).toBeGreaterThan(0)
|
|
120
120
|
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
121
|
-
const
|
|
121
|
+
const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
|
|
122
122
|
const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
|
|
123
|
-
expect(
|
|
124
|
-
expect(dispatchIdx).toBeGreaterThan(
|
|
123
|
+
expect(keepIdx).toBeGreaterThan(0)
|
|
124
|
+
expect(dispatchIdx).toBeGreaterThan(keepIdx)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
127
|
it('/new and /reset stamp keep-intent (fresh conversation, same model — contract row 7)', () => {
|
|
@@ -137,22 +137,28 @@ describe('gateway: intent writers on the restart verbs', () => {
|
|
|
137
137
|
|
|
138
138
|
describe('gateway: model-menu callback persists the sticky override', () => {
|
|
139
139
|
it('a confirmed selection persists the canonical token (selectedModelToken), never the display label', () => {
|
|
140
|
-
|
|
140
|
+
// Recording extracted into recordModelMenuSideEffects (#3017) — shared by the
|
|
141
|
+
// live dispatcher and the deferred (queued mid-turn) apply so both record
|
|
142
|
+
// identically.
|
|
143
|
+
const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
|
|
141
144
|
expect(idx).toBeGreaterThan(0)
|
|
142
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
145
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
143
146
|
expect(win).toContain('outcome.selectedModelToken')
|
|
144
147
|
expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
|
|
145
148
|
})
|
|
146
149
|
|
|
147
150
|
it('a confirmed "Default" selection CLEARS the sticky file', () => {
|
|
148
|
-
const idx = GATEWAY_SRC.indexOf('
|
|
149
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
151
|
+
const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
|
|
152
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
150
153
|
expect(win).toContain('outcome.clearedDefault')
|
|
151
154
|
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
152
155
|
})
|
|
153
156
|
|
|
154
157
|
it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
|
|
155
|
-
|
|
158
|
+
// Anchor on the sr-* TARGET dispatcher branch specifically (a mid-turn
|
|
159
|
+
// busy-gate #3017 also matches `if (data.startsWith(MODEL_CALLBACK_SR))`, so
|
|
160
|
+
// anchor on the relaunch call that is unique to the idle apply branch).
|
|
161
|
+
const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
|
|
156
162
|
expect(idx).toBeGreaterThan(0)
|
|
157
163
|
const win = GATEWAY_SRC.slice(idx, idx + 1400)
|
|
158
164
|
expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
|
|
@@ -172,7 +178,9 @@ describe('gateway: model-menu callback persists the sticky override', () => {
|
|
|
172
178
|
|
|
173
179
|
describe('gateway: typed /model persists the REQUESTED canonical token', () => {
|
|
174
180
|
it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
|
|
175
|
-
|
|
181
|
+
// Recording extracted into recordTypedModelSwitch (#3017) — shared by the
|
|
182
|
+
// live `bot.command('model')` handler and the deferred (queued mid-turn) apply.
|
|
183
|
+
const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
|
|
176
184
|
expect(idx).toBeGreaterThan(0)
|
|
177
185
|
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
178
186
|
expect(win).toContain("requested?.toLowerCase() === 'default'")
|
|
@@ -184,7 +192,7 @@ describe('gateway: typed /model persists the REQUESTED canonical token', () => {
|
|
|
184
192
|
})
|
|
185
193
|
|
|
186
194
|
it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
|
|
187
|
-
const idx = GATEWAY_SRC.indexOf(
|
|
195
|
+
const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
|
|
188
196
|
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
189
197
|
// The default branch clears the file unconditionally, and only the
|
|
190
198
|
// in-memory override change is confirmation-gated inside it.
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
isValidModelArg,
|
|
21
21
|
isSrModel,
|
|
22
22
|
isClaudeModel,
|
|
23
|
+
isBusyRefusalText,
|
|
24
|
+
isOfflineTrustedModelToken,
|
|
23
25
|
MODEL_ALIASES,
|
|
24
26
|
type ModelCommandDeps,
|
|
25
27
|
} from "../gateway/model-command.js";
|
|
@@ -201,7 +203,7 @@ describe("handleModelCommand — set", () => {
|
|
|
201
203
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
202
204
|
expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
|
|
203
205
|
expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
|
|
204
|
-
expect(reply.text).toContain("
|
|
206
|
+
expect(reply.text).toContain("persists across restarts, deploys, and crashes");
|
|
205
207
|
expect(reply.html).toBe(true);
|
|
206
208
|
// A verified confirmation records the live model so /status stays honest
|
|
207
209
|
// (bug 1: the typed path never recorded the switch before).
|
|
@@ -769,12 +771,19 @@ describe("buildModelMenu", () => {
|
|
|
769
771
|
}
|
|
770
772
|
});
|
|
771
773
|
|
|
772
|
-
it("busy agent → no discovery,
|
|
774
|
+
it("busy agent → no discovery, STATIC keyboard whose taps ride the queue (#3039)", async () => {
|
|
773
775
|
const { deps, calls } = makeMenuDeps({ isBusy: () => true });
|
|
774
776
|
const menu = await buildModelMenu(deps);
|
|
777
|
+
// Never drives the picker mid-turn…
|
|
775
778
|
expect(calls.discover).toBe(0);
|
|
776
|
-
expect(menu.keyboard).toBeUndefined();
|
|
777
779
|
expect(menu.text).toContain("mid-turn");
|
|
780
|
+
// …but no dead-end either: static alias rows are offered so the operator
|
|
781
|
+
// can still lock in a choice (the tap queues at the gateway busy gate).
|
|
782
|
+
expect(menu.keyboard).toBeDefined();
|
|
783
|
+
const data = menu.keyboard!.flat().map(b => b.callback_data);
|
|
784
|
+
expect(data).toContain("mdl:alias:opus");
|
|
785
|
+
expect(data).toContain("mdl:alias:default");
|
|
786
|
+
expect(menu.text).not.toContain("Try again");
|
|
778
787
|
});
|
|
779
788
|
|
|
780
789
|
it("discovery failure → static v1 fallback with the reason, no keyboard", async () => {
|
|
@@ -1317,3 +1326,37 @@ describe("Fable alias callback injects /model fable", () => {
|
|
|
1317
1326
|
expect(out.answer).toContain("Invalid");
|
|
1318
1327
|
});
|
|
1319
1328
|
});
|
|
1329
|
+
|
|
1330
|
+
// ─── #3039: busy-refusal detection for the queued-command drain ──────────────
|
|
1331
|
+
|
|
1332
|
+
describe("isBusyRefusalText (#3039)", () => {
|
|
1333
|
+
it("matches the typed and menu busy refusals", async () => {
|
|
1334
|
+
const deps = makeDeps({ isBusy: () => true }).deps;
|
|
1335
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
1336
|
+
expect(isBusyRefusalText(reply.text)).toBe(true);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("never matches a genuine confirmation or failure", () => {
|
|
1340
|
+
expect(isBusyRefusalText("⏺ Set model to Opus 4.8")).toBe(false);
|
|
1341
|
+
expect(isBusyRefusalText("✅ `/effort high` — Set effort level to high")).toBe(false);
|
|
1342
|
+
expect(isBusyRefusalText("❌ Switch to opus failed: tmux session not found")).toBe(false);
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
|
|
1348
|
+
it("trusts static Claude aliases and curated sr-* alias names/targets", () => {
|
|
1349
|
+
for (const a of MODEL_ALIASES) expect(isOfflineTrustedModelToken(a)).toBe(true);
|
|
1350
|
+
// Curated sr-* aliases resolve by construction (present in the LiteLLM config).
|
|
1351
|
+
const [alias, target] = Object.entries(SR_MODEL_ALIASES)[0];
|
|
1352
|
+
expect(isOfflineTrustedModelToken(alias)).toBe(true);
|
|
1353
|
+
expect(isOfflineTrustedModelToken(target)).toBe(true);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it("refuses hand-typed full ids — shape-valid garbage must never reach a boot carrier unconfirmed", () => {
|
|
1357
|
+
expect(isOfflineTrustedModelToken("claude-nonexistnet-9")).toBe(false);
|
|
1358
|
+
expect(isOfflineTrustedModelToken("claude-opus-4-8")).toBe(false); // real but unverifiable offline
|
|
1359
|
+
expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
|
|
1360
|
+
expect(isOfflineTrustedModelToken("")).toBe(false);
|
|
1361
|
+
});
|
|
1362
|
+
});
|