switchroom 0.16.23 → 0.16.27
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -62,10 +62,9 @@ function makeDeps(agentName: string | null) {
|
|
|
62
62
|
['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
|
|
63
63
|
['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
|
|
64
64
|
])
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
])
|
|
65
|
+
// #2669: the activeDraftParseModes map was retired with parse-mode rotation
|
|
66
|
+
// (single rich-markdown path now), so the disconnect-flush deps no longer
|
|
67
|
+
// carry or clear it.
|
|
69
68
|
|
|
70
69
|
return {
|
|
71
70
|
spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, log },
|
|
@@ -76,7 +75,6 @@ function makeDeps(agentName: string | null) {
|
|
|
76
75
|
activeTurnStartedAt,
|
|
77
76
|
claudeBusyKeys,
|
|
78
77
|
activeDraftStreams,
|
|
79
|
-
activeDraftParseModes,
|
|
80
78
|
clearActiveReactions,
|
|
81
79
|
disposeProgressDriver,
|
|
82
80
|
log,
|
|
@@ -103,7 +101,6 @@ describe('flushOnAgentDisconnect — anonymous clients (the regression scenario)
|
|
|
103
101
|
expect(deps.activeReactionMsgIds.size).toBe(2)
|
|
104
102
|
expect(deps.activeTurnStartedAt.size).toBe(2)
|
|
105
103
|
expect(deps.activeDraftStreams.size).toBe(2)
|
|
106
|
-
expect(deps.activeDraftParseModes.size).toBe(2)
|
|
107
104
|
|
|
108
105
|
// But it should log so the operator can correlate the no-op decision.
|
|
109
106
|
expect(spies.log).toHaveBeenCalledTimes(1)
|
|
@@ -150,7 +147,6 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
|
|
|
150
147
|
expect(spies.finalizeB).not.toHaveBeenCalled()
|
|
151
148
|
// Both stream maps cleared regardless.
|
|
152
149
|
expect(deps.activeDraftStreams.size).toBe(0)
|
|
153
|
-
expect(deps.activeDraftParseModes.size).toBe(0)
|
|
154
150
|
})
|
|
155
151
|
})
|
|
156
152
|
|
|
@@ -180,7 +176,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
180
176
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
181
177
|
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
182
178
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
183
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
184
179
|
clearActiveReactions,
|
|
185
180
|
disposeProgressDriver,
|
|
186
181
|
onDanglingTurnsSwept,
|
|
@@ -238,7 +233,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
238
233
|
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
239
234
|
claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
|
|
240
235
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
241
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
242
236
|
clearActiveReactions: vi.fn(),
|
|
243
237
|
disposeProgressDriver: vi.fn(),
|
|
244
238
|
onDanglingTurnsSwept,
|
|
@@ -273,7 +267,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
273
267
|
// handleInbound's fresh-turn branch.
|
|
274
268
|
claudeBusyKeys: new Set<string>(['cron-only-key:_']),
|
|
275
269
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
276
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
277
270
|
clearActiveReactions: vi.fn(),
|
|
278
271
|
disposeProgressDriver: vi.fn(),
|
|
279
272
|
onDanglingTurnsSwept,
|
|
@@ -306,7 +299,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
306
299
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
307
300
|
activeTurnStartedAt: new Map<string, number>(),
|
|
308
301
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
309
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
310
302
|
clearActiveReactions: vi.fn(),
|
|
311
303
|
disposeProgressDriver: vi.fn(),
|
|
312
304
|
}
|
|
@@ -342,7 +334,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
342
334
|
activeTurnStartedAt: new Map<string, number>(),
|
|
343
335
|
claudeBusyKeys: new Set<string>(),
|
|
344
336
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
345
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
346
337
|
clearActiveReactions: vi.fn(),
|
|
347
338
|
disposeProgressDriver: vi.fn(),
|
|
348
339
|
log,
|
|
@@ -362,7 +353,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
362
353
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
363
354
|
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
364
355
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
365
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
366
356
|
clearActiveReactions: vi.fn(),
|
|
367
357
|
disposeProgressDriver: vi.fn(),
|
|
368
358
|
// onDanglingTurnsSwept intentionally omitted.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, it, expect } from "vitest";
|
|
9
9
|
import { validateClientMessage } from "../gateway/ipc-server.js";
|
|
10
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
10
11
|
|
|
11
12
|
const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
|
|
12
13
|
|
|
@@ -38,8 +39,11 @@ describe("validateClientMessage — send_outbound", () => {
|
|
|
38
39
|
expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
|
|
39
40
|
expect(validateClientMessage({ ...base, text: "" })).toBe(false);
|
|
40
41
|
expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
// send_outbound posts via sendRichMessage (rich path), so the cap is the
|
|
43
|
+
// rich-message wire limit (RICH_MESSAGE_MAX_CHARS, 32768) post-#2669, not
|
|
44
|
+
// the legacy 4096 plain-text limit.
|
|
45
|
+
expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS) })).toBe(true); // at the cap
|
|
46
|
+
expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) })).toBe(false); // over the cap
|
|
43
47
|
});
|
|
44
48
|
|
|
45
49
|
it("rejects a non-integer threadId", () => {
|
|
@@ -40,7 +40,7 @@ describe("renderIssuesCard", () => {
|
|
|
40
40
|
makeEvent({ fingerprint: "a::3", code: "3", severity: "info" }),
|
|
41
41
|
];
|
|
42
42
|
const out = renderIssuesCard({ agentName: "klanker", events });
|
|
43
|
-
expect(out).toMatch(/^🚨
|
|
43
|
+
expect(out).toMatch(/^🚨 \*\*klanker\*\*/);
|
|
44
44
|
expect(out).toContain("3 issues");
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -54,7 +54,7 @@ describe("renderIssuesCard", () => {
|
|
|
54
54
|
}),
|
|
55
55
|
];
|
|
56
56
|
const out = renderIssuesCard({ agentName: "kengpt", events });
|
|
57
|
-
expect(out).toContain("→
|
|
57
|
+
expect(out).toContain("→ _switchroom vault unlock_");
|
|
58
58
|
// Summary still rendered above the remediation.
|
|
59
59
|
expect(out).toContain("Morning brief skipped");
|
|
60
60
|
});
|
|
@@ -66,7 +66,7 @@ describe("renderIssuesCard", () => {
|
|
|
66
66
|
}),
|
|
67
67
|
];
|
|
68
68
|
const out = renderIssuesCard({ agentName: "kengpt", events });
|
|
69
|
-
expect(out).toMatch(/→
|
|
69
|
+
expect(out).toMatch(/→ _run.*auth heal/);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("ignores multi-line detail (likely stderr tail, not remediation)", () => {
|
|
@@ -98,13 +98,12 @@ describe("renderIssuesCard", () => {
|
|
|
98
98
|
expect((out ?? "").split("\n").every((line) => line.length < 600)).toBe(true);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
it("
|
|
101
|
+
it("passes < > literally in remediation hints (markdown, #2669)", () => {
|
|
102
102
|
const events = [
|
|
103
103
|
makeEvent({ detail: "Fix: <script>alert('xss')</script>" }),
|
|
104
104
|
];
|
|
105
105
|
const out = renderIssuesCard({ agentName: "kengpt", events });
|
|
106
|
-
expect(out).
|
|
107
|
-
expect(out).toContain("<script>");
|
|
106
|
+
expect(out).toContain("<script>alert('xss')</script>");
|
|
108
107
|
});
|
|
109
108
|
});
|
|
110
109
|
|
|
@@ -158,7 +157,7 @@ describe("renderIssuesCard", () => {
|
|
|
158
157
|
expect(out).toContain("+10 more not shown");
|
|
159
158
|
});
|
|
160
159
|
|
|
161
|
-
it("
|
|
160
|
+
it("passes < > & literally in user-supplied fields (markdown, #2669)", () => {
|
|
162
161
|
const events = [
|
|
163
162
|
makeEvent({
|
|
164
163
|
agent: "<script>",
|
|
@@ -169,10 +168,12 @@ describe("renderIssuesCard", () => {
|
|
|
169
168
|
}),
|
|
170
169
|
];
|
|
171
170
|
const out = renderIssuesCard({ agentName: "<script>", events });
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
expect(out).toContain("
|
|
171
|
+
// < > & are literal in rich markdown — no HTML entities. The fingerprint
|
|
172
|
+
// rides in a `code span` (also literal) and the summary is escaped only
|
|
173
|
+
// for emphasis specials.
|
|
174
|
+
expect(out).toContain("<script>");
|
|
175
|
+
expect(out).toContain("`<x>::<y>`");
|
|
176
|
+
expect(out).toContain("<dangerous & stuff>");
|
|
176
177
|
});
|
|
177
178
|
});
|
|
178
179
|
|
|
@@ -233,7 +234,9 @@ describe("createIssuesCardHandle", () => {
|
|
|
233
234
|
});
|
|
234
235
|
await handle.refresh([makeEvent({})]);
|
|
235
236
|
expect(bot.sent).toHaveLength(1);
|
|
236
|
-
|
|
237
|
+
// #2669: the card body is raw GFM markdown sent through the rich path
|
|
238
|
+
// (the gateway wires sendMessage → sendRichMessage). No parse_mode.
|
|
239
|
+
expect(bot.sent[0].opts?.parse_mode).toBeUndefined();
|
|
237
240
|
expect(handle.messageId()).toBe(1000);
|
|
238
241
|
});
|
|
239
242
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the over-length error classification (task E of the Telegram-
|
|
3
|
+
* formatting bundle) and the hardSliceToCap last-resort slicer.
|
|
4
|
+
*
|
|
5
|
+
* The rich path rejects a 32769+ char body with `RICH_MESSAGE_TEXT_TOO_LONG`
|
|
6
|
+
* (and the legacy plain path with `MESSAGE_TOO_LONG`). That is a LENGTH error,
|
|
7
|
+
* not a markdown-parse error — it must be classified so the caller re-splits
|
|
8
|
+
* the body rather than (a) misclassifying it as a parse-reject and resending
|
|
9
|
+
* the same oversized payload as plain text, or (b) surfacing the raw 400.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from 'vitest'
|
|
12
|
+
import { GrammyError } from 'grammy'
|
|
13
|
+
import { isLengthError, isParseEntitiesError } from '../rich-send.js'
|
|
14
|
+
import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
|
|
15
|
+
import { hardSliceToCap, splitMarkdownChunks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
16
|
+
|
|
17
|
+
// Build a GrammyError with a given 400 description. GrammyError's constructor
|
|
18
|
+
// takes (message, payload, method, parameters) in grammy 1.44.
|
|
19
|
+
function grammy400(description: string): GrammyError {
|
|
20
|
+
return new GrammyError(
|
|
21
|
+
`Call to 'sendRichMessage' failed! (400: ${description})`,
|
|
22
|
+
{ ok: false, error_code: 400, description },
|
|
23
|
+
'sendRichMessage',
|
|
24
|
+
{},
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('length-error classification (rich-send.isLengthError)', () => {
|
|
29
|
+
test('RICH_MESSAGE_TEXT_TOO_LONG is a length error', () => {
|
|
30
|
+
const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
|
|
31
|
+
expect(isLengthError(err)).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('legacy MESSAGE_TOO_LONG / "message is too long" are length errors', () => {
|
|
35
|
+
expect(isLengthError(grammy400('MESSAGE_TOO_LONG'))).toBe(true)
|
|
36
|
+
expect(isLengthError(grammy400('Bad Request: message is too long'))).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('a parse-entities error is NOT a length error', () => {
|
|
40
|
+
expect(isLengthError(grammy400("can't parse entities: bad offset"))).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('a length error is NOT misclassified as a parse-entities error', () => {
|
|
44
|
+
const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
|
|
45
|
+
expect(isParseEntitiesError(err)).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('non-GrammyError and non-400 are neither', () => {
|
|
49
|
+
expect(isLengthError(new Error('boom'))).toBe(false)
|
|
50
|
+
const err403 = new GrammyError(
|
|
51
|
+
'forbidden',
|
|
52
|
+
{ ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
|
|
53
|
+
'sendRichMessage',
|
|
54
|
+
{},
|
|
55
|
+
)
|
|
56
|
+
expect(isLengthError(err403)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('length-error classification (retry-api-call.isMessageTooLongError)', () => {
|
|
61
|
+
test('RICH_MESSAGE_TEXT_TOO_LONG is a length error', () => {
|
|
62
|
+
expect(isMessageTooLongError(grammy400('RICH_MESSAGE_TEXT_TOO_LONG'))).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a length error is NOT misclassified as an html-parse-reject', () => {
|
|
66
|
+
const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
|
|
67
|
+
expect(isHtmlParseRejectError(err)).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('a parse-reject error still classifies as a parse-reject (not length)', () => {
|
|
71
|
+
const err = grammy400("can't parse entities: unexpected end")
|
|
72
|
+
expect(isHtmlParseRejectError(err)).toBe(true)
|
|
73
|
+
expect(isMessageTooLongError(err)).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('hardSliceToCap', () => {
|
|
78
|
+
test('returns the input as one piece when it already fits', () => {
|
|
79
|
+
expect(hardSliceToCap('short', 100)).toEqual(['short'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('slices an oversized body into pieces each <= cap', () => {
|
|
83
|
+
const body = 'x'.repeat(250)
|
|
84
|
+
const pieces = hardSliceToCap(body, 100)
|
|
85
|
+
expect(pieces).toHaveLength(3)
|
|
86
|
+
for (const p of pieces) expect(p.length).toBeLessThanOrEqual(100)
|
|
87
|
+
expect(pieces.join('')).toBe(body)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('defaults the cap to RICH_MESSAGE_MAX_CHARS', () => {
|
|
91
|
+
const body = 'y'.repeat(RICH_MESSAGE_MAX_CHARS + 5)
|
|
92
|
+
const pieces = hardSliceToCap(body)
|
|
93
|
+
expect(pieces).toHaveLength(2)
|
|
94
|
+
expect(pieces[0].length).toBe(RICH_MESSAGE_MAX_CHARS)
|
|
95
|
+
expect(pieces[1].length).toBe(5)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// The gateway length-error recovery (gateway.ts sendChunkResplit, ~8758) re-splits
|
|
100
|
+
// an oversized chunk with `splitMarkdownChunks` and falls back to `hardSliceToCap`
|
|
101
|
+
// when the block is indivisible. These tests pin that seam at the function level:
|
|
102
|
+
// a >RICH_MESSAGE_MAX_CHARS body is ALWAYS chunked into >=2 sends (each <= cap),
|
|
103
|
+
// never silently dropped or resent whole on a RICH_MESSAGE_TEXT_TOO_LONG reject.
|
|
104
|
+
describe('resplit-on-reject: oversized body chunks into >=2 sends', () => {
|
|
105
|
+
test('a >cap body with prose boundaries re-splits into >=2 chunks each <= cap', () => {
|
|
106
|
+
// A paragraph-separated body twice the cap: splitMarkdownChunks finds the
|
|
107
|
+
// `\n\n` boundaries and yields multiple chunks.
|
|
108
|
+
const para = 'x'.repeat(4000)
|
|
109
|
+
const body = Array.from({ length: 20 }, () => para).join('\n\n')
|
|
110
|
+
expect(body.length).toBeGreaterThan(RICH_MESSAGE_MAX_CHARS)
|
|
111
|
+
const pieces = splitMarkdownChunks(body, RICH_MESSAGE_MAX_CHARS)
|
|
112
|
+
expect(pieces.length).toBeGreaterThanOrEqual(2)
|
|
113
|
+
for (const p of pieces) expect(p.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('an indivisible >cap blob falls back through hardSliceToCap to >=2 chunks each <= cap', () => {
|
|
117
|
+
// A single boundary-free blob larger than the cap: splitMarkdownChunks
|
|
118
|
+
// cannot break it and emits it whole (length > cap), so the recovery path
|
|
119
|
+
// hands it to hardSliceToCap — mirroring sendChunkResplit's fallback.
|
|
120
|
+
const blob = 'z'.repeat(RICH_MESSAGE_MAX_CHARS + 5000)
|
|
121
|
+
const subPieces = splitMarkdownChunks(blob, RICH_MESSAGE_MAX_CHARS)
|
|
122
|
+
const pieces =
|
|
123
|
+
subPieces.length > 1 && subPieces.every((p) => p.length <= RICH_MESSAGE_MAX_CHARS)
|
|
124
|
+
? subPieces
|
|
125
|
+
: hardSliceToCap(blob, RICH_MESSAGE_MAX_CHARS)
|
|
126
|
+
expect(pieces.length).toBeGreaterThanOrEqual(2)
|
|
127
|
+
for (const p of pieces) expect(p.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
|
|
128
|
+
// Nothing is dropped — the pieces reconstitute the original body.
|
|
129
|
+
expect(pieces.join('')).toBe(blob)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -247,7 +247,7 @@ describe('emitLinearAgentActivity — operator alert when auth is unrecoverable
|
|
|
247
247
|
it('buildLinearAuthDeadMessage: no_bundle names the missing oauth key + re-auth command', () => {
|
|
248
248
|
const msg = buildLinearAuthDeadMessage('clerk', 'no_bundle')
|
|
249
249
|
expect(msg).toMatch(/Linear auth needs you/)
|
|
250
|
-
expect(msg).toContain('
|
|
250
|
+
expect(msg).toContain('`linear/clerk/oauth`')
|
|
251
251
|
expect(msg).toContain('switchroom linear-agent setup --agent clerk')
|
|
252
252
|
})
|
|
253
253
|
|
|
@@ -257,10 +257,13 @@ describe('emitLinearAgentActivity — operator alert when auth is unrecoverable
|
|
|
257
257
|
expect(msg).not.toContain('/oauth</code> is missing')
|
|
258
258
|
})
|
|
259
259
|
|
|
260
|
-
it('buildLinearAuthDeadMessage:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
260
|
+
it('buildLinearAuthDeadMessage: markdown-escapes a hostile agent slug in prose (#2669)', () => {
|
|
261
|
+
// < > & are literal in rich markdown; an emphasis special is escaped so it
|
|
262
|
+
// can't break out of the **…** bold run.
|
|
263
|
+
const msg = buildLinearAuthDeadMessage('a_b*c', 'no_bundle')
|
|
264
|
+
expect(msg).toContain('**a\\_b\\*c**')
|
|
265
|
+
// Inside the `code span`, the slug is literal (no escaping).
|
|
266
|
+
expect(msg).toContain('`linear/a_b*c/oauth`')
|
|
264
267
|
})
|
|
265
268
|
|
|
266
269
|
it('successful auto-refresh does NOT page the operator', async () => {
|
|
@@ -172,7 +172,7 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
|
|
|
172
172
|
const { deps, calls } = makeDeps({ getConfiguredModel: () => null });
|
|
173
173
|
const reply = await handleModelCommand({ kind: "show" }, deps);
|
|
174
174
|
expect(calls.length).toBe(0);
|
|
175
|
-
expect(reply.text).toContain("
|
|
175
|
+
expect(reply.text).toContain("`default`");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
it("help never injects", async () => {
|
|
@@ -445,7 +445,7 @@ describe("buildModelMenu", () => {
|
|
|
445
445
|
const { deps, calls } = makeMenuDeps();
|
|
446
446
|
const menu = await buildModelMenu(deps);
|
|
447
447
|
expect(calls.discover).toBe(1);
|
|
448
|
-
expect(menu.text).toContain("
|
|
448
|
+
expect(menu.text).toContain("**Sonnet**");
|
|
449
449
|
expect(menu.text).toContain("29% / 5h · 33% / 7d");
|
|
450
450
|
expect(menu.keyboard).toBeDefined();
|
|
451
451
|
// 3 option rows + refresh row
|
|
@@ -288,13 +288,13 @@ describe('formatModelUnavailableCard — actionable card', () => {
|
|
|
288
288
|
{ now: NOW },
|
|
289
289
|
)
|
|
290
290
|
expect(card).toMatchInlineSnapshot(`
|
|
291
|
-
"⚠️
|
|
291
|
+
"⚠️ **Model unavailable** on agent **gymbro**
|
|
292
292
|
Reason: quota exhausted (resets in 5h)
|
|
293
293
|
|
|
294
|
-
|
|
295
|
-
•
|
|
296
|
-
•
|
|
297
|
-
•
|
|
294
|
+
**What to try**
|
|
295
|
+
• \`/auth use <label>\` — switch the fleet to a healthy account
|
|
296
|
+
• \`/auth add\` — attach another subscription
|
|
297
|
+
• \`/usage\` — show quota breakdown"
|
|
298
298
|
`)
|
|
299
299
|
})
|
|
300
300
|
|
|
@@ -330,9 +330,9 @@ describe('formatModelUnavailableCard — actionable card', () => {
|
|
|
330
330
|
|
|
331
331
|
it('default (no autoFallback) variant includes the actionable suggestions', () => {
|
|
332
332
|
const card = formatModelUnavailableCard(detection('quota_exhausted'), 'gymbro', { now: NOW })
|
|
333
|
-
expect(card).toContain('
|
|
334
|
-
expect(card).toContain('
|
|
335
|
-
expect(card).toContain('
|
|
333
|
+
expect(card).toContain('`/auth use')
|
|
334
|
+
expect(card).toContain('`/auth add`')
|
|
335
|
+
expect(card).toContain('`/usage`')
|
|
336
336
|
// Regression — `/authfallback` is no longer a verb (post-RFC-H);
|
|
337
337
|
// pre-fix the card lied by suggesting it.
|
|
338
338
|
expect(card).not.toContain('/authfallback')
|
|
@@ -343,17 +343,17 @@ describe('formatModelUnavailableCard — actionable card', () => {
|
|
|
343
343
|
slot: 'pro-1',
|
|
344
344
|
now: NOW,
|
|
345
345
|
})
|
|
346
|
-
expect(card).toContain('slot
|
|
346
|
+
expect(card).toContain('slot **pro-1**')
|
|
347
347
|
})
|
|
348
348
|
|
|
349
|
-
it('
|
|
349
|
+
it('passes < > literally in agent and slot names (markdown, #2669)', () => {
|
|
350
|
+
// < > " are literal in rich markdown; emphasis specials are escaped.
|
|
350
351
|
const card = formatModelUnavailableCard(detection('overload'), '<evil>', {
|
|
351
352
|
slot: '"injected"',
|
|
352
353
|
now: NOW,
|
|
353
354
|
})
|
|
354
|
-
expect(card).toContain('
|
|
355
|
-
expect(card).toContain('
|
|
356
|
-
expect(card).not.toContain('<evil>')
|
|
355
|
+
expect(card).toContain('<evil>')
|
|
356
|
+
expect(card).toContain('"injected"')
|
|
357
357
|
})
|
|
358
358
|
})
|
|
359
359
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Integration tests for multi-turn state isolation.
|
|
3
3
|
*
|
|
4
4
|
* Verifies that state carried across turns in the same chat+thread
|
|
5
|
-
* (activeDraftStreams,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* (activeDraftStreams, suppressPtyPreview) resets cleanly when a new turn
|
|
6
|
+
* starts — i.e. the previous turn's stream handle is finalized/discarded
|
|
7
|
+
* before the next begins. (The activeDraftParseModes map was retired with
|
|
8
|
+
* parse-mode rotation — there is one rich-markdown path now, #2669.)
|
|
8
9
|
*
|
|
9
10
|
* This is one of the "per-chat continuity" gaps the plan called out:
|
|
10
11
|
* previously tested only through the full server.ts orchestration in
|
|
@@ -14,13 +15,11 @@
|
|
|
14
15
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
15
16
|
import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
|
|
16
17
|
import type { DraftStreamHandle } from '../draft-stream.js'
|
|
17
|
-
import { markdownToHtml as realMarkdownToHtml } from '../format.js'
|
|
18
18
|
import { createFakeBotApi, type FakeBot } from './fake-bot-api.js'
|
|
19
19
|
|
|
20
20
|
function makeState(): StreamReplyState {
|
|
21
21
|
return {
|
|
22
22
|
activeDraftStreams: new Map<string, DraftStreamHandle>(),
|
|
23
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
24
23
|
suppressPtyPreview: new Set<string>(),
|
|
25
24
|
}
|
|
26
25
|
}
|
|
@@ -28,13 +27,11 @@ function makeState(): StreamReplyState {
|
|
|
28
27
|
function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamReplyDeps {
|
|
29
28
|
return {
|
|
30
29
|
bot: bot as unknown as StreamReplyDeps['bot'],
|
|
31
|
-
markdownToHtml: (t) => realMarkdownToHtml(t),
|
|
32
|
-
escapeMarkdownV2: (t) => t,
|
|
33
30
|
repairEscapedWhitespace: (t) => t,
|
|
34
31
|
assertAllowedChat: () => {},
|
|
35
32
|
resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
36
33
|
disableLinkPreview: true,
|
|
37
|
-
defaultFormat: '
|
|
34
|
+
defaultFormat: 'markdown',
|
|
38
35
|
logStreamingEvent: () => {},
|
|
39
36
|
historyEnabled: false,
|
|
40
37
|
recordOutbound: () => {},
|
|
@@ -51,13 +48,12 @@ describe('multi-turn continuity', () => {
|
|
|
51
48
|
bot = createFakeBotApi({ startMessageId: 500 })
|
|
52
49
|
})
|
|
53
50
|
|
|
54
|
-
it('done=true clears the stream entry
|
|
51
|
+
it('done=true clears the stream entry', async () => {
|
|
55
52
|
const state = makeState()
|
|
56
53
|
const deps = makeDeps(bot)
|
|
57
54
|
|
|
58
55
|
await handleStreamReply({ chat_id: 'c', text: 'turn-1', done: true }, state, deps)
|
|
59
56
|
expect(state.activeDraftStreams.has('c:_')).toBe(false)
|
|
60
|
-
expect(state.activeDraftParseModes.has('c:_')).toBe(false)
|
|
61
57
|
})
|
|
62
58
|
|
|
63
59
|
it('two sequential turns on the same chat each get their own message', async () => {
|
|
@@ -158,7 +158,7 @@ describe('renderOperatorEvent — credentials-expired', () => {
|
|
|
158
158
|
it('produces reauth button', () => {
|
|
159
159
|
const { text, keyboard } = renderOperatorEvent(makeEvent('credentials-expired'))
|
|
160
160
|
expect(text).toContain('expired')
|
|
161
|
-
expect(text).toContain('
|
|
161
|
+
expect(text).toContain('**gymbro**')
|
|
162
162
|
expect(keyboard.inline_keyboard.flat().some(b => b.callback_data?.includes('reauth'))).toBe(true)
|
|
163
163
|
})
|
|
164
164
|
|
|
@@ -193,7 +193,7 @@ describe('renderOperatorEvent — quota-exhausted', () => {
|
|
|
193
193
|
it('renders quota text with /auth use hint and no stub buttons (E5)', () => {
|
|
194
194
|
const { text, keyboard } = renderOperatorEvent(makeEvent('quota-exhausted'))
|
|
195
195
|
expect(text).toContain('Quota exhausted')
|
|
196
|
-
expect(text).toContain('
|
|
196
|
+
expect(text).toContain('**gymbro**')
|
|
197
197
|
expect(text).toContain('/auth use')
|
|
198
198
|
const buttons = keyboard.inline_keyboard.flat()
|
|
199
199
|
// swap-slot and add-slot buttons removed (E5 — they redirected to terminal)
|
|
@@ -253,17 +253,15 @@ describe('renderOperatorEvent — unknown-5xx', () => {
|
|
|
253
253
|
})
|
|
254
254
|
})
|
|
255
255
|
|
|
256
|
-
describe('renderOperatorEvent —
|
|
257
|
-
it('
|
|
256
|
+
describe('renderOperatorEvent — markdown escaping (#2669)', () => {
|
|
257
|
+
it('passes < > literally in agent name (markdown, #2669)', () => {
|
|
258
258
|
const { text } = renderOperatorEvent(makeEvent('unknown-4xx', { agent: '<evil>' }))
|
|
259
|
-
expect(text).toContain('
|
|
260
|
-
expect(text).not.toContain('<evil>')
|
|
259
|
+
expect(text).toContain('<evil>')
|
|
261
260
|
})
|
|
262
261
|
|
|
263
|
-
it('
|
|
262
|
+
it('passes < > literally in detail (markdown, #2669)', () => {
|
|
264
263
|
const { text } = renderOperatorEvent(makeEvent('credentials-expired', { detail: '<script>alert(1)</script>' }))
|
|
265
|
-
expect(text).toContain('
|
|
266
|
-
expect(text).not.toContain('<script>')
|
|
264
|
+
expect(text).toContain('<script>alert(1)</script>')
|
|
267
265
|
})
|
|
268
266
|
})
|
|
269
267
|
|