switchroom 0.16.29 → 0.16.46
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 +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -84,10 +84,10 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
84
84
|
"**→ Reading gateway.ts**",
|
|
85
85
|
);
|
|
86
86
|
expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
|
|
87
|
-
"_✓ Reading gateway.ts_\n**→ Searching memory**",
|
|
87
|
+
"~~_✓ Reading gateway.ts_~~ \n**→ Searching memory**",
|
|
88
88
|
);
|
|
89
89
|
expect(appendActivityLine(lines, "Bash", { command: "ls", description: "List workspace" })).toBe(
|
|
90
|
-
"_✓ Reading gateway.ts_\
|
|
90
|
+
"~~_✓ Reading gateway.ts_~~ \n~~_✓ Searching memory_~~ \n**→ List workspace**",
|
|
91
91
|
);
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -110,14 +110,14 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
110
110
|
const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
|
|
111
111
|
const out = renderActivityFeed(lines)!;
|
|
112
112
|
const hidden = total - STATUS_ROLLING_LINES;
|
|
113
|
-
expect(out.startsWith(`_✓ +${hidden} earlier…_\n`)).toBe(true);
|
|
113
|
+
expect(out.startsWith(`_✓ +${hidden} earlier…_ \n`)).toBe(true);
|
|
114
114
|
// Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
|
|
115
115
|
const firstVisible = total - STATUS_ROLLING_LINES + 1;
|
|
116
|
-
expect(out).toContain(
|
|
116
|
+
expect(out).toContain(`~~_✓ Action ${firstVisible}_~~`);
|
|
117
117
|
expect(out).not.toContain(`Action ${firstVisible - 1}<`);
|
|
118
118
|
// The newest action is the in-progress step (bold →); the rest are done (✓).
|
|
119
119
|
expect(out).toContain(`**→ Action ${total}**`);
|
|
120
|
-
expect(out).toContain(
|
|
120
|
+
expect(out).toContain(`~~_✓ Action ${total - 1}_~~`);
|
|
121
121
|
expect(out).not.toContain(`**→ Action ${total - 1}**`);
|
|
122
122
|
});
|
|
123
123
|
|
|
@@ -145,26 +145,46 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
145
145
|
const lines = ["Reading a.ts", "Searching memory", "Running a command"];
|
|
146
146
|
const out = renderActivityFeed(lines, true)!;
|
|
147
147
|
expect(out).toBe(
|
|
148
|
-
"_✓ Reading a.ts_\
|
|
148
|
+
"~~_✓ Reading a.ts_~~ \n~~_✓ Searching memory_~~ \n~~_✓ Running a command_~~",
|
|
149
149
|
);
|
|
150
150
|
expect(out).not.toContain("→"); // no in-progress arrow anywhere
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
it("final=true on a single line is also done (✓)", () => {
|
|
154
|
-
expect(renderActivityFeed(["Reading a.ts"], true)).toBe("_✓ Reading a.ts_");
|
|
154
|
+
expect(renderActivityFeed(["Reading a.ts"], true)).toBe("~~_✓ Reading a.ts_~~");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it("final defaults false (live render keeps the → in-progress newest line)", () => {
|
|
158
158
|
expect(renderActivityFeed(["Reading a.ts"])).toBe("**→ Reading a.ts**");
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
// Strikethrough contract (operator request): done (✓) step lines render with
|
|
162
|
+
// GFM strikethrough wrapping the italic so completed items visually recede,
|
|
163
|
+
// while the active (→ bold) line is never struck.
|
|
164
|
+
it("done step lines are struck (~~) AND italic; the active → line is not struck", () => {
|
|
165
|
+
const out = renderActivityFeed(["Reading a.ts", "Searching memory"])!;
|
|
166
|
+
// earlier step is done → struck italic
|
|
167
|
+
expect(out).toContain("~~_✓ Reading a.ts_~~");
|
|
168
|
+
// newest step is active → bold, NOT struck
|
|
169
|
+
expect(out).toContain("**→ Searching memory**");
|
|
170
|
+
expect(out).not.toContain("~~_✓ Searching memory_~~");
|
|
171
|
+
expect(out).not.toContain("~~**→");
|
|
172
|
+
// the meta/footer count lines stay UNSTRUCK (kept legible as meta)
|
|
173
|
+
const overflow = Array.from({ length: STATUS_ROLLING_LINES + 2 }, (_, i) => `Action ${i + 1}`);
|
|
174
|
+
const finalOut = renderActivityFeed(overflow, true, "", overflow.length)!;
|
|
175
|
+
expect(finalOut).toContain(`_✓ +${overflow.length - STATUS_ROLLING_LINES} earlier…_`);
|
|
176
|
+
expect(finalOut).toContain(`_✓ ${overflow.length} steps_`);
|
|
177
|
+
expect(finalOut).not.toContain(`~~_✓ +`);
|
|
178
|
+
expect(finalOut).not.toContain(`~~_✓ ${overflow.length} steps_~~`);
|
|
179
|
+
});
|
|
180
|
+
|
|
161
181
|
// liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
|
|
162
182
|
// long single step visibly advances ("→ Pulling Meta data · 18s") even though
|
|
163
183
|
// the feed is pull-only and no new tool label arrived.
|
|
164
184
|
describe("liveSuffix (heartbeat)", () => {
|
|
165
185
|
it("appends the suffix to the newest in-progress line only", () => {
|
|
166
186
|
expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " · 18s")).toBe(
|
|
167
|
-
"_✓ Reading a.ts_\n**→ Running a command · 18s**",
|
|
187
|
+
"~~_✓ Reading a.ts_~~ \n**→ Running a command · 18s**",
|
|
168
188
|
);
|
|
169
189
|
});
|
|
170
190
|
it("single live line gets the suffix", () => {
|
|
@@ -176,7 +196,7 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
176
196
|
const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " · 18s")!;
|
|
177
197
|
expect(out).not.toContain("·");
|
|
178
198
|
expect(out).not.toContain("→");
|
|
179
|
-
expect(out).toBe("_✓ Reading a.ts_\
|
|
199
|
+
expect(out).toBe("~~_✓ Reading a.ts_~~ \n~~_✓ Running a command_~~");
|
|
180
200
|
});
|
|
181
201
|
it("default empty suffix is byte-identical to no suffix", () => {
|
|
182
202
|
expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
|
|
@@ -191,7 +211,7 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
|
|
|
191
211
|
const lines: string[] = [];
|
|
192
212
|
expect(appendActivityLabel(lines, "Searching memory")).toBe("**→ Searching memory**");
|
|
193
213
|
expect(appendActivityLabel(lines, "List workspace")).toBe(
|
|
194
|
-
"_✓ Searching memory_\n**→ List workspace**",
|
|
214
|
+
"~~_✓ Searching memory_~~ \n**→ List workspace**",
|
|
195
215
|
);
|
|
196
216
|
// consecutive dup collapses
|
|
197
217
|
appendActivityLabel(lines, "List workspace");
|
|
@@ -221,10 +241,15 @@ describe("clipNarrative — narrative-step clip (JSONL-text-narrative primitive)
|
|
|
221
241
|
const narrativeLines: string[] = [];
|
|
222
242
|
const labelLines: string[] = [];
|
|
223
243
|
const text = "Important find: no main branch yet — branching off origin.";
|
|
244
|
+
// F1: the feed renders through renderStatusCard → stripMarkdown, which now
|
|
245
|
+
// normalizes em/en-dashes on this card surface. A narrative and a label go
|
|
246
|
+
// through the exact same path, so the render is still byte-identical — the
|
|
247
|
+
// dash is scrubbed to a full stop (new sentence, recapitalized) in BOTH.
|
|
248
|
+
const scrubbed = "Important find: no main branch yet. Branching off origin.";
|
|
224
249
|
const narrativeRender = appendActivityLabel(narrativeLines, clipNarrative(text));
|
|
225
250
|
const labelRender = appendActivityLabel(labelLines, clipNarrative(text));
|
|
226
251
|
expect(narrativeRender).toBe(labelRender);
|
|
227
|
-
expect(narrativeRender).toBe(`**→ ${
|
|
252
|
+
expect(narrativeRender).toBe(`**→ ${scrubbed}**`);
|
|
228
253
|
});
|
|
229
254
|
|
|
230
255
|
it("empty string yields an empty clip (appendActivityLabel then drops it)", () => {
|
|
@@ -238,7 +263,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
|
|
|
238
263
|
const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
|
|
239
264
|
const out = renderActivityFeed(lines, true, "", 5)!;
|
|
240
265
|
// All lines are done (✓) and the footer is appended.
|
|
241
|
-
expect(out).toContain("_✓ Running a command_");
|
|
266
|
+
expect(out).toContain("~~_✓ Running a command_~~");
|
|
242
267
|
expect(out).toContain("_✓ 5 steps_");
|
|
243
268
|
expect(out.endsWith("_✓ 5 steps_")).toBe(true);
|
|
244
269
|
});
|
|
@@ -247,7 +272,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
|
|
|
247
272
|
const lines = ["Reading CLAUDE.md"];
|
|
248
273
|
const out = renderActivityFeed(lines, true, "", 0)!;
|
|
249
274
|
expect(out).not.toContain("steps");
|
|
250
|
-
expect(out).toBe("_✓ Reading CLAUDE.md_");
|
|
275
|
+
expect(out).toBe("~~_✓ Reading CLAUDE.md_~~");
|
|
251
276
|
});
|
|
252
277
|
|
|
253
278
|
it("stepCount undefined → no footer (live/non-final callers omit it)", () => {
|
|
@@ -307,11 +332,11 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
307
332
|
const child = ["Reading schema.ts", "Looking for foreign keys"];
|
|
308
333
|
const out = renderActivityFeedWithNested(parent, child)!;
|
|
309
334
|
// Parent is blocked at the Task tool → none of its lines is the live step.
|
|
310
|
-
expect(out).toContain("_✓ Searching memory_");
|
|
311
|
-
expect(out).toContain("_✓ Delegating: review the migration_");
|
|
335
|
+
expect(out).toContain("~~_✓ Searching memory_~~");
|
|
336
|
+
expect(out).toContain("~~_✓ Delegating: review the migration_~~");
|
|
312
337
|
expect(out).not.toContain("**→ Delegating");
|
|
313
338
|
// The live → step is the newest nested child line; earlier child = italic.
|
|
314
|
-
expect(out).toContain(" ↳ _Reading schema.ts_");
|
|
339
|
+
expect(out).toContain(" ↳ ~~_Reading schema.ts_~~");
|
|
315
340
|
expect(out).toContain(" ↳ **→ Looking for foreign keys**");
|
|
316
341
|
});
|
|
317
342
|
|
|
@@ -342,13 +367,13 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
342
367
|
["Reading schema.ts", "Looking for foreign keys"],
|
|
343
368
|
true,
|
|
344
369
|
)!;
|
|
345
|
-
expect(out).toContain(" ↳ _Looking for foreign keys_"); // newest now italic done
|
|
370
|
+
expect(out).toContain(" ↳ ~~_Looking for foreign keys_~~"); // newest now struck italic done
|
|
346
371
|
expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
|
|
347
372
|
});
|
|
348
373
|
|
|
349
374
|
it("final=true with no children delegates to the finalized flat render", () => {
|
|
350
375
|
expect(renderActivityFeedWithNested(["Reading a.ts"], [], true)).toBe(
|
|
351
|
-
"_✓ Reading a.ts_",
|
|
376
|
+
"~~_✓ Reading a.ts_~~",
|
|
352
377
|
);
|
|
353
378
|
});
|
|
354
379
|
|
|
@@ -381,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
381
406
|
it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
|
|
382
407
|
const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
|
|
383
408
|
expect(out).toContain("_✓ 3 steps_");
|
|
384
|
-
expect(out).toBe("_✓ Reading a.ts_\n_✓ 3 steps_");
|
|
409
|
+
expect(out).toBe("~~_✓ Reading a.ts_~~ \n_✓ 3 steps_");
|
|
385
410
|
});
|
|
386
411
|
|
|
387
412
|
// Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
|
|
@@ -398,7 +423,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
398
423
|
|
|
399
424
|
it("finalizes the placeholder to a done record (no frozen → line)", () => {
|
|
400
425
|
expect(renderActivityFeedWithNested(["Working…"], [], true)).toBe(
|
|
401
|
-
"_✓ Working…_",
|
|
426
|
+
"~~_✓ Working…_~~",
|
|
402
427
|
);
|
|
403
428
|
});
|
|
404
429
|
|
|
@@ -612,8 +637,8 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
|
|
|
612
637
|
expect(out).not.toBeNull();
|
|
613
638
|
expect(out.length).toBeLessThanOrEqual(4000);
|
|
614
639
|
expect(isValidMarkdown(out)).toBe(true);
|
|
615
|
-
expect(out.startsWith("_✓ ")).toBe(true);
|
|
616
|
-
expect(out.endsWith("_")).toBe(true);
|
|
640
|
+
expect(out.startsWith("~~_✓ ")).toBe(true);
|
|
641
|
+
expect(out.endsWith("_~~")).toBe(true);
|
|
617
642
|
});
|
|
618
643
|
|
|
619
644
|
it("renderActivityFeedWithNested: single ~4100-char parent line → ≤ budget and valid markdown", () => {
|
|
@@ -770,7 +795,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
770
795
|
expect(out).toContain("🤖 **Agent**");
|
|
771
796
|
expect(out).toContain("_15s · 7 tools_");
|
|
772
797
|
// Step feed follows the header.
|
|
773
|
-
expect(out).toContain("_✓ Reading CLAUDE.md_");
|
|
798
|
+
expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
|
|
774
799
|
expect(out).toContain("**→ Searching memory**");
|
|
775
800
|
});
|
|
776
801
|
|
|
@@ -784,7 +809,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
784
809
|
const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
|
|
785
810
|
expect(out).toContain("🤖 **Agent**");
|
|
786
811
|
expect(out).toContain("_done · 3 tools · 1m05s_");
|
|
787
|
-
expect(out).toContain("_✓ Reading CLAUDE.md_");
|
|
812
|
+
expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
|
|
788
813
|
// No in-progress arrow (final=true).
|
|
789
814
|
expect(out).not.toContain("→");
|
|
790
815
|
});
|
|
@@ -824,7 +849,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
824
849
|
expect(out).toContain("🤖 **Agent**");
|
|
825
850
|
expect(out).toContain("_30s · 5 tools_");
|
|
826
851
|
// Parent step is done-styled (child is the live step).
|
|
827
|
-
expect(out).toContain("_✓ Delegating: review_");
|
|
852
|
+
expect(out).toContain("~~_✓ Delegating: review_~~");
|
|
828
853
|
// Child step is the in-progress step.
|
|
829
854
|
expect(out).toContain(" ↳ **→ Reading schema.ts**");
|
|
830
855
|
});
|
|
@@ -847,3 +872,41 @@ describe("describeToolUse — surface-tool suppression is key-agnostic", () => {
|
|
|
847
872
|
expect(describeToolUse("mcp__clerk-telegram__react", {})).toBeNull();
|
|
848
873
|
});
|
|
849
874
|
});
|
|
875
|
+
|
|
876
|
+
describe("status-card body stacks like a reply (#2669 rich-message renderer)", () => {
|
|
877
|
+
// The single most-seen surface: the operator's progress card. Its step lines
|
|
878
|
+
// are styled prose (`~~_✓ …_~~`, `**→ …**`), NOT GFM list items, so a lone `\n`
|
|
879
|
+
// between them is a SOFT break the renderer collapses — bullets ran together
|
|
880
|
+
// into one line. The card now joins via stackCardLines (hard breaks), so the
|
|
881
|
+
// bullets stack exactly as the reply path renders them.
|
|
882
|
+
|
|
883
|
+
it("multiple step bullets stack on their own lines (no soft-break collapse)", () => {
|
|
884
|
+
const out = renderActivityFeed(["Reading a.ts", "Searching memory", "Running tests"], true)!;
|
|
885
|
+
const lines = out.split(" \n");
|
|
886
|
+
expect(lines).toEqual(["~~_✓ Reading a.ts_~~", "~~_✓ Searching memory_~~", "~~_✓ Running tests_~~"]);
|
|
887
|
+
// Not a single lone `\n` survives to collapse two bullets.
|
|
888
|
+
expect(out).not.toMatch(/(?<! {2})\n/);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("the collapsed-inline-bullet case is fixed on a card body specifically", () => {
|
|
892
|
+
// Three labels that, joined by a lone `\n`, would render as a single run-on
|
|
893
|
+
// visual line in the rich renderer. After stacking, each renders distinctly.
|
|
894
|
+
const out = renderActivityFeed(["Editing foo.ts", "Editing bar.ts", "Writing baz.ts"])!;
|
|
895
|
+
// Every adjacent pair is separated by a hard break.
|
|
896
|
+
expect(out.match(/ {2}\n/g)?.length).toBe(2);
|
|
897
|
+
// The newest is the in-progress bold step; earlier are done — all visible.
|
|
898
|
+
expect(out).toContain("~~_✓ Editing foo.ts_~~");
|
|
899
|
+
expect(out).toContain("~~_✓ Editing bar.ts_~~");
|
|
900
|
+
expect(out).toContain("**→ Writing baz.ts**");
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("stacking does not corrupt the card's own italic/bold wrapper markup", () => {
|
|
904
|
+
// The status card wraps each step in `_…_` (done) or `**→ …**` (live). The
|
|
905
|
+
// stacking pass only changes the SEPARATOR between lines — the per-line
|
|
906
|
+
// wrapper markup must be byte-identical. (Step TEXT is stripped to plain
|
|
907
|
+
// prose by escapeStepLine; the inline-markup-survives case is covered by
|
|
908
|
+
// the stackCardLines unit test, which operates on pre-rendered lines.)
|
|
909
|
+
const out = renderActivityFeed(["Reading a.ts", "Running tests"], false)!;
|
|
910
|
+
expect(out).toBe("~~_✓ Reading a.ts_~~ \n**→ Running tests**");
|
|
911
|
+
});
|
|
912
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for tts-normalize.ts — deterministic L1 TTS normalization
|
|
3
|
+
* (issue #2760, Phase 1). Bun test (this dir uses bun test, not vitest).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
6
|
+
import { normalizeForTts, ttsNormalizeEnabled } from '../tts-normalize.js'
|
|
7
|
+
|
|
8
|
+
const KILL = 'SWITCHROOM_DISABLE_TTS_NORMALIZE'
|
|
9
|
+
|
|
10
|
+
let savedKill: string | undefined
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
savedKill = process.env[KILL]
|
|
14
|
+
delete process.env[KILL]
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (savedKill === undefined) delete process.env[KILL]
|
|
19
|
+
else process.env[KILL] = savedKill
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('gating', () => {
|
|
23
|
+
test('ON by default — normalizes with no env vars set', () => {
|
|
24
|
+
expect(ttsNormalizeEnabled()).toBe(true)
|
|
25
|
+
expect(normalizeForTts('**bold** $5.20')).toBe('bold five dollars twenty')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('kill switch → byte-identical pass-through', () => {
|
|
29
|
+
process.env[KILL] = '1'
|
|
30
|
+
const input = '**bold** and $5.20 🚀 https://github.com/x'
|
|
31
|
+
expect(normalizeForTts(input)).toBe(input)
|
|
32
|
+
expect(ttsNormalizeEnabled()).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('kill switch accepts "true"', () => {
|
|
36
|
+
process.env[KILL] = 'true'
|
|
37
|
+
expect(ttsNormalizeEnabled()).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('empty input stays empty', () => {
|
|
41
|
+
expect(normalizeForTts('')).toBe('')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('markdown', () => {
|
|
46
|
+
test('bold/italic/strike markers removed', () => {
|
|
47
|
+
expect(normalizeForTts('**bold** and *italic* and ~~gone~~')).toBe(
|
|
48
|
+
'bold and italic and gone',
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('headings become plain text', () => {
|
|
53
|
+
expect(normalizeForTts('## Status report\nAll good')).toBe(
|
|
54
|
+
'Status report All good',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('links become link text', () => {
|
|
59
|
+
expect(normalizeForTts('see [the docs](https://example.com/a/b) now')).toBe(
|
|
60
|
+
'see the docs now',
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('list markers dropped', () => {
|
|
65
|
+
expect(normalizeForTts('- first\n- second')).toBe('first second')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('blockquote markers dropped', () => {
|
|
69
|
+
expect(normalizeForTts('> quoted line')).toBe('quoted line')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('code fences become spoken placeholder', () => {
|
|
73
|
+
const out = normalizeForTts('before\n```ts\nconst x = 1\n```\nafter')
|
|
74
|
+
expect(out).toContain('code block omitted')
|
|
75
|
+
expect(out).not.toContain('const x')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('inline code read as-is without backticks, content untouched', () => {
|
|
79
|
+
expect(normalizeForTts('run `deploy_5km_run` now')).toBe(
|
|
80
|
+
'run deploy_5km_run now',
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('unterminated fence swallows to end-of-input (deliberate)', () => {
|
|
85
|
+
// An opening ``` with no close drops everything after it — reading
|
|
86
|
+
// half a code block aloud is worse than omitting it. Pinned as the
|
|
87
|
+
// intended trade-off.
|
|
88
|
+
const out = normalizeForTts('before\n```ts\nconst secret = 1\nmore code')
|
|
89
|
+
expect(out).toBe('before code block omitted.')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('tables summarized as table omitted', () => {
|
|
93
|
+
const out = normalizeForTts('| a | b |\n|---|---|\n| 1 | 2 |\n')
|
|
94
|
+
expect(out).toContain('table omitted')
|
|
95
|
+
expect(out).not.toContain('|')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('numbers', () => {
|
|
100
|
+
test('currency $5.20 → five dollars twenty', () => {
|
|
101
|
+
expect(normalizeForTts('that costs $5.20 today')).toBe(
|
|
102
|
+
'that costs five dollars twenty today',
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('whole-dollar and singular', () => {
|
|
107
|
+
expect(normalizeForTts('$1 fee')).toBe('one dollar fee')
|
|
108
|
+
expect(normalizeForTts('$300 total')).toBe('three hundred dollars total')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('percent 12% → twelve percent', () => {
|
|
112
|
+
expect(normalizeForTts('up 12% overnight')).toBe('up twelve percent overnight')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('ordinal 3rd → third', () => {
|
|
116
|
+
expect(normalizeForTts('the 3rd item and the 21st day')).toBe(
|
|
117
|
+
'the third item and the twenty-first day',
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('time 14:30 spoken', () => {
|
|
122
|
+
expect(normalizeForTts('meet at 14:30 sharp')).toBe(
|
|
123
|
+
'meet at fourteen thirty sharp',
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('ISO date spoken', () => {
|
|
128
|
+
expect(normalizeForTts('due 2026-07-04 ok')).toBe(
|
|
129
|
+
'due July fourth two thousand twenty-six ok',
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('phone-like digit run read as digits', () => {
|
|
134
|
+
expect(normalizeForTts('call 0412 345 678 now')).toBe(
|
|
135
|
+
'call zero four one two three four five six seven eight now',
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('international +-prefixed number read as digits', () => {
|
|
140
|
+
expect(normalizeForTts('call +61 412 345 678')).toBe(
|
|
141
|
+
'call plus six one four one two three four five six seven eight',
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('ordinary space-separated numbers stay untouched', () => {
|
|
146
|
+
expect(normalizeForTts('scores were 1024 2048 4096 today')).toBe(
|
|
147
|
+
'scores were 1024 2048 4096 today',
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('short digit groups without phone prefix stay untouched', () => {
|
|
152
|
+
// The unit pass may expand a trailing unit, but the digit groups
|
|
153
|
+
// themselves must never be read digit-by-digit.
|
|
154
|
+
const out = normalizeForTts('12 34 56 78 items')
|
|
155
|
+
expect(out).toBe('12 34 56 78 items')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('currency with thousands separator: $1,000 → one thousand dollars', () => {
|
|
159
|
+
expect(normalizeForTts('paid $1,000 up front')).toBe(
|
|
160
|
+
'paid one thousand dollars up front',
|
|
161
|
+
)
|
|
162
|
+
expect(normalizeForTts('$12,345.67 total')).toBe(
|
|
163
|
+
'twelve thousand three hundred forty-five dollars sixty-seven total',
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('odd cents ($5.203) left unchanged rather than half-read', () => {
|
|
168
|
+
expect(normalizeForTts('value $5.203 today')).toBe('value $5.203 today')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('HH:MM:SS left unchanged (no dangling seconds)', () => {
|
|
172
|
+
expect(normalizeForTts('at 12:34:56 sharp')).toBe('at 12:34:56 sharp')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('units expand: 5km, 10GB, 500ms', () => {
|
|
176
|
+
expect(normalizeForTts('ran 5km')).toBe('ran five kilometres')
|
|
177
|
+
expect(normalizeForTts('used 10GB')).toBe('used ten gigabytes')
|
|
178
|
+
expect(normalizeForTts('took 500ms')).toBe('took five hundred milliseconds')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('identifiers with digits are never touched', () => {
|
|
182
|
+
expect(normalizeForTts('rename class5 and my5thing')).toBe(
|
|
183
|
+
'rename class5 and my5thing',
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('URLs', () => {
|
|
189
|
+
test('bare URL → spoken domain', () => {
|
|
190
|
+
expect(normalizeForTts('see https://github.com/switchroom/switchroom for code')).toBe(
|
|
191
|
+
'see github dot com link for code',
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('www prefix dropped from spoken domain', () => {
|
|
196
|
+
expect(normalizeForTts('at https://www.example.org/x')).toBe(
|
|
197
|
+
'at example dot org link',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('IP-host URL degrades to a link', () => {
|
|
202
|
+
expect(normalizeForTts('at http://127.0.0.1:8080/x ok')).toBe('at a link ok')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('emoji', () => {
|
|
207
|
+
test('common emoji spoken via map', () => {
|
|
208
|
+
expect(normalizeForTts('great work 👍')).toBe('great work thumbs up')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('other emoji stripped', () => {
|
|
212
|
+
expect(normalizeForTts('launch 🚀 now 🎉')).toBe('launch now')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('symbols', () => {
|
|
217
|
+
test('& → and, @ → at, # → hash', () => {
|
|
218
|
+
expect(normalizeForTts('cats & dogs')).toBe('cats and dogs')
|
|
219
|
+
expect(normalizeForTts('ping @ken')).toBe('ping at ken')
|
|
220
|
+
expect(normalizeForTts('see #general')).toBe('see hash general')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('prose slash spoken', () => {
|
|
224
|
+
expect(normalizeForTts('yes/no answer')).toBe('yes slash no answer')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('~ before a number → about', () => {
|
|
228
|
+
expect(normalizeForTts('~5 minutes left')).toBe('about 5 minutes left')
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('conservatism', () => {
|
|
233
|
+
test('plain prose passes through unchanged', () => {
|
|
234
|
+
const input = 'The build finished and everything looks good.'
|
|
235
|
+
expect(normalizeForTts(input)).toBe(input)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('deterministic: same input, same output', () => {
|
|
239
|
+
const input = '## Hi\n**bold** $5.20 at 14:30 👍 https://github.com/a'
|
|
240
|
+
expect(normalizeForTts(input)).toBe(normalizeForTts(input))
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { maybeRotate, TURNS_JSONL_MAX_BYTES, type RotateFs } from '../gateway/turns-jsonl-rotate.js'
|
|
4
|
+
|
|
5
|
+
describe('maybeRotate — turns.jsonl size cap', () => {
|
|
6
|
+
const mkFs = (size: number | undefined) => {
|
|
7
|
+
const rename = vi.fn()
|
|
8
|
+
const fs: RotateFs = { statSize: () => size, rename }
|
|
9
|
+
return { fs, rename }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
it('does not rotate below the cap', () => {
|
|
13
|
+
const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES - 1)
|
|
14
|
+
expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
|
|
15
|
+
expect(rename).not.toHaveBeenCalled()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rotates at/over the cap → renames to <path>.1', () => {
|
|
19
|
+
const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES)
|
|
20
|
+
expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(true)
|
|
21
|
+
expect(rename).toHaveBeenCalledWith('/state/agent/turns.jsonl', '/state/agent/turns.jsonl.1')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('absent file (statSize undefined) → no rotation', () => {
|
|
25
|
+
const { fs, rename } = mkFs(undefined)
|
|
26
|
+
expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
|
|
27
|
+
expect(rename).not.toHaveBeenCalled()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('rotation is bounded to one generation (overwrites <path>.1)', () => {
|
|
31
|
+
// A second rotation renames onto the same .1 target — no unbounded
|
|
32
|
+
// accumulation of generations.
|
|
33
|
+
const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES * 3)
|
|
34
|
+
maybeRotate('/a/turns.jsonl', fs)
|
|
35
|
+
maybeRotate('/a/turns.jsonl', fs)
|
|
36
|
+
expect(rename).toHaveBeenNthCalledWith(1, '/a/turns.jsonl', '/a/turns.jsonl.1')
|
|
37
|
+
expect(rename).toHaveBeenNthCalledWith(2, '/a/turns.jsonl', '/a/turns.jsonl.1')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -84,6 +84,18 @@ describe('classifyRejection — benign Telegram 400s', () => {
|
|
|
84
84
|
const err = grammyError(400, 'Bad Request: MESSAGE IS NOT MODIFIED: blah')
|
|
85
85
|
expect(classifyRejection(err)).toBe('log_only')
|
|
86
86
|
})
|
|
87
|
+
|
|
88
|
+
it('returns "log_only" for pin-rights 400 "not enough rights to manage pinned messages" (marko 2026-07-01)', () => {
|
|
89
|
+
// The bot is not an admin in a supergroup, so the auto status-pin
|
|
90
|
+
// driver's pinChatMessage returns this 400. It was crashing the whole
|
|
91
|
+
// gateway via a leaked fire-and-forget rejection. Auto-pin is cosmetic —
|
|
92
|
+
// a missing pin right must never be fatal.
|
|
93
|
+
const err = grammyError(
|
|
94
|
+
400,
|
|
95
|
+
'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
96
|
+
)
|
|
97
|
+
expect(classifyRejection(err)).toBe('log_only')
|
|
98
|
+
})
|
|
87
99
|
})
|
|
88
100
|
|
|
89
101
|
describe('classifyRejection — genuine errors still crash', () => {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { describe, it, expect } from 'vitest'
|
|
16
16
|
import {
|
|
17
17
|
buildVaultGrantApprovedInbound,
|
|
18
|
+
buildVaultGrantApprovedCardText,
|
|
18
19
|
buildVaultGrantDeniedInbound,
|
|
19
20
|
buildVaultSaveCompletedInbound,
|
|
20
21
|
buildVaultSaveFailedInbound,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type VaultGrantInboundContext,
|
|
23
24
|
type VaultSaveInboundContext,
|
|
24
25
|
} from '../gateway/vault-grant-inbound-builders.js'
|
|
26
|
+
import { richMessage } from '../rich-send.js'
|
|
25
27
|
|
|
26
28
|
const FIXED_NOW = 1_700_000_000_000
|
|
27
29
|
|
|
@@ -40,6 +42,58 @@ const CTX_WRITE: VaultGrantInboundContext = {
|
|
|
40
42
|
ttl_seconds: 7 * 86400,
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
describe('buildVaultGrantApprovedCardText — the [object Object] regression guard', () => {
|
|
46
|
+
const BASE = {
|
|
47
|
+
agentEscaped: 'gymbro',
|
|
48
|
+
scope: 'read' as const,
|
|
49
|
+
key: 'fatsecret/credentials',
|
|
50
|
+
days: 30,
|
|
51
|
+
grantId: 'vg_a1b2c3',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('renders the full confirmation as ONE markdown string wrapped once — no [object Object]', () => {
|
|
55
|
+
// This is the whole point of the helper: the card text is built as a
|
|
56
|
+
// single string and passed to a single richMessage(...) call. The bug
|
|
57
|
+
// (fixed here) was `"prefix " + richMessage("suffix")`, which coerces
|
|
58
|
+
// the {markdown} object and prints "[object Object]".
|
|
59
|
+
const text = buildVaultGrantApprovedCardText(BASE)
|
|
60
|
+
const rich = richMessage(text)
|
|
61
|
+
expect(rich.markdown).toBe(text)
|
|
62
|
+
expect(rich.markdown).not.toContain('[object Object]')
|
|
63
|
+
expect(rich.markdown).toContain('vg_a1b2c3') // grant id present
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('includes agent, scope, key, days, and the grant id', () => {
|
|
67
|
+
const text = buildVaultGrantApprovedCardText(BASE)
|
|
68
|
+
expect(text).toContain('**gymbro**')
|
|
69
|
+
expect(text).toContain('read access')
|
|
70
|
+
expect(text).toContain('`fatsecret/credentials`')
|
|
71
|
+
expect(text).toContain('for 30d')
|
|
72
|
+
expect(text).toContain('(grant `vg_a1b2c3`)')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('appends the footer when provided, omits it otherwise', () => {
|
|
76
|
+
const withFooter = buildVaultGrantApprovedCardText({
|
|
77
|
+
...BASE,
|
|
78
|
+
footer: '\n_Approver verified by Telegram identity._',
|
|
79
|
+
})
|
|
80
|
+
expect(withFooter).toContain('_Approver verified by Telegram identity._')
|
|
81
|
+
// still one clean string
|
|
82
|
+
expect(withFooter).not.toContain('[object Object]')
|
|
83
|
+
|
|
84
|
+
const noFooter = buildVaultGrantApprovedCardText(BASE)
|
|
85
|
+
expect(noFooter.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('DOCUMENTS the original bug: bare-string + richMessage() coerces to [object Object]', () => {
|
|
89
|
+
// Demonstrates why the fix matters — if a future edit reintroduces the
|
|
90
|
+
// "concatenate a string onto the richMessage object" pattern, the result
|
|
91
|
+
// contains the literal "[object Object]". The helper above avoids it.
|
|
92
|
+
const buggy = '✅ Granted access. ' + (richMessage('(grant `vg_x`)') as unknown as string)
|
|
93
|
+
expect(buggy).toContain('[object Object]')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
43
97
|
describe('buildVaultGrantApprovedInbound', () => {
|
|
44
98
|
it('emits the canonical envelope (type, chat_id, user, userId, ts, messageId)', () => {
|
|
45
99
|
const msg = buildVaultGrantApprovedInbound({
|