switchroom 0.16.28 → 0.16.38

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.
Files changed (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -188,6 +188,24 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
188
188
  // The underscore is escaped so it can't open an italic run inside **…**.
189
189
  expect(d.message).toContain("ev\\_il");
190
190
  });
191
+
192
+ // Boundary-escaping after the #2695 escaper de-dup: the entry message escapes
193
+ // BOTH the agent name and the raw reason via the consolidated `escapeMarkdown`
194
+ // import. A metacharacter-laden value through each site proves the import swap
195
+ // preserved escaping at this call-site.
196
+ it("escapes metacharacters in both the agent name and the cached reason (#2695)", () => {
197
+ const d = evaluateCreditState({
198
+ agentName: "a_b*c",
199
+ currentReason: "weird_reason*x",
200
+ prev: HEALTHY,
201
+ now: NOW,
202
+ fatalReasons: new Set(["weird_reason*x"]),
203
+ });
204
+ expect(d.kind).toBe("notify");
205
+ if (d.kind !== "notify") return;
206
+ expect(d.message).toContain("a\\_b\\*c");
207
+ expect(d.message).toContain("weird\\_reason\\*x");
208
+ });
191
209
  });
192
210
 
193
211
  describe("evaluateCreditState — subscription-only default (the fix)", () => {
@@ -28,19 +28,55 @@ afterEach(() => {
28
28
  })
29
29
 
30
30
  describe('initHistory', () => {
31
- it('creates history.db with chmod 0600', () => {
31
+ it('creates history.db with chmod 0644', () => {
32
32
  initHistory(stateDir, 30)
33
33
  const dbPath = join(stateDir, 'history.db')
34
34
  expect(existsSync(dbPath)).toBe(true)
35
35
  const st = statSync(dbPath)
36
36
  // Mask off the file-type bits — only the perm bits matter.
37
- expect(st.mode & 0o777).toBe(0o600)
37
+ // 0644: the web container (uid 1000) opens history.db read-only to
38
+ // stream replies back to Hermes Desktop. Content is redacted on write,
39
+ // so world-readable is safe (owner-approved behaviour change).
40
+ expect(st.mode & 0o777).toBe(0o644)
38
41
  })
39
42
 
40
43
  it('is idempotent — second call is a no-op', () => {
41
44
  initHistory(stateDir, 30)
42
45
  expect(() => initHistory(stateDir, 30)).not.toThrow()
43
46
  })
47
+
48
+ // Regression for HERMES #2689 review defect #1: the web process (a different
49
+ // uid than the agent) opens history.db READ-ONLY to stream Telegram replies
50
+ // back to Hermes Desktop. If the db/WAL sidecars were left 0600, that open
51
+ // fails and the real-time sync poller silently no-ops. Assert both the perm
52
+ // bits AND that a readonly open + the poller's exact SELECT succeed.
53
+ it('history.db is world-readable and the Hermes poller can open it read-only', async () => {
54
+ initHistory(stateDir, 30)
55
+ recordInbound({ chat_id: '1', thread_id: null, message_id: 1, user: 'a', user_id: '1', ts: 100, text: 'hi' })
56
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [2], texts: ['hello'], ts: 200 })
57
+
58
+ const dbPath = join(stateDir, 'history.db')
59
+ // Force a WAL sidecar to exist so we exercise the -wal/-shm perm path too.
60
+ for (const suffix of ['', '-shm', '-wal']) {
61
+ const f = dbPath + suffix
62
+ if (existsSync(f)) expect(statSync(f).mode & 0o777).toBe(0o644)
63
+ }
64
+
65
+ // Reproduce the poller's open (src/web/hermes-adapter.ts startHistoryPoll):
66
+ // readonly connection + MAX(rowid) + assistant-row SELECT.
67
+ const { Database } = await import('bun:sqlite')
68
+ const rdb = new Database(dbPath, { readonly: true })
69
+ try {
70
+ const max = rdb.prepare('SELECT MAX(rowid) AS r FROM messages').get() as { r: number | null }
71
+ expect(max.r).toBeGreaterThan(0)
72
+ const rows = rdb
73
+ .prepare("SELECT rowid, role, text FROM messages WHERE rowid > ? AND role = 'assistant' ORDER BY rowid ASC")
74
+ .all(0) as Array<{ role: string; text: string }>
75
+ expect(rows.some((r) => r.text === 'hello')).toBe(true)
76
+ } finally {
77
+ rdb.close()
78
+ }
79
+ })
44
80
  })
45
81
 
46
82
  describe('recordInbound + query', () => {
@@ -20,39 +20,83 @@ describe("formatIdleFooter", () => {
20
20
 
21
21
  it("single old turn ended 1h ago → idle 1h ago", () => {
22
22
  const r = row({ startedAt: NOW - 70 * 60_000, endedAt: NOW - 60 * 60_000 });
23
- expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 1h ago");
23
+ expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **1h ago**");
24
24
  });
25
25
 
26
26
  it("single turn ended 3m ago → idle 3m ago", () => {
27
27
  const r = row({ startedAt: NOW - 5 * 60_000, endedAt: NOW - 3 * 60_000 });
28
- expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 3m ago");
28
+ expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **3m ago**");
29
29
  });
30
30
 
31
31
  it("single turn ended 10s ago → idle <1m ago", () => {
32
32
  const r = row({ startedAt: NOW - 30_000, endedAt: NOW - 10_000 });
33
- expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply <1m ago");
33
+ expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **<1m ago**");
34
34
  });
35
35
 
36
- it("currently running turn started 2m ago → working since 2m ago", () => {
36
+ it("currently running turn started 2m ago → working since **2m ago**", () => {
37
37
  const r = row({ startedAt: NOW - 2 * 60_000 });
38
- expect(formatIdleFooter([r], NOW)).toBe("⚙️ working since 2m ago");
38
+ expect(formatIdleFooter([r], NOW)).toBe("⚙️ working since **2m ago**");
39
39
  });
40
40
 
41
41
  it("multiple turns, most recent is running → working since recent", () => {
42
42
  const old = row({ turnKey: "t1", startedAt: NOW - 10 * 60_000, endedAt: NOW - 8 * 60_000 });
43
43
  const running = row({ turnKey: "t2", startedAt: NOW - 90_000 }); // 1m 30s ago
44
- expect(formatIdleFooter([old, running], NOW)).toBe("⚙️ working since 1m ago");
44
+ expect(formatIdleFooter([old, running], NOW)).toBe("⚙️ working since **1m ago**");
45
45
  });
46
46
 
47
47
  it("multiple turns, most recent is ended → idle last reply", () => {
48
48
  const old = row({ turnKey: "t1", startedAt: NOW - 30 * 60_000, endedAt: NOW - 28 * 60_000 });
49
49
  const recent = row({ turnKey: "t2", startedAt: NOW - 6 * 60_000, endedAt: NOW - 4 * 60_000 });
50
- expect(formatIdleFooter([old, recent], NOW)).toBe("🟢 idle · last reply 4m ago");
50
+ expect(formatIdleFooter([old, recent], NOW)).toBe("🟢 idle · last reply **4m ago**");
51
51
  });
52
52
 
53
53
  it("day-old turn → 1d ago", () => {
54
54
  const r = row({ startedAt: NOW - 26 * 3_600_000, endedAt: NOW - 25 * 3_600_000 });
55
- expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 1d ago");
55
+ expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **1d ago**");
56
+ });
57
+
58
+ it("no-data (quiet) state carries NO ** markup", () => {
59
+ const out = formatIdleFooter([], NOW);
60
+ expect(out).toBe("🟡 quiet · no turns yet");
61
+ expect(out).not.toContain("**");
62
+ });
63
+
64
+ it("working state bolds ONLY the elapsed value, not the whole line", () => {
65
+ const r = row({ startedAt: NOW - 4 * 60_000 });
66
+ const out = formatIdleFooter([r], NOW);
67
+ // The bold span wraps the time and only the time.
68
+ const bold = out.match(/\*\*(.+?)\*\*/);
69
+ expect(bold).not.toBeNull();
70
+ expect(bold![1]).toBe("4m ago");
71
+ // The label "⚙️ working since " is outside the bold span.
72
+ expect(out).toBe("⚙️ working since **4m ago**");
73
+ // Exactly one bold span (two `**` delimiters).
74
+ expect((out.match(/\*\*/g) ?? []).length).toBe(2);
75
+ });
76
+
77
+ it("idle state bolds ONLY the last-reply value, not the whole line", () => {
78
+ const r = row({ startedAt: NOW - 6 * 60_000, endedAt: NOW - 3 * 60_000 });
79
+ const out = formatIdleFooter([r], NOW);
80
+ const bold = out.match(/\*\*(.+?)\*\*/);
81
+ expect(bold).not.toBeNull();
82
+ expect(bold![1]).toBe("3m ago");
83
+ expect(out).toBe("🟢 idle · last reply **3m ago**");
84
+ expect((out.match(/\*\*/g) ?? []).length).toBe(2);
85
+ });
86
+
87
+ // Table-driven across elapsed magnitudes: a future change to formatAgo that
88
+ // drops the bold (or widens it past the time value) is caught here.
89
+ it.each([
90
+ { label: "seconds", deltaMs: 10_000, ago: "<1m ago" },
91
+ { label: "minutes", deltaMs: 7 * 60_000, ago: "7m ago" },
92
+ { label: "hours", deltaMs: 5 * 3_600_000, ago: "5h ago" },
93
+ { label: "days", deltaMs: 3 * 86_400_000, ago: "3d ago" },
94
+ ])("idle footer bolds the $label-magnitude elapsed value", ({ deltaMs, ago }) => {
95
+ const r = row({ startedAt: NOW - deltaMs - 1000, endedAt: NOW - deltaMs });
96
+ const out = formatIdleFooter([r], NOW);
97
+ expect(out).toBe(`🟢 idle · last reply **${ago}**`);
98
+ const bold = out.match(/\*\*(.+?)\*\*/);
99
+ expect(bold![1]).toBe(ago);
56
100
  });
57
101
 
58
102
  it("unsorted rows — picks max startedAt", () => {
@@ -61,6 +105,6 @@ describe("formatIdleFooter", () => {
61
105
  const r2 = row({ turnKey: "t2", startedAt: NOW - 5 * 60_000, endedAt: NOW - 2 * 60_000 });
62
106
  const r3 = row({ turnKey: "t3", startedAt: NOW - 40 * 60_000, endedAt: NOW - 38 * 60_000 });
63
107
  // r2 has the largest startedAt, endedAt is 2m ago
64
- expect(formatIdleFooter([r1, r2, r3], NOW)).toBe("🟢 idle · last reply 2m ago");
108
+ expect(formatIdleFooter([r1, r2, r3], NOW)).toBe("🟢 idle · last reply **2m ago**");
65
109
  });
66
110
  });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Validation contract for the #2726 rollout status IPC verbs — hostd asks the
3
+ * caller agent's gateway to POST (terminal ping / first narration message) or
4
+ * EDIT (later phases) an ORDINARY operator-DM message. A rogue process on the
5
+ * same UDS must not inject a malformed payload: requestId + agentName required
6
+ * and shaped, text required non-empty and bounded, messageId (edit) an integer.
7
+ *
8
+ * No Bun-native API → vitest per the mixed-runner gotcha.
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import { validateClientMessage } from "../gateway/ipc-server.js";
12
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
13
+
14
+ const post = {
15
+ type: "rollout_status_post",
16
+ requestId: "ro-1",
17
+ agentName: "overlord",
18
+ text: "⏳ Rollout → v1.2.3 — applying",
19
+ };
20
+ const edit = {
21
+ type: "rollout_status_edit",
22
+ requestId: "ro-1",
23
+ agentName: "overlord",
24
+ messageId: 42,
25
+ text: "✅ Rollout → v1.2.3 — done",
26
+ };
27
+
28
+ describe("validateClientMessage — rollout_status_post", () => {
29
+ it("accepts a well-formed message", () => {
30
+ expect(validateClientMessage(post)).toBe(true);
31
+ });
32
+
33
+ it("rejects a missing / empty / over-long requestId", () => {
34
+ expect(validateClientMessage({ ...post, requestId: undefined })).toBe(false);
35
+ expect(validateClientMessage({ ...post, requestId: "" })).toBe(false);
36
+ expect(validateClientMessage({ ...post, requestId: "x".repeat(65) })).toBe(false);
37
+ });
38
+
39
+ it("rejects a missing / malformed agentName", () => {
40
+ expect(validateClientMessage({ ...post, agentName: undefined })).toBe(false);
41
+ expect(validateClientMessage({ ...post, agentName: "Bad Name" })).toBe(false);
42
+ });
43
+
44
+ it("rejects empty or over-long text", () => {
45
+ expect(validateClientMessage({ ...post, text: "" })).toBe(false);
46
+ expect(
47
+ validateClientMessage({ ...post, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) }),
48
+ ).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("validateClientMessage — rollout_status_edit", () => {
53
+ it("accepts a well-formed message", () => {
54
+ expect(validateClientMessage(edit)).toBe(true);
55
+ });
56
+
57
+ it("rejects a non-integer messageId", () => {
58
+ expect(validateClientMessage({ ...edit, messageId: 1.5 })).toBe(false);
59
+ expect(validateClientMessage({ ...edit, messageId: "42" })).toBe(false);
60
+ expect(validateClientMessage({ ...edit, messageId: undefined })).toBe(false);
61
+ });
62
+
63
+ it("rejects empty text", () => {
64
+ expect(validateClientMessage({ ...edit, text: "" })).toBe(false);
65
+ });
66
+ });
@@ -175,6 +175,30 @@ describe("renderIssuesCard", () => {
175
175
  expect(out).toContain("`<x>::<y>`");
176
176
  expect(out).toContain("<dangerous & stuff>");
177
177
  });
178
+
179
+ it("stacks issue rows with GFM hard breaks so they don't collapse onto one line (#2669)", () => {
180
+ // Card-body regression: the rich-message renderer collapses lone-`\n`
181
+ // separated rows into a run-on blob. The issues card now routes its rows
182
+ // through stackCardLines, so each issue row is separated by a hard break.
183
+ const events = [
184
+ makeEvent({ fingerprint: "a::1", code: "1", severity: "critical", summary: "alpha down", detail: "Fix: restart alpha" }),
185
+ makeEvent({ fingerprint: "a::2", code: "2", severity: "error", summary: "beta slow" }),
186
+ ];
187
+ const out = renderIssuesCard({ agentName: "klanker", events, now: 1_700_000_000_000 })!;
188
+ // Header → rows is a genuine paragraph gap (blank line preserved).
189
+ expect(out).toContain("\n\n");
190
+ // Each adjacent issue row (and its remediation line) is hard-broken, not
191
+ // soft — so no two non-blank body lines are glued by a LONE `\n`.
192
+ const softBreaks = out.split("\n\n").flatMap((block) => {
193
+ // Within a block, every internal `\n` must be a hard break (` \n`).
194
+ return [...block.matchAll(/(?<! {2})\n/g)];
195
+ });
196
+ expect(softBreaks.length).toBe(0);
197
+ // Both rows + the remediation line all rendered, stacked.
198
+ expect(out).toContain("alpha down");
199
+ expect(out).toContain("→ _restart alpha_");
200
+ expect(out).toContain("beta slow");
201
+ });
178
202
  });
179
203
 
180
204
  // ─── createIssuesCardHandle (lifecycle) ──────────────────────────────────────
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { decideObligationTurnEnd } from '../gateway/obligation-turn-end.js'
4
+ import { ObligationLedger } from '../gateway/obligation-ledger.js'
5
+
6
+ /**
7
+ * #2624 — obligation close on replyCalled shipped test-free. Pin BOTH branches:
8
+ *
9
+ * - Normal reply → close at turn_end (finalAnswerDelivered OR replyCalled).
10
+ * - Ack-then-ghost / no-reply → NOT closed at turn_end; stays open so the
11
+ * idle sweep ends it via silence_fallback.
12
+ */
13
+ describe('decideObligationTurnEnd (#2624)', () => {
14
+ it('final answer delivered → close', () => {
15
+ expect(decideObligationTurnEnd(true, false)).toBe('close')
16
+ })
17
+
18
+ it('replyCalled (short sr-* reply demoted to non-final) → close', () => {
19
+ // The LiteLLM sr-* cascade: reply("OK", {disable_notification:true}) is
20
+ // below the 200-char backstop + notification-suppressed, so
21
+ // finalAnswerDelivered is false — but the model explicitly replied.
22
+ expect(decideObligationTurnEnd(false, true)).toBe('close')
23
+ })
24
+
25
+ it('final answer AND replyCalled → close', () => {
26
+ expect(decideObligationTurnEnd(true, true)).toBe('close')
27
+ })
28
+
29
+ it('no reply at all (ack-then-ghost / no-reply) → note-ended, NOT close', () => {
30
+ expect(decideObligationTurnEnd(false, false)).toBe('note-ended')
31
+ })
32
+ })
33
+
34
+ describe('#2624 obligation lifecycle — end-to-end over the ledger', () => {
35
+ const openObligation = (ledger: ObligationLedger, turnId: string) =>
36
+ ledger.openIfAbsent({
37
+ originTurnId: turnId,
38
+ chatId: '123',
39
+ messageId: 1,
40
+ text: 'do the thing',
41
+ openedAt: 1_000,
42
+ })
43
+
44
+ it('normal reply closes the obligation at turn_end', () => {
45
+ const ledger = new ObligationLedger()
46
+ openObligation(ledger, 'turn-A')
47
+ expect(ledger.isOpen('turn-A')).toBe(true)
48
+
49
+ // Simulate the gateway turn_end branch for a turn that called reply.
50
+ if (decideObligationTurnEnd(true, false) === 'close') {
51
+ ledger.close('turn-A')
52
+ } else {
53
+ ledger.noteTurnEnded('turn-A', 2_000)
54
+ }
55
+ expect(ledger.isOpen('turn-A')).toBe(false)
56
+ })
57
+
58
+ it('ack-then-ghost turn_end does NOT close — obligation survives for silence_fallback', () => {
59
+ const ledger = new ObligationLedger()
60
+ openObligation(ledger, 'turn-B')
61
+
62
+ // turn_end with no reply: the ack-then-ghost path.
63
+ if (decideObligationTurnEnd(false, false) === 'close') {
64
+ ledger.close('turn-B')
65
+ } else {
66
+ ledger.noteTurnEnded('turn-B', 2_000)
67
+ }
68
+ // Still open at turn_end — the whole point: it is ended later via
69
+ // silence_fallback, NOT turn_end.
70
+ expect(ledger.isOpen('turn-B')).toBe(true)
71
+
72
+ // The silence-fallback sweep is what finally closes it.
73
+ expect(ledger.close('turn-B')).toBe(true)
74
+ expect(ledger.isOpen('turn-B')).toBe(false)
75
+ })
76
+ })
@@ -9,7 +9,12 @@
9
9
  * (un-promoted break) are preferred over false positives (double-spaced list).
10
10
  */
11
11
  import { describe, test, expect } from 'vitest'
12
- import { normalizeParagraphBreaks } from '../format.js'
12
+ import {
13
+ normalizeParagraphBreaks,
14
+ splitCollapsedInlineBullets,
15
+ addParagraphSpacers,
16
+ PARAGRAPH_SPACER,
17
+ } from '../format.js'
13
18
 
14
19
  describe('normalizeParagraphBreaks', () => {
15
20
  test('promotes a lone prose paragraph break (prev ends with `.`, next is prose)', () => {
@@ -271,3 +276,197 @@ describe('normalizeParagraphBreaks', () => {
271
276
  expect(normalizeParagraphBreaks(input)).toBe(input)
272
277
  })
273
278
  })
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // splitCollapsedInlineBullets — stack an inline-collapsed bullet list
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe('splitCollapsedInlineBullets', () => {
285
+ test('splits the bath-bulb run-on (4 inline bullets → 4 lines)', () => {
286
+ const input =
287
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
288
+ expect(splitCollapsedInlineBullets(input)).toBe(
289
+ '• Master Bath 1, clean\n• Master Bath 2, 33% packet loss\n• Master Bath 3, clean\n• Cabinet, clean',
290
+ )
291
+ })
292
+
293
+ test('a bold segment inside one bullet survives the split', () => {
294
+ const input = '• A, clean • **Master Bath 2** (192.168.5.252), **33% loss** • C, clean'
295
+ expect(splitCollapsedInlineBullets(input)).toBe(
296
+ '• A, clean\n• **Master Bath 2** (192.168.5.252), **33% loss**\n• C, clean',
297
+ )
298
+ })
299
+
300
+ test('a <b> segment inside one bullet survives the split', () => {
301
+ const input = '• A, clean • <b>Master Bath 2</b>, <b>33% packet loss</b> • C, clean'
302
+ expect(splitCollapsedInlineBullets(input)).toBe(
303
+ '• A, clean\n• <b>Master Bath 2</b>, <b>33% packet loss</b>\n• C, clean',
304
+ )
305
+ })
306
+
307
+ test('splits on the middle-dot `·` separator too, and a `·`-led line', () => {
308
+ const input = '· one · two · three'
309
+ expect(splitCollapsedInlineBullets(input)).toBe('· one\n· two\n· three')
310
+ })
311
+
312
+ test('accepts a `-` or `*` leading marker (but only splits unicode interior bullets)', () => {
313
+ expect(splitCollapsedInlineBullets('- lead marker • two • three')).toBe(
314
+ '- lead marker\n• two\n• three',
315
+ )
316
+ expect(splitCollapsedInlineBullets('* lead marker • two')).toBe('* lead marker\n• two')
317
+ })
318
+
319
+ test('prose containing a mid-sentence `•` (line does not start with a bullet) is untouched', () => {
320
+ const input = 'The plan uses A • B notation throughout the design doc.'
321
+ expect(splitCollapsedInlineBullets(input)).toBe(input)
322
+ })
323
+
324
+ test('does NOT split on interior `-`/`*` (hyphens, ranges, multiplication)', () => {
325
+ const input = '• range 1-3 and a * b product stay on one bullet'
326
+ // No interior unicode bullet → line is returned unchanged.
327
+ expect(splitCollapsedInlineBullets(input)).toBe(input)
328
+ })
329
+
330
+ test('is idempotent on the bath-bulb input', () => {
331
+ const input =
332
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
333
+ const once = splitCollapsedInlineBullets(input)
334
+ const twice = splitCollapsedInlineBullets(once)
335
+ expect(twice).toBe(once)
336
+ })
337
+
338
+ test('an already-correct multi-line bullet list passes through unchanged', () => {
339
+ const input = '• Master Bath 1, clean\n• Master Bath 2, 33% loss\n• Cabinet, clean'
340
+ expect(splitCollapsedInlineBullets(input)).toBe(input)
341
+ })
342
+ })
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // addParagraphSpacers — restore a VISIBLE blank line between prose paragraphs.
346
+ //
347
+ // The rich GFM renderer (post-#2669) collapses a `\n\n` paragraph gap TIGHT, so
348
+ // multi-paragraph replies render jammed together — the operator-confirmed
349
+ // regression vs the old HTML path (`parse_mode:"HTML"`, `\n\n` → real blank
350
+ // line). addParagraphSpacers wedges a U+00A0 spacer paragraph into each prose
351
+ // `\n\n` gap so the rich renderer shows a visible empty line. Conservative:
352
+ // only between two prose blocks, never adjacent to list/table/code/quote/heading.
353
+ // ---------------------------------------------------------------------------
354
+
355
+ describe('addParagraphSpacers', () => {
356
+ const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
357
+
358
+ test('the spacer is a single non-breaking space (U+00A0), not an ASCII space', () => {
359
+ expect(PARAGRAPH_SPACER).toBe(' ')
360
+ })
361
+
362
+ test('inserts a visible spacer paragraph into a prose `\\n\\n` gap', () => {
363
+ expect(addParagraphSpacers('Paragraph one.\n\nParagraph two.')).toBe(
364
+ `Paragraph one.${gap}Paragraph two.`,
365
+ )
366
+ })
367
+
368
+ test('spaces every gap in a three-paragraph body', () => {
369
+ expect(addParagraphSpacers('One.\n\nTwo.\n\nThree.')).toBe(
370
+ `One.${gap}Two.${gap}Three.`,
371
+ )
372
+ })
373
+
374
+ test('is idempotent — a spaced gap is not doubled on a second pass', () => {
375
+ const once = addParagraphSpacers('Alpha.\n\nBravo.')
376
+ expect(addParagraphSpacers(once)).toBe(once)
377
+ })
378
+
379
+ test('leaves a single-`\\n` separation alone (only `\\n\\n` gaps are spaced)', () => {
380
+ // normalizeParagraphBreaks owns lone-break promotion; this pass only adds a
381
+ // visible spacer where a genuine blank-line gap already exists.
382
+ const input = 'Line one.\nLine two.'
383
+ expect(addParagraphSpacers(input)).toBe(input)
384
+ })
385
+
386
+ test('does NOT space a gap adjacent to a list (tight list rhythm preserved)', () => {
387
+ const input = 'Here are the steps.\n\n- first\n- second'
388
+ expect(addParagraphSpacers(input)).toBe(input)
389
+ const input2 = '- first\n- second\n\nClosing prose after the list.'
390
+ expect(addParagraphSpacers(input2)).toBe(input2)
391
+ })
392
+
393
+ test('does NOT space a gap adjacent to a table', () => {
394
+ const input = 'Intro.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |'
395
+ expect(addParagraphSpacers(input)).toBe(input)
396
+ })
397
+
398
+ test('does NOT space a gap adjacent to a fenced code block', () => {
399
+ const input = 'Look here.\n\n```js\nconst a = 1;\n```\n\nDone.'
400
+ // Neither the prose→fence nor the fence→prose gap gets a spacer.
401
+ expect(addParagraphSpacers(input)).toBe(input)
402
+ })
403
+
404
+ test('does NOT space a gap adjacent to a heading or blockquote', () => {
405
+ expect(addParagraphSpacers('Intro prose.\n\n## Section\n\nBody prose.')).toBe(
406
+ 'Intro prose.\n\n## Section\n\nBody prose.',
407
+ )
408
+ expect(addParagraphSpacers('Said.\n\n> a quote\n\nAfter.')).toBe(
409
+ 'Said.\n\n> a quote\n\nAfter.',
410
+ )
411
+ })
412
+
413
+ test('never reaches inside a fenced block (interior blank line untouched)', () => {
414
+ const input = 'Before.\n\n```\nline 1\n\nline 2\n```\n\nAfter.'
415
+ const out = addParagraphSpacers(input)
416
+ // The fence interior — including its own blank line — is byte-for-byte intact.
417
+ expect(out).toContain('```\nline 1\n\nline 2\n```')
418
+ // And no spacer paragraph was injected anywhere (both gaps touch the fence).
419
+ expect(out).not.toContain(PARAGRAPH_SPACER)
420
+ })
421
+
422
+ test('a body with no paragraph gap is returned unchanged', () => {
423
+ expect(addParagraphSpacers('just one paragraph, no gap')).toBe('just one paragraph, no gap')
424
+ })
425
+
426
+ test('composes after normalizeParagraphBreaks: prose gap spaced, list left tight', () => {
427
+ const input = [
428
+ 'Summary of the change.',
429
+ '',
430
+ 'It does two things.',
431
+ '',
432
+ '- adds a normalizer',
433
+ '- lifts the cap',
434
+ ].join('\n')
435
+ const out = addParagraphSpacers(normalizeParagraphBreaks(input))
436
+ // The two prose paragraphs gain a visible spacer between them.
437
+ expect(out).toContain(`Summary of the change.${gap}It does two things.`)
438
+ // The list stays tight (no spacer wedged before it).
439
+ expect(out).toContain('- adds a normalizer\n- lifts the cap')
440
+ expect(out).not.toContain(`${PARAGRAPH_SPACER}\n\n- adds`)
441
+ })
442
+ })
443
+
444
+ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
445
+ test('splits the bath-bulb run-on as part of the full outbound pass', () => {
446
+ const input =
447
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
448
+ expect(normalizeParagraphBreaks(input)).toBe(
449
+ '• Master Bath 1, clean\n• Master Bath 2, 33% packet loss\n• Master Bath 3, clean\n• Cabinet, clean',
450
+ )
451
+ })
452
+
453
+ test('a `•` inside an inline code span is NOT split (code masked)', () => {
454
+ const input = '`• a • b • c` is a code span'
455
+ // The line does not start with a bullet marker (it starts with the masked
456
+ // code placeholder), and the bullets live inside masked code regardless.
457
+ expect(normalizeParagraphBreaks(input)).toBe(input)
458
+ })
459
+
460
+ test('a `•` inside a fenced code block is NOT split', () => {
461
+ const input = '```\n• a • b • c\n```'
462
+ expect(normalizeParagraphBreaks(input)).toBe(input)
463
+ })
464
+
465
+ test('idempotent through the full pass on the bath-bulb input', () => {
466
+ const input =
467
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Cabinet, clean'
468
+ const once = normalizeParagraphBreaks(input)
469
+ const twice = normalizeParagraphBreaks(once)
470
+ expect(twice).toBe(once)
471
+ })
472
+ })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Structural pin for card-ux fix 3 — the "Allow once" tap updates the SAME
3
+ * card instead of spawning a separate "▶️ … got it, continuing" message.
4
+ *
5
+ * What broke before: tapping Allow edited the card to "✅ Allowed once" AND
6
+ * posted a separate resume message (to EVERY resolvePermissionCardTargets()
7
+ * surface). Ken wants no split — the continuation folds into the tapped card.
8
+ *
9
+ * The turn-resume trigger (dispatchPermissionVerdict) and the glyph flip
10
+ * (resumeReactionAfterVerdict) MUST still fire; only the user-visible second
11
+ * message collapses. mtcute UAT can observe the card edit and a separate
12
+ * sendMessage, but wiring a full Grammy ctx here is heavy, so this is a
13
+ * source-structure pin over the exact callback block (mirrors
14
+ * permission-verdict-resume-guard.test.ts).
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+ import { readFileSync } from 'node:fs'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { dirname, resolve } from 'node:path'
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ const GATEWAY_SRC = readFileSync(
24
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
25
+ 'utf8',
26
+ )
27
+
28
+ // Isolate the single "Allow once / Deny" verdict-tap block: from the
29
+ // `labelWithResume` construction to the end of its finalizeCallback call.
30
+ function allowOnceBlock(): string {
31
+ const start = GATEWAY_SRC.indexOf('const resumeLine = process.env.SWITCHROOM_RESUME_MSG')
32
+ expect(start).toBeGreaterThan(-1)
33
+ // Cut a generous slice covering resumeLine + finalizeCallback + synthInbound.
34
+ return GATEWAY_SRC.slice(start, start + 2000)
35
+ }
36
+
37
+ describe('permission "Allow once" tap → single in-place card edit (card-ux fix 3)', () => {
38
+ it('folds the resume line into the card edit via formatPermissionResumeMessage', () => {
39
+ const block = allowOnceBlock()
40
+ expect(block).toContain('formatPermissionResumeMessage(')
41
+ // The card newText carries BOTH the verdict label and the resume line.
42
+ expect(block).toContain('const labelWithResume = resumeLine')
43
+ expect(block).toMatch(/newText:\s*baseText\s*\?\s*`\$\{baseText\}\\n\\n\$\{labelWithResume\}`/)
44
+ })
45
+
46
+ it('does NOT post a separate resume message on this path', () => {
47
+ const block = allowOnceBlock()
48
+ expect(block).not.toContain('postPermissionResumeMessage(')
49
+ })
50
+
51
+ it('still fires the turn-resume trigger and glyph flip', () => {
52
+ const block = allowOnceBlock()
53
+ expect(block).toContain('dispatchPermissionVerdict(')
54
+ expect(block).toContain('resumeReactionAfterVerdict()')
55
+ })
56
+
57
+ it('honours the SWITCHROOM_RESUME_MSG=0 kill-switch for the folded line', () => {
58
+ const block = allowOnceBlock()
59
+ expect(block).toContain("process.env.SWITCHROOM_RESUME_MSG === '0'")
60
+ })
61
+
62
+ it('carries the card-folded-resume sentinel the resume guard keys off', () => {
63
+ const block = allowOnceBlock()
64
+ expect(block).toContain('card-folded-resume')
65
+ })
66
+ })