switchroom 0.18.7 → 0.18.8
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/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 +1403 -657
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/gateway.ts +564 -85
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- 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/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/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -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/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
|
+
});
|
|
@@ -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')
|
|
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
9
9
|
import {
|
|
10
10
|
createPtyPartialHandler,
|
|
11
11
|
handlePtyPartialPure,
|
|
12
|
+
looksLikeRawApiError,
|
|
12
13
|
type PtyHandlerState,
|
|
13
14
|
type PtyHandlerDeps,
|
|
14
15
|
} from '../pty-partial-handler.js'
|
|
@@ -65,6 +66,35 @@ describe('handlePtyPartialPure', () => {
|
|
|
65
66
|
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
66
67
|
})
|
|
67
68
|
|
|
69
|
+
it('suppresses a raw API-error TUI line so it never leaks to chat (#2922 Bug 3)', async () => {
|
|
70
|
+
const state = makeState({ currentSessionChatId: '1' })
|
|
71
|
+
const deps = makeDeps(bot)
|
|
72
|
+
// The exact shape Claude Code's TUI renders on a transient 429.
|
|
73
|
+
const raw =
|
|
74
|
+
"API Error: Server is temporarily limiting requests (not your usage limit) · " +
|
|
75
|
+
"b'{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}'"
|
|
76
|
+
const action = handlePtyPartialPure(raw, state, deps)
|
|
77
|
+
expect(action).toBe('error-suppressed')
|
|
78
|
+
await microtaskFlush()
|
|
79
|
+
// Nothing sent — the operator-event pipeline owns the user-facing card.
|
|
80
|
+
expect(bot.api.sendMessage).not.toHaveBeenCalled()
|
|
81
|
+
expect(state.activeDraftStreams.size).toBe(0)
|
|
82
|
+
// Not recorded as a preview, so it can't poison later dedup either.
|
|
83
|
+
expect(state.lastPtyPreviewByChat.size).toBe(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does NOT suppress ordinary assistant text that merely mentions errors', async () => {
|
|
87
|
+
const state = makeState({ currentSessionChatId: '1' })
|
|
88
|
+
const action = handlePtyPartialPure(
|
|
89
|
+
"Here's how to handle an error in your retry loop:",
|
|
90
|
+
state,
|
|
91
|
+
makeDeps(bot),
|
|
92
|
+
)
|
|
93
|
+
expect(action).toBe('update-new')
|
|
94
|
+
await microtaskFlush()
|
|
95
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
96
|
+
})
|
|
97
|
+
|
|
68
98
|
it('dedups when same text arrives twice in a row', async () => {
|
|
69
99
|
const state = makeState({ currentSessionChatId: '1' })
|
|
70
100
|
const deps = makeDeps(bot)
|
|
@@ -324,3 +354,29 @@ describe('createPtyPartialHandler — session + buffer replay', () => {
|
|
|
324
354
|
expect(state.activeDraftStreams.size).toBe(0)
|
|
325
355
|
})
|
|
326
356
|
})
|
|
357
|
+
|
|
358
|
+
describe('looksLikeRawApiError (#2922 Bug 3)', () => {
|
|
359
|
+
it('flags the CLI "API Error: … · b\'{…}\'" line', () => {
|
|
360
|
+
expect(
|
|
361
|
+
looksLikeRawApiError(
|
|
362
|
+
"API Error: Server is temporarily limiting requests · b'{\"type\":\"error\"}'",
|
|
363
|
+
),
|
|
364
|
+
).toBe(true)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('flags a bare rate_limit_error JSON body', () => {
|
|
368
|
+
expect(
|
|
369
|
+
looksLikeRawApiError('{"type":"error","error":{"type":"rate_limit_error"}}'),
|
|
370
|
+
).toBe(true)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('flags overloaded_error and is_error markers', () => {
|
|
374
|
+
expect(looksLikeRawApiError('{"type":"overloaded_error"}')).toBe(true)
|
|
375
|
+
expect(looksLikeRawApiError('{"is_error":true,"content":"boom"}')).toBe(true)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('does not flag ordinary prose mentioning "error"', () => {
|
|
379
|
+
expect(looksLikeRawApiError('I hit an error handling that request')).toBe(false)
|
|
380
|
+
expect(looksLikeRawApiError('')).toBe(false)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the chunk-boundary cap bug (fix/rich-render-chunk-boundary-cap).
|
|
3
|
+
*
|
|
4
|
+
* THE BUG: the outbound safe renderer (`renderSafe`) re-escapes GFM-special
|
|
5
|
+
* characters, which GROWS a body. A raw chunk sized just under the wire cap can
|
|
6
|
+
* escape PAST it. `renderSafe`'s only oversize recourse is to degrade the WHOLE
|
|
7
|
+
* document to `mode:"plain"` (raw source, no rich wrapper) — it never re-splits
|
|
8
|
+
* a multi-block body. The send path then ships that plain body through the
|
|
9
|
+
* 4096-char plain `sendMessage` endpoint, so a ~32k plain body is rejected by
|
|
10
|
+
* Telegram (`message is too long`) and the answer is dropped.
|
|
11
|
+
*
|
|
12
|
+
* `renderOutboundChunks` closes the gap: every returned piece fits its own wire
|
|
13
|
+
* cap (rich `<= maxLen`, plain `<= plainMax`) and is cut only at
|
|
14
|
+
* `splitMarkdownChunks`' safe boundaries (never bisecting a fence / table row).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from "vitest";
|
|
17
|
+
import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
|
|
18
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
|
|
19
|
+
|
|
20
|
+
const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
|
|
21
|
+
const OFF = {} as NodeJS.ProcessEnv;
|
|
22
|
+
|
|
23
|
+
/** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
|
|
24
|
+
* fenced block has an ODD count. */
|
|
25
|
+
function fenceCount(s: string): number {
|
|
26
|
+
return (s.match(/^```/gm) ?? []).length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("renderOutboundChunks", () => {
|
|
30
|
+
it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
|
|
31
|
+
const raw = "**bold** and _italic_ | a | table |";
|
|
32
|
+
const pieces = renderOutboundChunks(raw, OFF);
|
|
33
|
+
expect(pieces).toHaveLength(1);
|
|
34
|
+
expect(pieces[0].text).toBe(raw);
|
|
35
|
+
expect(pieces[0].mode).toBe("markdown");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("flag ON, body that fits is a single piece (common case)", () => {
|
|
39
|
+
const pieces = renderOutboundChunks("just some plain prose", ON);
|
|
40
|
+
expect(pieces).toHaveLength(1);
|
|
41
|
+
expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("REGRESSION: a near-cap escapable body splits into cap-respecting pieces", () => {
|
|
45
|
+
// Prose whose escaping (`_ * |` each gain a leading `\`) grows it past the
|
|
46
|
+
// cap. Use small caps so the test is fast; the invariant is cap-agnostic.
|
|
47
|
+
const maxLen = 200;
|
|
48
|
+
const plainMax = 80;
|
|
49
|
+
const unit = "a_b*c|d ";
|
|
50
|
+
const raw = unit.repeat(40); // 320 raw chars, ~1.5x after escaping
|
|
51
|
+
const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
|
|
52
|
+
|
|
53
|
+
// The whole body did NOT fit as one message — it was re-split.
|
|
54
|
+
expect(pieces.length).toBeGreaterThan(1);
|
|
55
|
+
for (const p of pieces) {
|
|
56
|
+
if (p.mode === "plain") {
|
|
57
|
+
// A plain piece rides the plain `sendMessage` endpoint — must fit its cap.
|
|
58
|
+
expect(p.text.length).toBeLessThanOrEqual(plainMax);
|
|
59
|
+
} else {
|
|
60
|
+
expect(p.text.length).toBeLessThanOrEqual(maxLen);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("REGRESSION: never bisects a fenced code block when splitting", () => {
|
|
66
|
+
const maxLen = 300;
|
|
67
|
+
const plainMax = 250; // >= the fence size, so a fence never needs a hard slice.
|
|
68
|
+
// A fenced block big enough that a naive length cut would land inside it,
|
|
69
|
+
// wrapped in prose so the whole document overflows and must be re-split.
|
|
70
|
+
const fence = "```\n" + "code line here\n".repeat(8) + "```";
|
|
71
|
+
const prose = "word ".repeat(40);
|
|
72
|
+
const raw = `${prose}\n\n${fence}\n\n${prose}`;
|
|
73
|
+
const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
|
|
74
|
+
|
|
75
|
+
expect(pieces.length).toBeGreaterThan(1);
|
|
76
|
+
for (const p of pieces) {
|
|
77
|
+
// Every emitted piece has BALANCED fence delimiters — no piece opens a
|
|
78
|
+
// fence it doesn't close (which would swallow the next piece's text).
|
|
79
|
+
expect(fenceCount(p.text) % 2).toBe(0);
|
|
80
|
+
const cap = p.mode === "plain" ? plainMax : maxLen;
|
|
81
|
+
expect(p.text.length).toBeLessThanOrEqual(cap);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("REAL-CAP: a ~32k escapable body never yields an over-4096 plain piece", () => {
|
|
86
|
+
// The production failure: raw just under RICH_MESSAGE_MAX_CHARS, escaping
|
|
87
|
+
// pushes the rendered form over it. On the buggy path this became ONE
|
|
88
|
+
// ~32k plain body sent through the 4096 plain endpoint. Here every plain
|
|
89
|
+
// piece is <= 4096 and every rich piece <= 32768.
|
|
90
|
+
const unit = "a_b*c|d ";
|
|
91
|
+
const raw = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
92
|
+
const pieces = renderOutboundChunks(raw, ON);
|
|
93
|
+
for (const p of pieces) {
|
|
94
|
+
const cap = p.mode === "plain" ? PLAIN_TEXT_MAX_CHARS : RICH_MESSAGE_MAX_CHARS;
|
|
95
|
+
expect(p.text.length).toBeLessThanOrEqual(cap);
|
|
96
|
+
}
|
|
97
|
+
}, 30000);
|
|
98
|
+
});
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
createSwallowingRetryApiCall,
|
|
16
16
|
retryWithThreadFallback,
|
|
17
17
|
isHtmlParseRejectError,
|
|
18
|
+
isLocalResourceError,
|
|
19
|
+
LOCAL_RESOURCE_EXHAUSTED,
|
|
18
20
|
type RetryObserver,
|
|
19
21
|
} from '../retry-api-call.js'
|
|
20
22
|
import { errors, makeGrammyError } from './fake-bot-api.js'
|
|
@@ -512,3 +514,60 @@ describe('isHtmlParseRejectError', () => {
|
|
|
512
514
|
).toBe(true)
|
|
513
515
|
})
|
|
514
516
|
})
|
|
517
|
+
|
|
518
|
+
describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)', () => {
|
|
519
|
+
it('classifies ENOSPC / EDQUOT / EIO / ENOMEM by errno code', () => {
|
|
520
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOSPC' }))).toBe(true)
|
|
521
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EDQUOT' }))).toBe(true)
|
|
522
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EIO' }))).toBe(true)
|
|
523
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOMEM' }))).toBe(true)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('classifies by message when no code is present (incl. EIO, word-boundaried)', () => {
|
|
527
|
+
expect(isLocalResourceError(new Error('ENOSPC: no space left on device, write'))).toBe(true)
|
|
528
|
+
expect(isLocalResourceError(new Error('disk quota exceeded'))).toBe(true)
|
|
529
|
+
expect(isLocalResourceError(new Error('EIO: i/o error, write'))).toBe(true)
|
|
530
|
+
// No false match on a substring (e.g. a word containing the letters).
|
|
531
|
+
expect(isLocalResourceError(new Error('DENOSPCX not a real code'))).toBe(false)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('does NOT classify a remote GrammyError or ordinary error', () => {
|
|
535
|
+
expect(isLocalResourceError(errors.floodWait(10))).toBe(false)
|
|
536
|
+
expect(isLocalResourceError(new Error('fetch failed'))).toBe(false)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('throws LOCAL_RESOURCE_EXHAUSTED immediately without retrying', async () => {
|
|
540
|
+
// Before the fix: an ENOSPC thrown by the send-staging step fell through
|
|
541
|
+
// to the network-retry branch pattern OR was rethrown but only after the
|
|
542
|
+
// caller kept re-driving sends — the storm that tripped the flood ban.
|
|
543
|
+
// Now it must fail FAST on the first attempt with a distinct marker.
|
|
544
|
+
const sleep = vi.fn(async () => {})
|
|
545
|
+
let calls = 0
|
|
546
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep })
|
|
547
|
+
await expect(
|
|
548
|
+
retry(async () => {
|
|
549
|
+
calls++
|
|
550
|
+
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' })
|
|
551
|
+
}),
|
|
552
|
+
).rejects.toThrow(LOCAL_RESOURCE_EXHAUSTED)
|
|
553
|
+
expect(calls).toBe(1) // no retry
|
|
554
|
+
expect(sleep).not.toHaveBeenCalled() // no backoff-into-flood
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('fires onFloodWait with the retry_after when a 429 is seen', async () => {
|
|
558
|
+
const seen: number[] = []
|
|
559
|
+
const sleep = vi.fn(async () => {})
|
|
560
|
+
let n = 0
|
|
561
|
+
const retry = createRetryApiCall({
|
|
562
|
+
maxRetries: 3,
|
|
563
|
+
sleep,
|
|
564
|
+
onFloodWait: (s) => seen.push(s),
|
|
565
|
+
})
|
|
566
|
+
const out = await retry(async () => {
|
|
567
|
+
if (n++ === 0) throw errors.floodWait(42)
|
|
568
|
+
return 'ok'
|
|
569
|
+
})
|
|
570
|
+
expect(out).toBe('ok')
|
|
571
|
+
expect(seen).toEqual([42])
|
|
572
|
+
})
|
|
573
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* #2555 — run-hook.sh must tolerate a Node exit-134 (uv_thread_create abort
|
|
10
|
+
* under memory pressure): retry once, and if it still aborts, skip cleanly
|
|
11
|
+
* (exit 0) rather than propagating 134. Real non-134 statuses pass through.
|
|
12
|
+
*
|
|
13
|
+
* We drive the wrapper with a fake command (a tiny sh script) whose exit code
|
|
14
|
+
* is scripted via a counter file, so we exercise the exact control flow
|
|
15
|
+
* without needing a real memory-pressured Node.
|
|
16
|
+
*/
|
|
17
|
+
const wrapper = resolve(
|
|
18
|
+
fileURLToPath(new URL('../hooks/run-hook.sh', import.meta.url)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
describe('#2555 run-hook.sh exit-134 tolerance', () => {
|
|
22
|
+
let dir: string
|
|
23
|
+
let fake: string
|
|
24
|
+
let counter: string
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), 'run-hook-'))
|
|
28
|
+
fake = join(dir, 'fake.sh')
|
|
29
|
+
counter = join(dir, 'n')
|
|
30
|
+
writeFileSync(counter, '0')
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
33
|
+
|
|
34
|
+
/** Fake command: exits `codes[attemptIndex]`, records each invocation. */
|
|
35
|
+
function writeFake(codes: number[]): void {
|
|
36
|
+
writeFileSync(
|
|
37
|
+
fake,
|
|
38
|
+
[
|
|
39
|
+
'#!/bin/sh',
|
|
40
|
+
`n=$(cat "${counter}")`,
|
|
41
|
+
`echo "$((n + 1))" > "${counter}"`,
|
|
42
|
+
'case "$n" in',
|
|
43
|
+
...codes.map((c, i) => ` ${i}) exit ${c} ;;`),
|
|
44
|
+
` *) exit ${codes[codes.length - 1]} ;;`,
|
|
45
|
+
'esac',
|
|
46
|
+
].join('\n'),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const run = (input = '') =>
|
|
51
|
+
spawnSync('sh', [wrapper, 'sh', fake], { encoding: 'utf-8', input })
|
|
52
|
+
|
|
53
|
+
const attempts = () => Number(readFileSync(counter, 'utf-8').trim())
|
|
54
|
+
|
|
55
|
+
it('passes a clean exit 0 through without retrying', () => {
|
|
56
|
+
writeFake([0])
|
|
57
|
+
const r = run()
|
|
58
|
+
expect(r.status).toBe(0)
|
|
59
|
+
expect(attempts()).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('passes a real non-134 failure through unchanged (no retry, not masked)', () => {
|
|
63
|
+
writeFake([2])
|
|
64
|
+
const r = run()
|
|
65
|
+
expect(r.status).toBe(2)
|
|
66
|
+
expect(attempts()).toBe(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('retries ONCE on a 134 abort and succeeds on the second attempt', () => {
|
|
70
|
+
writeFake([134, 0])
|
|
71
|
+
const r = run()
|
|
72
|
+
expect(r.status).toBe(0)
|
|
73
|
+
expect(attempts()).toBe(2)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('skips cleanly (exit 0) when it aborts 134 twice — no crash card', () => {
|
|
77
|
+
writeFake([134, 134])
|
|
78
|
+
const r = run()
|
|
79
|
+
expect(r.status).toBe(0) // skipped cleanly, NOT 134
|
|
80
|
+
expect(attempts()).toBe(2)
|
|
81
|
+
expect(r.stderr).toMatch(/skipping hook cleanly/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('exports a shrunk UV_THREADPOOL_SIZE to the child', () => {
|
|
85
|
+
writeFileSync(fake, `#!/bin/sh\necho "$UV_THREADPOOL_SIZE"\nexit 0\n`)
|
|
86
|
+
const r = run()
|
|
87
|
+
expect(r.stdout.trim()).toBe('1')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('replays the SAME stdin payload on the retry (scanner never sees empty input)', () => {
|
|
91
|
+
// Fake: attempt 0 reads stdin then aborts 134; attempt 1 reads stdin and
|
|
92
|
+
// records it, then exits 0. If stdin were not preserved, the recorded
|
|
93
|
+
// payload on the retry would be empty.
|
|
94
|
+
const seen = join(dir, 'seen-stdin')
|
|
95
|
+
writeFileSync(
|
|
96
|
+
fake,
|
|
97
|
+
[
|
|
98
|
+
'#!/bin/sh',
|
|
99
|
+
`n=$(cat "${counter}")`,
|
|
100
|
+
`echo "$((n + 1))" > "${counter}"`,
|
|
101
|
+
'data=$(cat)', // drain stdin (the abort-after-read scenario)
|
|
102
|
+
`echo "$data" > "${seen}.$n"`,
|
|
103
|
+
'[ "$n" = "0" ] && exit 134',
|
|
104
|
+
'exit 0',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
)
|
|
107
|
+
const r = run('SECRET-PAYLOAD-123')
|
|
108
|
+
expect(r.status).toBe(0)
|
|
109
|
+
expect(attempts()).toBe(2)
|
|
110
|
+
// The RETRY (attempt 1) must have received the full payload, not empty.
|
|
111
|
+
expect(readFileSync(`${seen}.1`, 'utf-8').trim()).toBe('SECRET-PAYLOAD-123')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('FAILS CLOSED (propagates 134) for a security hook that aborts twice', () => {
|
|
115
|
+
// A genuinely broken secret scanner must NOT silently pass — exit 0 after
|
|
116
|
+
// two aborts would be a silent security bypass.
|
|
117
|
+
const secFake = join(dir, 'secret-guard-pretool.mjs')
|
|
118
|
+
writeFileSync(
|
|
119
|
+
secFake,
|
|
120
|
+
[
|
|
121
|
+
'#!/bin/sh',
|
|
122
|
+
`n=$(cat "${counter}")`,
|
|
123
|
+
`echo "$((n + 1))" > "${counter}"`,
|
|
124
|
+
'exit 134',
|
|
125
|
+
].join('\n'),
|
|
126
|
+
)
|
|
127
|
+
const r = spawnSync('sh', [wrapper, 'sh', secFake], { encoding: 'utf-8', input: '{}' })
|
|
128
|
+
expect(r.status).toBe(134) // fail closed — NOT skipped
|
|
129
|
+
expect(attempts()).toBe(2)
|
|
130
|
+
expect(r.stderr).toMatch(/FAILING CLOSED/)
|
|
131
|
+
})
|
|
132
|
+
})
|