switchroom 0.18.7 → 0.18.9
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/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- 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/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- 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-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- 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-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -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/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression coverage for #2976 — HTML-entity corruption in mental-model
|
|
3
|
+
* fields, config-write-boundary layer.
|
|
4
|
+
*
|
|
5
|
+
* Reachability (stated plainly so these tests don't over-claim):
|
|
6
|
+
* - `source_query` decode is the REACHABLE/load-bearing half: a proposal's
|
|
7
|
+
* free-form query can carry escaped entities the model copied out of its
|
|
8
|
+
* Telegram-HTML context, and undecoded it steers recall on `R&D`
|
|
9
|
+
* instead of `R&D`.
|
|
10
|
+
* - `name` decode is REDUNDANT with the gateway slug gate
|
|
11
|
+
* (/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ rejects an entity-bearing name before
|
|
12
|
+
* `buildMentalModelAppendDiff` ever runs). It's belt-and-suspenders; the
|
|
13
|
+
* name-path tests here exercise the boundary directly (bypassing the gate)
|
|
14
|
+
* to prove the normalization, NOT to imply the propose path could carry the
|
|
15
|
+
* bug.
|
|
16
|
+
* - The observed in-NAME corruption (`Nutrition Protocol & Deficit
|
|
17
|
+
* Status`, klanker 2026-07-06) actually arrived via the DIRECT
|
|
18
|
+
* `create_mental_model` Hindsight tool, which this PR does not touch — that
|
|
19
|
+
* vector is out of scope (steering.ts / Dockerfile.hindsight follow-ups).
|
|
20
|
+
*
|
|
21
|
+
* These tests pin the switchroom-owned WRITE-BOUNDARY normalization: name +
|
|
22
|
+
* source_query are decoded to their literal characters BEFORE synthesis into
|
|
23
|
+
* the config diff. Each assertion below FAILS against the pre-fix code (which
|
|
24
|
+
* serialized `spec.name` / `spec.source_query` verbatim) and passes after.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from "vitest";
|
|
28
|
+
import { parseDocument } from "yaml";
|
|
29
|
+
import {
|
|
30
|
+
buildMentalModelAppendDiff,
|
|
31
|
+
readDeclaredMentalModelNames,
|
|
32
|
+
decodeCanonicalEntities,
|
|
33
|
+
} from "../gateway/mental-model-propose-diff.js";
|
|
34
|
+
|
|
35
|
+
const BASE_CONFIG = `agents:
|
|
36
|
+
klanker:
|
|
37
|
+
memory:
|
|
38
|
+
backend: hindsight
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/** Pull the appended model node out of the synthesized `after` config. */
|
|
42
|
+
function appendedModel(after: string, agent = "klanker"): { name?: unknown; source_query?: unknown } {
|
|
43
|
+
const js = parseDocument(after).toJS() as {
|
|
44
|
+
agents: Record<string, { memory: { mental_models: Array<Record<string, unknown>> } }>;
|
|
45
|
+
};
|
|
46
|
+
const models = js.agents[agent]!.memory.mental_models;
|
|
47
|
+
return models[models.length - 1]!;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("decodeCanonicalEntities — single-pass, canonical six only", () => {
|
|
51
|
+
it("decodes each canonical entity to its literal character", () => {
|
|
52
|
+
expect(decodeCanonicalEntities("R&D")).toBe("R&D");
|
|
53
|
+
expect(decodeCanonicalEntities("a<b>c")).toBe("a<b>c");
|
|
54
|
+
expect(decodeCanonicalEntities("say "hi"")).toBe('say "hi"');
|
|
55
|
+
expect(decodeCanonicalEntities("Ken's")).toBe("Ken's");
|
|
56
|
+
expect(decodeCanonicalEntities("Ken's")).toBe("Ken's");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("undoes exactly ONE layer of escaping (double-escaped → single-escaped)", () => {
|
|
60
|
+
// We only reverse the escaping switchroom applied when rendering the model's
|
|
61
|
+
// own prior output — never the user's literal intent.
|
|
62
|
+
expect(decodeCanonicalEntities("R&amp;D")).toBe("R&D");
|
|
63
|
+
expect(decodeCanonicalEntities("&lt;")).toBe("<");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("leaves entity-free text untouched (idempotent on clean input)", () => {
|
|
67
|
+
expect(decodeCanonicalEntities("Nutrition Protocol & Deficit")).toBe("Nutrition Protocol & Deficit");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("#2976 — propose write-boundary normalizes the mental-model name", () => {
|
|
72
|
+
it("stores the LITERAL '&' name, not the escaped '&' the model emitted", () => {
|
|
73
|
+
const built = buildMentalModelAppendDiff({
|
|
74
|
+
configText: BASE_CONFIG,
|
|
75
|
+
agentName: "klanker",
|
|
76
|
+
spec: { name: "Nutrition Protocol & Deficit Status", source_query: "how is the deficit?" },
|
|
77
|
+
});
|
|
78
|
+
expect(built.ok).toBe(true);
|
|
79
|
+
if (!built.ok) return;
|
|
80
|
+
expect(appendedModel(built.after).name).toBe("Nutrition Protocol & Deficit Status");
|
|
81
|
+
// And there is no residual entity anywhere in the persisted config.
|
|
82
|
+
expect(built.after).not.toContain("&");
|
|
83
|
+
// Reading the declared names back yields the decoded literal.
|
|
84
|
+
expect(readDeclaredMentalModelNames(built.after, "klanker")).toContain(
|
|
85
|
+
"Nutrition Protocol & Deficit Status",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("normalizes the recall-steering source_query too (not just the name)", () => {
|
|
90
|
+
const built = buildMentalModelAppendDiff({
|
|
91
|
+
configText: BASE_CONFIG,
|
|
92
|
+
agentName: "klanker",
|
|
93
|
+
spec: { name: "rd-budget", source_query: "what is the user's R&D budget?" },
|
|
94
|
+
});
|
|
95
|
+
expect(built.ok).toBe(true);
|
|
96
|
+
if (!built.ok) return;
|
|
97
|
+
expect(appendedModel(built.after).source_query).toBe("what is the user's R&D budget?");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects an entity-escaped re-propose of an already-declared literal name (dup guard on decoded name)", () => {
|
|
101
|
+
const configWithModel = `agents:
|
|
102
|
+
klanker:
|
|
103
|
+
memory:
|
|
104
|
+
backend: hindsight
|
|
105
|
+
mental_models:
|
|
106
|
+
- name: Q3 R&D Plan
|
|
107
|
+
source_query: what is the plan?
|
|
108
|
+
`;
|
|
109
|
+
const built = buildMentalModelAppendDiff({
|
|
110
|
+
configText: configWithModel,
|
|
111
|
+
agentName: "klanker",
|
|
112
|
+
// Escaped variant of the same logical name — must collide, not create a twin.
|
|
113
|
+
spec: { name: "Q3 R&D Plan", source_query: "different query" },
|
|
114
|
+
});
|
|
115
|
+
expect(built.ok).toBe(false);
|
|
116
|
+
if (built.ok) return;
|
|
117
|
+
expect(built.error).toBe("duplicate");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -19,7 +19,14 @@ import { describe, expect, it } from 'vitest'
|
|
|
19
19
|
import { readFileSync } from 'node:fs'
|
|
20
20
|
import { resolve } from 'node:path'
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// #2996 Phase 5: the callback-query handler families moved verbatim to
|
|
23
|
+
// gateway/callback-query-handlers.ts; these pins read the gateway source
|
|
24
|
+
// COMBINED with that module so the wiring assertions keep covering the
|
|
25
|
+
// same runtime source text.
|
|
26
|
+
const gatewaySrc =
|
|
27
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
28
|
+
'\n' +
|
|
29
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
23
30
|
|
|
24
31
|
function proposeCallbackBlock(): string {
|
|
25
32
|
return (
|
|
@@ -201,7 +201,7 @@ describe("handleModelCommand — set", () => {
|
|
|
201
201
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
202
202
|
expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
|
|
203
203
|
expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
|
|
204
|
-
expect(reply.text).toContain("
|
|
204
|
+
expect(reply.text).toContain("Sticky across switchroom-managed relaunches");
|
|
205
205
|
expect(reply.html).toBe(true);
|
|
206
206
|
// A verified confirmation records the live model so /status stays honest
|
|
207
207
|
// (bug 1: the typed path never recorded the switch before).
|
|
@@ -444,7 +444,7 @@ describe("handleModelCommand — busy gate + honest unverified reporting", () =>
|
|
|
444
444
|
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus" }, deps);
|
|
445
445
|
expect(reply.text).toContain("did not take");
|
|
446
446
|
expect(reply.text).toContain("Model not found");
|
|
447
|
-
expect(reply.text).not.toContain("
|
|
447
|
+
expect(reply.text).not.toContain("Sticky across switchroom-managed relaunches");
|
|
448
448
|
expect(reply.selectedModel).toBeUndefined();
|
|
449
449
|
});
|
|
450
450
|
|
|
@@ -76,6 +76,47 @@ describe('detectModelUnavailable — overload / 429 / 5xx strings', () => {
|
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
+
describe('detectModelUnavailable — transient upstream 429 vs account quota (#2922)', () => {
|
|
80
|
+
// The load-bearing regression: a server-side transient 429 whose message
|
|
81
|
+
// explicitly negates the account-quota reading ("not your usage limit")
|
|
82
|
+
// was misclassified as `quota_exhausted` by the negation-blind "usage limit"
|
|
83
|
+
// substring, firing a phantom fleet failover + dead turn. It must classify
|
|
84
|
+
// as `overload` (the calm rate-limit path Claude Code retries internally).
|
|
85
|
+
it("classifies the live-incident 'not your usage limit' 429 as overload, NOT quota_exhausted", () => {
|
|
86
|
+
const raw =
|
|
87
|
+
"API Error: Server is temporarily limiting requests (not your usage limit) · " +
|
|
88
|
+
'b\'{"type":"error","error":{"type":"rate_limit_error","message":' +
|
|
89
|
+
'"This request would exceed your account\'s rate limit. Please try again later."}}\''
|
|
90
|
+
const d = detectModelUnavailable(raw)
|
|
91
|
+
expect(d?.kind).toBe('overload')
|
|
92
|
+
expect(d?.kind).not.toBe('quota_exhausted')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("classifies bare 'temporarily limiting requests' as overload", () => {
|
|
96
|
+
expect(
|
|
97
|
+
detectModelUnavailable('Server is temporarily limiting requests')?.kind,
|
|
98
|
+
).toBe('overload')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("classifies \"would exceed your account's rate limit\" as overload", () => {
|
|
102
|
+
expect(
|
|
103
|
+
detectModelUnavailable(
|
|
104
|
+
"This request would exceed your account's rate limit. Please try again later.",
|
|
105
|
+
)?.kind,
|
|
106
|
+
).toBe('overload')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('still classifies a genuine account usage-limit hit as quota_exhausted', () => {
|
|
110
|
+
// Guard against over-correction: real quota exhaustion must stay quota.
|
|
111
|
+
expect(
|
|
112
|
+
detectModelUnavailable("You've hit your limit · resets 8:50am (Australia/Melbourne)")?.kind,
|
|
113
|
+
).toBe('quota_exhausted')
|
|
114
|
+
expect(detectModelUnavailable('Reached usage limit for the 5h window')?.kind).toBe(
|
|
115
|
+
'quota_exhausted',
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
79
120
|
describe('detectModelUnavailable — network failures', () => {
|
|
80
121
|
it('classifies ECONNREFUSED', () => {
|
|
81
122
|
expect(detectModelUnavailable('connect ECONNREFUSED 1.2.3.4:443')?.kind).toBe('network')
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invocable send-loop harness (#2996 step 1).
|
|
3
|
+
*
|
|
4
|
+
* PR 3007 extracted the deterministic text/chunk CORE of the outbound path but
|
|
5
|
+
* noted the side-effecting send ORCHESTRATION could not be moved (or tested)
|
|
6
|
+
* without an "invocable-executeReply harness" — because gateway.ts is not
|
|
7
|
+
* importable in any test runner: it runs boot logic (acquires the PID lock,
|
|
8
|
+
* `process.exit(1)` when another gateway is live) and calls `Bun.listen` at
|
|
9
|
+
* import time, so `executeReply` can never be driven from vitest/bun in-place.
|
|
10
|
+
*
|
|
11
|
+
* This harness closes that gap for the highest-bug-density mechanic of the send
|
|
12
|
+
* path — the chunk-send loop with its THREAD_NOT_FOUND / oversize re-split /
|
|
13
|
+
* parse-reject fallback ladder and partial-failure contract (exactly where the
|
|
14
|
+
* recent oversize / wire-cap fixes landed). `sendReplyChunks` was relocated
|
|
15
|
+
* VERBATIM from executeReply and is now driven here against a FAKE bot API,
|
|
16
|
+
* asserting: multi-chunk send order, partial-failure contract (which chunks
|
|
17
|
+
* report sent), oversize re-send, HTML parse-reject plaintext fallback,
|
|
18
|
+
* THREAD_NOT_FOUND thread-drop + retry, keyboard-on-last-chunk passthrough,
|
|
19
|
+
* preview edit-in-place, and voice-only text suppression.
|
|
20
|
+
*
|
|
21
|
+
* The gateway keeps the raw `bot.api.*` calls as thin injected adapters; the
|
|
22
|
+
* module is bot-agnostic, so a fake is a plain record of function calls. The
|
|
23
|
+
* cross-surface `outboundDedup` singleton contract is covered by the sibling
|
|
24
|
+
* `outbound-send-path.test.ts` (a stream-path `record` suppresses a reply-path
|
|
25
|
+
* `check` on the same instance) — dedup lives in executeReply, not in this
|
|
26
|
+
* relocated loop, so it is intentionally out of scope here.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect } from 'vitest'
|
|
29
|
+
import { GrammyError } from 'grammy'
|
|
30
|
+
import {
|
|
31
|
+
sendReplyChunks,
|
|
32
|
+
type ReplyChunkSendDeps,
|
|
33
|
+
type ReplyChunkSendState,
|
|
34
|
+
} from '../gateway/outbound-send-path.js'
|
|
35
|
+
|
|
36
|
+
// Build a GrammyError with a given 400 description (grammy 1.44 ctor shape).
|
|
37
|
+
function grammy400(description: string, method = 'sendRichMessage'): GrammyError {
|
|
38
|
+
return new GrammyError(
|
|
39
|
+
`Call to '${method}' failed! (400: ${description})`,
|
|
40
|
+
{ ok: false, error_code: 400, description },
|
|
41
|
+
method,
|
|
42
|
+
{},
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RecordedCall {
|
|
47
|
+
kind: 'sendRich' | 'sendLiteral' | 'sendLiteralRaw' | 'sendRichRaw' | 'editPreview'
|
|
48
|
+
opts: Record<string, unknown>
|
|
49
|
+
body: unknown
|
|
50
|
+
threadId?: number | undefined
|
|
51
|
+
messageId?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fake bot surface. `sendRich`/`sendLiteral` are the retry-wrapped adapters;
|
|
56
|
+
* `*Raw` are the unwrapped last-resort adapters; `editPreview` is the
|
|
57
|
+
* preview edit-in-place. Each records its call; per-kind `failers` inject
|
|
58
|
+
* one-shot errors (keyed by chunk-body identity or call index) so we can
|
|
59
|
+
* script the fallback ladder deterministically.
|
|
60
|
+
*/
|
|
61
|
+
function makeFake(opts?: {
|
|
62
|
+
failNext?: Partial<Record<RecordedCall['kind'], Array<unknown>>>
|
|
63
|
+
richMessage?: (s: string) => unknown
|
|
64
|
+
}): {
|
|
65
|
+
deps: ReplyChunkSendDeps
|
|
66
|
+
calls: RecordedCall[]
|
|
67
|
+
stderr: string[]
|
|
68
|
+
logs: Array<{ id: number; chars: number; extra?: string }>
|
|
69
|
+
deleted: number[]
|
|
70
|
+
} {
|
|
71
|
+
const calls: RecordedCall[] = []
|
|
72
|
+
const stderr: string[] = []
|
|
73
|
+
const logs: Array<{ id: number; chars: number; extra?: string }> = []
|
|
74
|
+
const deleted: number[] = []
|
|
75
|
+
let nextId = 1000
|
|
76
|
+
const queues = opts?.failNext ?? {}
|
|
77
|
+
|
|
78
|
+
const maybeFail = (kind: RecordedCall['kind']): void => {
|
|
79
|
+
const q = queues[kind]
|
|
80
|
+
if (q && q.length > 0) {
|
|
81
|
+
const e = q.shift()
|
|
82
|
+
if (e != null) throw e
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const send = (
|
|
86
|
+
kind: RecordedCall['kind'],
|
|
87
|
+
o: Record<string, unknown>,
|
|
88
|
+
body: unknown,
|
|
89
|
+
threadId?: number | undefined,
|
|
90
|
+
): Promise<{ message_id: number }> => {
|
|
91
|
+
// Evaluate the failure queue synchronously so a thrown GrammyError
|
|
92
|
+
// rejects the returned promise exactly as the real adapter would.
|
|
93
|
+
return (async () => {
|
|
94
|
+
maybeFail(kind)
|
|
95
|
+
const id = ++nextId
|
|
96
|
+
calls.push({ kind, opts: o, body, threadId, messageId: id })
|
|
97
|
+
return { message_id: id }
|
|
98
|
+
})()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const deps: ReplyChunkSendDeps = {
|
|
102
|
+
sendRich: (o, body, tid) => send('sendRich', o, body, tid),
|
|
103
|
+
sendLiteral: (o, txt, tid) => send('sendLiteral', o, txt, tid),
|
|
104
|
+
sendLiteralRaw: (o, txt) => send('sendLiteralRaw', o, txt),
|
|
105
|
+
sendRichRaw: (o, body) => send('sendRichRaw', o, body),
|
|
106
|
+
editPreview: async (mid, body, o, tid) => {
|
|
107
|
+
maybeFail('editPreview')
|
|
108
|
+
calls.push({ kind: 'editPreview', opts: o, body, threadId: tid, messageId: mid })
|
|
109
|
+
return {}
|
|
110
|
+
},
|
|
111
|
+
richMessage: opts?.richMessage ?? ((s: string) => ({ rich: s })),
|
|
112
|
+
logOutbound: (_path, _chat, id, chars, extra) => { logs.push({ id, chars, extra }) },
|
|
113
|
+
deleteStalePreview: async (id) => { deleted.push(id) },
|
|
114
|
+
stderr: (s) => { stderr.push(s) },
|
|
115
|
+
}
|
|
116
|
+
return { deps, calls, stderr, logs, deleted }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function baseState(over: Partial<ReplyChunkSendState> & { chunks: string[] }): ReplyChunkSendState {
|
|
120
|
+
return {
|
|
121
|
+
chatId: '555',
|
|
122
|
+
literalText: false,
|
|
123
|
+
suppressText: false,
|
|
124
|
+
threadId: undefined,
|
|
125
|
+
previewMessageId: null,
|
|
126
|
+
sentIds: [],
|
|
127
|
+
buildSendOpts: (i, isLastChunk, tid) => ({
|
|
128
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
129
|
+
...(isLastChunk ? { _last: true } : {}),
|
|
130
|
+
_chunkIndex: i,
|
|
131
|
+
}),
|
|
132
|
+
buildPreviewEditOpts: () => ({}),
|
|
133
|
+
...over,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe('sendReplyChunks — multi-chunk send order', () => {
|
|
138
|
+
it('sends every chunk in order and appends ids to the shared sentIds array', async () => {
|
|
139
|
+
const { deps, calls } = makeFake()
|
|
140
|
+
const state = baseState({ chunks: ['a', 'b', 'c'] })
|
|
141
|
+
const res = await sendReplyChunks(deps, state)
|
|
142
|
+
expect(calls.map((c) => c.kind)).toEqual(['sendRich', 'sendRich', 'sendRich'])
|
|
143
|
+
expect(calls.map((c) => c.body)).toEqual([{ rich: 'a' }, { rich: 'b' }, { rich: 'c' }])
|
|
144
|
+
// ids appended in order, one per chunk
|
|
145
|
+
expect(state.sentIds).toHaveLength(3)
|
|
146
|
+
expect(state.sentIds).toEqual([...state.sentIds].sort((x, y) => x - y))
|
|
147
|
+
expect(res.threadId).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('literal (format:text) chunks route through sendLiteral, never rich', async () => {
|
|
151
|
+
const { deps, calls } = makeFake()
|
|
152
|
+
const state = baseState({ chunks: ['x', 'y'], literalText: true })
|
|
153
|
+
await sendReplyChunks(deps, state)
|
|
154
|
+
expect(calls.map((c) => c.kind)).toEqual(['sendLiteral', 'sendLiteral'])
|
|
155
|
+
expect(calls.map((c) => c.body)).toEqual(['x', 'y'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('voice-only suppressText sends NOTHING (voice note IS the reply)', async () => {
|
|
159
|
+
const { deps, calls } = makeFake()
|
|
160
|
+
const state = baseState({ chunks: ['a', 'b'], suppressText: true })
|
|
161
|
+
await sendReplyChunks(deps, state)
|
|
162
|
+
expect(calls).toHaveLength(0)
|
|
163
|
+
expect(state.sentIds).toHaveLength(0)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('sendReplyChunks — keyboard/opts passthrough', () => {
|
|
168
|
+
it('reply_markup rides ONLY on the last chunk (isLastChunk gate)', async () => {
|
|
169
|
+
const { deps, calls } = makeFake()
|
|
170
|
+
const state = baseState({
|
|
171
|
+
chunks: ['one', 'two', 'three'],
|
|
172
|
+
buildSendOpts: (i, isLastChunk, tid) => ({
|
|
173
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
174
|
+
...(isLastChunk ? { reply_markup: { inline_keyboard: [[{ text: 'Go', callback_data: 'x' }]] } } : {}),
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
await sendReplyChunks(deps, state)
|
|
178
|
+
expect(calls[0].opts.reply_markup).toBeUndefined()
|
|
179
|
+
expect(calls[1].opts.reply_markup).toBeUndefined()
|
|
180
|
+
expect(calls[2].opts.reply_markup).toBeDefined()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('rich path strips link_preview_options before sending (rich entity previews)', async () => {
|
|
184
|
+
const { deps, calls } = makeFake()
|
|
185
|
+
const state = baseState({
|
|
186
|
+
chunks: ['only'],
|
|
187
|
+
buildSendOpts: () => ({ link_preview_options: { is_disabled: true }, keepme: 1 }),
|
|
188
|
+
})
|
|
189
|
+
await sendReplyChunks(deps, state)
|
|
190
|
+
expect(calls[0].opts.link_preview_options).toBeUndefined()
|
|
191
|
+
expect(calls[0].opts.keepme).toBe(1)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('sendReplyChunks — partial-failure contract', () => {
|
|
196
|
+
it('an unrecoverable error midway propagates, and sentIds holds ONLY the chunks already sent', async () => {
|
|
197
|
+
// chunk 0 ok, chunk 1 throws a non-400 (not length, not parse) → rethrow.
|
|
198
|
+
const boom = new Error('network exploded')
|
|
199
|
+
const { deps, calls } = makeFake({ failNext: { sendRich: [undefined, boom] } })
|
|
200
|
+
const state = baseState({ chunks: ['first', 'second', 'third'] })
|
|
201
|
+
await expect(sendReplyChunks(deps, state)).rejects.toThrow('network exploded')
|
|
202
|
+
// first chunk reported sent; second/third never appended.
|
|
203
|
+
expect(state.sentIds).toHaveLength(1)
|
|
204
|
+
// exactly one send succeeded (chunk 1 threw before being recorded).
|
|
205
|
+
expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(1)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('sendReplyChunks — oversize re-send', () => {
|
|
210
|
+
it('a RICH_MESSAGE_TEXT_TOO_LONG on the wrapped send re-splits via the raw rich adapter', async () => {
|
|
211
|
+
// A chunk far past the wire cap so resplitOversizeChunk yields >1 piece.
|
|
212
|
+
const huge = 'x'.repeat(70_000)
|
|
213
|
+
const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400('RICH_MESSAGE_TEXT_TOO_LONG')] } })
|
|
214
|
+
const state = baseState({ chunks: [huge] })
|
|
215
|
+
await sendReplyChunks(deps, state)
|
|
216
|
+
const raws = calls.filter((c) => c.kind === 'sendRichRaw')
|
|
217
|
+
expect(raws.length).toBeGreaterThan(1)
|
|
218
|
+
// every re-split piece landed a message id
|
|
219
|
+
expect(state.sentIds.length).toBe(raws.length)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('sendReplyChunks — parse-reject plaintext fallback', () => {
|
|
224
|
+
it("a can't-parse-entities 400 resends the chunk as plain text via sendLiteralRaw", async () => {
|
|
225
|
+
const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400("can't parse entities: bad offset")] } })
|
|
226
|
+
const state = baseState({ chunks: ['**bad markdown'] })
|
|
227
|
+
await sendReplyChunks(deps, state)
|
|
228
|
+
expect(calls.map((c) => c.kind)).toEqual(['sendLiteralRaw'])
|
|
229
|
+
expect(calls[0].body).toBe('**bad markdown')
|
|
230
|
+
expect(state.sentIds).toHaveLength(1)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('an empty chunk parse-reject falls back to the placeholder glyph', async () => {
|
|
234
|
+
const { deps, calls } = makeFake({ failNext: { sendRich: [grammy400("can't parse entities")] } })
|
|
235
|
+
const state = baseState({ chunks: [''] })
|
|
236
|
+
await sendReplyChunks(deps, state)
|
|
237
|
+
expect(calls[0].kind).toBe('sendLiteralRaw')
|
|
238
|
+
expect(String(calls[0].body)).toContain('could not be rendered')
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('sendReplyChunks — THREAD_NOT_FOUND fallback', () => {
|
|
243
|
+
it('drops the thread and retries UNWRAPPED, and returns threadId undefined', async () => {
|
|
244
|
+
// wrapped send throws THREAD_NOT_FOUND; retry (raw) succeeds.
|
|
245
|
+
const tnf = new Error('THREAD_NOT_FOUND')
|
|
246
|
+
const { deps, calls } = makeFake({ failNext: { sendRich: [tnf] } })
|
|
247
|
+
const state = baseState({ chunks: ['hi'], threadId: 42 })
|
|
248
|
+
const res = await sendReplyChunks(deps, state)
|
|
249
|
+
// The first (wrapped) attempt threw before recording; the retry uses
|
|
250
|
+
// sendChunk(_, false) → the UNWRAPPED raw adapter, deliberately not
|
|
251
|
+
// re-entering the retry policy after a dropped thread.
|
|
252
|
+
expect(calls[0].kind).toBe('sendRichRaw')
|
|
253
|
+
// retry re-built opts WITHOUT the thread id.
|
|
254
|
+
expect(res.threadId).toBeUndefined()
|
|
255
|
+
expect(calls[0].opts.message_thread_id).toBeUndefined()
|
|
256
|
+
expect(state.sentIds).toHaveLength(1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('THREAD_NOT_FOUND then a length error on retry re-splits', async () => {
|
|
260
|
+
const tnf = new Error('THREAD_NOT_FOUND')
|
|
261
|
+
const huge = 'y'.repeat(70_000)
|
|
262
|
+
const { deps, calls } = makeFake({
|
|
263
|
+
failNext: { sendRich: [tnf], sendRichRaw: [grammy400('MESSAGE_TOO_LONG')] },
|
|
264
|
+
})
|
|
265
|
+
const state = baseState({ chunks: [huge], threadId: 7 })
|
|
266
|
+
await sendReplyChunks(deps, state)
|
|
267
|
+
// retry (raw) threw length → resplit sent multiple raw pieces
|
|
268
|
+
const raws = calls.filter((c) => c.kind === 'sendRichRaw')
|
|
269
|
+
expect(raws.length).toBeGreaterThan(1)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('sendReplyChunks — preview edit-in-place', () => {
|
|
274
|
+
it('edits the stale preview on the first chunk, consumes it, then sends the rest fresh', async () => {
|
|
275
|
+
const { deps, calls } = makeFake()
|
|
276
|
+
const state = baseState({ chunks: ['a', 'b'], previewMessageId: 900 })
|
|
277
|
+
const res = await sendReplyChunks(deps, state)
|
|
278
|
+
expect(calls[0].kind).toBe('editPreview')
|
|
279
|
+
expect(calls[0].messageId).toBe(900)
|
|
280
|
+
// preview id consumed → subsequent chunk is a fresh send
|
|
281
|
+
expect(calls[1].kind).toBe('sendRich')
|
|
282
|
+
expect(res.previewMessageId).toBeNull()
|
|
283
|
+
// first sentId is the reused preview id
|
|
284
|
+
expect(state.sentIds[0]).toBe(900)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('a failed preview edit deletes the stale preview and sends fresh', async () => {
|
|
288
|
+
const { deps, calls, deleted } = makeFake({ failNext: { editPreview: [new Error('message to edit not found')] } })
|
|
289
|
+
const state = baseState({ chunks: ['a'], previewMessageId: 901 })
|
|
290
|
+
const res = await sendReplyChunks(deps, state)
|
|
291
|
+
expect(deleted).toEqual([901])
|
|
292
|
+
expect(calls.some((c) => c.kind === 'sendRich')).toBe(true)
|
|
293
|
+
expect(res.previewMessageId).toBeNull()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('a "not modified" preview edit is treated as success (id reused, no fresh send)', async () => {
|
|
297
|
+
const { deps, calls, deleted } = makeFake({ failNext: { editPreview: [new Error('Bad Request: message is not modified')] } })
|
|
298
|
+
const state = baseState({ chunks: ['a'], previewMessageId: 902 })
|
|
299
|
+
await sendReplyChunks(deps, state)
|
|
300
|
+
expect(deleted).toEqual([])
|
|
301
|
+
expect(state.sentIds).toEqual([902])
|
|
302
|
+
expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(0)
|
|
303
|
+
})
|
|
304
|
+
})
|