switchroom 0.16.22 → 0.16.24
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/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2865 -48
- package/dist/cli/switchroom.js +338 -92
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- 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/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 +751 -624
- 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 +28 -0
- 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/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 +36 -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 +44 -2
- package/telegram-plugin/retry-api-call.ts +15 -7
- package/telegram-plugin/rich-send.ts +57 -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 +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- 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/issues-card.test.ts +15 -12
- 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/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -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.
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -139,7 +139,7 @@ describe('pending-work-progress', () => {
|
|
|
139
139
|
expect(cap.edits).toHaveLength(1)
|
|
140
140
|
expect(cap.edits[0].messageId).toBe(100)
|
|
141
141
|
expect(cap.edits[0].newText).toBe(
|
|
142
|
-
"Background sleep running; awaiting completion.\n\
|
|
142
|
+
"Background sleep running; awaiting completion.\n\n_still working (1m) · message me anytime, I'll keep you posted_",
|
|
143
143
|
)
|
|
144
144
|
|
|
145
145
|
// Tick at 3 intervals total — second edit, "3m".
|
|
@@ -148,7 +148,7 @@ describe('pending-work-progress', () => {
|
|
|
148
148
|
await flush()
|
|
149
149
|
expect(cap.edits).toHaveLength(2)
|
|
150
150
|
expect(cap.edits[1].newText).toBe(
|
|
151
|
-
"Background sleep running; awaiting completion.\n\
|
|
151
|
+
"Background sleep running; awaiting completion.\n\n_still working (3m) · message me anytime, I'll keep you posted_",
|
|
152
152
|
)
|
|
153
153
|
})
|
|
154
154
|
|
|
@@ -168,7 +168,7 @@ describe('pending-work-progress', () => {
|
|
|
168
168
|
await flush()
|
|
169
169
|
// The new edit should be based on 'worker dispatched' alone.
|
|
170
170
|
expect(cap.edits[0].newText).toBe(
|
|
171
|
-
"worker dispatched\n\
|
|
171
|
+
"worker dispatched\n\n_still working (1m) · message me anytime, I'll keep you posted_",
|
|
172
172
|
)
|
|
173
173
|
})
|
|
174
174
|
|
|
@@ -186,7 +186,7 @@ describe('pending-work-progress', () => {
|
|
|
186
186
|
__tickForTests(cap.now)
|
|
187
187
|
await flush()
|
|
188
188
|
expect(cap.edits[0].newText).toBe(
|
|
189
|
-
"worker dispatched\n\
|
|
189
|
+
"worker dispatched\n\n_still working (1m) · message me anytime, I'll keep you posted_",
|
|
190
190
|
)
|
|
191
191
|
})
|
|
192
192
|
|
|
@@ -336,17 +336,17 @@ describe('pending-work-progress', () => {
|
|
|
336
336
|
expect(cap.edits).toHaveLength(0)
|
|
337
337
|
})
|
|
338
338
|
|
|
339
|
-
// ─── #1698
|
|
340
|
-
|
|
339
|
+
// ─── #1698 successor — preserve the anchor's send shape (literalText) on
|
|
340
|
+
// every cross-turn edit (#2669 single-rich-path contract) ───
|
|
341
|
+
it("preserves the anchor's rich shape on every edit (default literalText=false)", async () => {
|
|
341
342
|
const cap = setup()
|
|
342
343
|
startTurn(KEY)
|
|
343
344
|
noteAsyncDispatch(KEY)
|
|
344
|
-
// Anchor was sent
|
|
345
|
-
//
|
|
345
|
+
// Anchor was sent via the rich-markdown path (the default), so the
|
|
346
|
+
// captured text is raw GFM markdown and the edit re-sends it richly.
|
|
346
347
|
noteOutbound(KEY, {
|
|
347
348
|
messageId: 100,
|
|
348
|
-
text: '
|
|
349
|
-
parseMode: 'HTML',
|
|
349
|
+
text: '**Worker back.** Both blockers fixed.',
|
|
350
350
|
})
|
|
351
351
|
cap.now = 0
|
|
352
352
|
noteTurnEnd(KEY)
|
|
@@ -354,42 +354,41 @@ describe('pending-work-progress', () => {
|
|
|
354
354
|
__tickForTests(cap.now)
|
|
355
355
|
await flush()
|
|
356
356
|
expect(cap.edits).toHaveLength(1)
|
|
357
|
-
expect(cap.edits[0].
|
|
357
|
+
expect(cap.edits[0].literalText).toBe(false)
|
|
358
358
|
expect(cap.edits[0].newText).toBe(
|
|
359
|
-
"
|
|
359
|
+
"**Worker back.** Both blockers fixed.\n\n_still working (1m) · message me anytime, I'll keep you posted_",
|
|
360
360
|
)
|
|
361
361
|
})
|
|
362
362
|
|
|
363
|
-
it('
|
|
363
|
+
it('threads literalText=true through when the anchor was a literal format:text send', async () => {
|
|
364
364
|
const cap = setup()
|
|
365
365
|
startTurn(KEY)
|
|
366
366
|
noteAsyncDispatch(KEY)
|
|
367
|
-
// format: 'text' path — anchor was
|
|
367
|
+
// format: 'text' path — the anchor was a literal plain send.
|
|
368
368
|
noteOutbound(KEY, {
|
|
369
369
|
messageId: 100,
|
|
370
370
|
text: 'plain text reply',
|
|
371
|
-
|
|
371
|
+
literalText: true,
|
|
372
372
|
})
|
|
373
373
|
noteTurnEnd(KEY)
|
|
374
374
|
cap.now = EDIT_INTERVAL_MS
|
|
375
375
|
__tickForTests(cap.now)
|
|
376
376
|
await flush()
|
|
377
|
-
expect(cap.edits[0].
|
|
377
|
+
expect(cap.edits[0].literalText).toBe(true)
|
|
378
378
|
})
|
|
379
379
|
|
|
380
|
-
it('defaults
|
|
380
|
+
it('defaults literalText to false when caller omits it (rich path, test ergonomics)', async () => {
|
|
381
381
|
const cap = setup()
|
|
382
382
|
startTurn(KEY)
|
|
383
383
|
noteAsyncDispatch(KEY)
|
|
384
|
-
// Callsite that
|
|
385
|
-
//
|
|
386
|
-
// matching the pre-#1698 behaviour for legacy callers.
|
|
384
|
+
// Callsite that omits the field — must not typecheck-fail nor crash.
|
|
385
|
+
// The edit goes out on the rich path (literalText=false).
|
|
387
386
|
noteOutbound(KEY, { messageId: 100, text: 'wd' })
|
|
388
387
|
noteTurnEnd(KEY)
|
|
389
388
|
cap.now = EDIT_INTERVAL_MS
|
|
390
389
|
__tickForTests(cap.now)
|
|
391
390
|
await flush()
|
|
392
|
-
expect(cap.edits[0].
|
|
391
|
+
expect(cap.edits[0].literalText).toBe(false)
|
|
393
392
|
})
|
|
394
393
|
|
|
395
394
|
it('multiple chats — independent state', async () => {
|