switchroom 0.19.4 → 0.19.6

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 (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Unit tests for the progress-based hang-restart discriminator (Stage B).
3
+ *
4
+ * The crux: a mid-tool framework fallback with a STALE turn-active marker
5
+ * escalates to a real restart, but a fallback whose marker mtime keeps
6
+ * advancing (a healthy long turn), OR whose in-flight tool is a known-long
7
+ * class (Bash/WebFetch/Task/research — Finding A), must NOT be restarted.
8
+ *
9
+ * FAILS on current head: `../gateway/hang-restart-decision.js` does not exist
10
+ * there — the discriminator is the contribution under test.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ decideHangRestart,
16
+ isHangRestartProtectedTool,
17
+ classifyToolClass,
18
+ hangStalenessMs,
19
+ DEFAULT_HANG_STALENESS_MS,
20
+ } from '../gateway/hang-restart-decision.js'
21
+
22
+ const STALE = 300_000
23
+
24
+ describe('decideHangRestart — the crux discriminator', () => {
25
+ it('(1) tool mid-call + zero marker progress past the ceiling ⇒ restart requested', () => {
26
+ const d = decideHangRestart({
27
+ inFlightToolNames: ['some_mcp_tool'], // a standard, non-long tool
28
+ markerAgeMs: 600_000, // 10 min since last observable progress
29
+ stalenessThresholdMs: STALE,
30
+ })
31
+ expect(d.restart).toBe(true)
32
+ expect(d.reason).toBe('mid-tool-marker-stale')
33
+ })
34
+
35
+ it('(2) a turn whose marker mtime keeps advancing is NOT restarted', () => {
36
+ const d = decideHangRestart({
37
+ inFlightToolNames: ['some_mcp_tool'],
38
+ markerAgeMs: 4_000, // marker touched 4s ago — the turn is working
39
+ stalenessThresholdMs: STALE,
40
+ })
41
+ expect(d.restart).toBe(false)
42
+ expect(d.reason).toBe('marker-advancing')
43
+ })
44
+
45
+ it('a marker exactly at the staleness ceiling is stale ⇒ restart', () => {
46
+ const d = decideHangRestart({
47
+ inFlightToolNames: ['some_mcp_tool'],
48
+ markerAgeMs: STALE,
49
+ stalenessThresholdMs: STALE,
50
+ })
51
+ expect(d.restart).toBe(true)
52
+ })
53
+
54
+ it('an absent marker (no progress signal at all) is treated as stale ⇒ restart', () => {
55
+ const d = decideHangRestart({
56
+ inFlightToolNames: ['some_mcp_tool'],
57
+ markerAgeMs: null,
58
+ stalenessThresholdMs: STALE,
59
+ })
60
+ expect(d.restart).toBe(true)
61
+ })
62
+
63
+ it('a fallback that is NOT mid-tool never restarts (ordinary teardown owns it)', () => {
64
+ const d = decideHangRestart({
65
+ inFlightToolNames: [],
66
+ markerAgeMs: null,
67
+ stalenessThresholdMs: STALE,
68
+ })
69
+ expect(d.restart).toBe(false)
70
+ expect(d.reason).toBe('not-mid-tool')
71
+ })
72
+ })
73
+
74
+ describe('decideHangRestart — Finding A: protect known-long foreground tools', () => {
75
+ it('a healthy long foreground Bash + stale marker is NOT restarted', () => {
76
+ // The exact false-positive the review flagged: a 15-min foreground `Bash`
77
+ // build/test never touches the marker, so at the ceiling it looks stale —
78
+ // but it is genuinely working. It must be protected.
79
+ const d = decideHangRestart({
80
+ inFlightToolNames: ['Bash'],
81
+ markerAgeMs: 900_000, // 15 min stale
82
+ stalenessThresholdMs: STALE,
83
+ })
84
+ expect(d.restart).toBe(false)
85
+ expect(d.reason).toBe('protected-long-tool:Bash')
86
+ })
87
+
88
+ it('WebFetch / Task / a research MCP tool + stale marker are NOT restarted', () => {
89
+ for (const name of ['WebFetch', 'Task', 'Agent', 'perplexity_research', 'mcp__webkite__crawl']) {
90
+ const d = decideHangRestart({
91
+ inFlightToolNames: [name],
92
+ markerAgeMs: 900_000,
93
+ stalenessThresholdMs: STALE,
94
+ })
95
+ expect(d.restart, `${name} should be protected`).toBe(false)
96
+ expect(d.reason).toContain('protected-long-tool')
97
+ }
98
+ })
99
+
100
+ it('a protected long tool ALONGSIDE a standard tool still protects (conservative)', () => {
101
+ const d = decideHangRestart({
102
+ inFlightToolNames: ['quick_tool', 'Bash'],
103
+ markerAgeMs: 900_000,
104
+ stalenessThresholdMs: STALE,
105
+ })
106
+ expect(d.restart).toBe(false)
107
+ })
108
+
109
+ it('a genuinely-standard hung tool (no long class in flight) + stale ⇒ restart', () => {
110
+ const d = decideHangRestart({
111
+ inFlightToolNames: ['Read', 'some_mcp_query'],
112
+ markerAgeMs: 900_000,
113
+ stalenessThresholdMs: STALE,
114
+ })
115
+ expect(d.restart).toBe(true)
116
+ })
117
+ })
118
+
119
+ describe('isHangRestartProtectedTool + classifyToolClass', () => {
120
+ it('protects background / long-fetch / research / human classes', () => {
121
+ for (const n of ['Task', 'Agent', 'Bash', 'WebFetch', 'WebSearch', 'ask_user',
122
+ 'perplexity_research', 'deep_research', 'mcp__webkite__crawl']) {
123
+ expect(isHangRestartProtectedTool(n), n).toBe(true)
124
+ }
125
+ })
126
+ it('does NOT protect ordinary quick tools', () => {
127
+ for (const n of ['Read', 'Grep', 'Edit', 'Write', 'some_mcp_query']) {
128
+ expect(isHangRestartProtectedTool(n), n).toBe(false)
129
+ }
130
+ })
131
+ it('classifyToolClass taxonomy (ported from Stage A)', () => {
132
+ expect(classifyToolClass('ask_user')).toBe('human')
133
+ expect(classifyToolClass('Task')).toBe('background')
134
+ expect(classifyToolClass('Bash', { backgroundBash: true })).toBe('background')
135
+ expect(classifyToolClass('Read')).toBe('standard')
136
+ })
137
+ })
138
+
139
+ describe('config helpers', () => {
140
+ it('hangStalenessMs keys off TURN_HANG_SECS (seconds → ms), default 300s', () => {
141
+ expect(hangStalenessMs({})).toBe(DEFAULT_HANG_STALENESS_MS)
142
+ expect(hangStalenessMs({ TURN_HANG_SECS: '120' })).toBe(120_000)
143
+ expect(hangStalenessMs({ TURN_HANG_SECS: 'nope' })).toBe(DEFAULT_HANG_STALENESS_MS)
144
+ expect(hangStalenessMs({ TURN_HANG_SECS: '0' })).toBe(DEFAULT_HANG_STALENESS_MS)
145
+ })
146
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Integration test for the Stage B crux false-positive protection (review
3
+ * Finding C).
4
+ *
5
+ * The pure `decideHangRestart` tests pass `markerAgeMs` as a literal. This
6
+ * test proves the REAL chain end-to-end on a real marker file:
7
+ *
8
+ * sub-agent JSONL growth → touchTurnActiveMarker (the exact call the
9
+ * subagent-watcher makes at subagent-watcher.ts:1324) → readTurnActiveMarkerAgeMs
10
+ * yields a SMALL age → decideHangRestart does NOT restart.
11
+ *
12
+ * i.e. a genuinely-working long turn (its only liveness being sub-agent output)
13
+ * is protected against the hang-restart, not just in the abstract.
14
+ */
15
+
16
+ import { describe, it, expect, afterEach } from 'vitest'
17
+ import { mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { tmpdir } from 'node:os'
20
+ import {
21
+ writeTurnActiveMarker,
22
+ touchTurnActiveMarker,
23
+ readTurnActiveMarkerAgeMs,
24
+ removeTurnActiveMarker,
25
+ TURN_ACTIVE_MARKER_FILE,
26
+ } from '../gateway/turn-active-marker.js'
27
+ import { decideHangRestart, DEFAULT_HANG_STALENESS_MS } from '../gateway/hang-restart-decision.js'
28
+
29
+ const dirs: string[] = []
30
+ function tempDir(): string {
31
+ const d = mkdtempSync(join(tmpdir(), 'hang-marker-'))
32
+ dirs.push(d)
33
+ return d
34
+ }
35
+ afterEach(() => {
36
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
37
+ })
38
+
39
+ describe('Stage B marker integration — healthy long turn is protected end-to-end', () => {
40
+ it('sub-agent JSONL growth touches the marker → small age → NOT restarted', () => {
41
+ const dir = tempDir()
42
+ writeTurnActiveMarker(dir, { turnKey: 't1', chatId: '123', startedAt: Date.now() })
43
+ const path = join(dir, TURN_ACTIVE_MARKER_FILE)
44
+
45
+ // Simulate a turn that has been running long with NO interim tool_use —
46
+ // force the marker mtime stale (15 min old). Without a progress touch this
47
+ // would read as a hang.
48
+ const now = Date.now()
49
+ const staleTime = new Date(now - 900_000)
50
+ utimesSync(path, staleTime, staleTime)
51
+ const staleAge = readTurnActiveMarkerAgeMs(dir, now)
52
+ expect(staleAge).not.toBeNull()
53
+ expect(staleAge!).toBeGreaterThanOrEqual(DEFAULT_HANG_STALENESS_MS)
54
+
55
+ // Now the sub-agent produces output: the watcher touches the marker (this
56
+ // is the exact call at subagent-watcher.ts:1324). The mtime advances.
57
+ touchTurnActiveMarker(dir)
58
+ const freshAge = readTurnActiveMarkerAgeMs(dir)
59
+ expect(freshAge).not.toBeNull()
60
+ expect(freshAge!).toBeLessThan(5_000) // touched just now → small age
61
+
62
+ // Fed through the real decision with a NON-protected in-flight tool (so the
63
+ // only thing keeping it alive is the fresh marker), it must NOT restart.
64
+ const d = decideHangRestart({
65
+ inFlightToolNames: ['some_mcp_query'],
66
+ markerAgeMs: freshAge,
67
+ stalenessThresholdMs: DEFAULT_HANG_STALENESS_MS,
68
+ })
69
+ expect(d.restart).toBe(false)
70
+ expect(d.reason).toBe('marker-advancing')
71
+ })
72
+
73
+ it('with no touch, the stale marker + non-protected tool DOES restart (control)', () => {
74
+ const dir = tempDir()
75
+ writeTurnActiveMarker(dir, { turnKey: 't2', chatId: '123', startedAt: Date.now() })
76
+ const path = join(dir, TURN_ACTIVE_MARKER_FILE)
77
+ const now = Date.now()
78
+ const staleTime = new Date(now - 900_000)
79
+ utimesSync(path, staleTime, staleTime)
80
+
81
+ const age = readTurnActiveMarkerAgeMs(dir, now)
82
+ const d = decideHangRestart({
83
+ inFlightToolNames: ['some_mcp_query'],
84
+ markerAgeMs: age,
85
+ stalenessThresholdMs: DEFAULT_HANG_STALENESS_MS,
86
+ })
87
+ expect(d.restart).toBe(true)
88
+ expect(d.reason).toBe('mid-tool-marker-stale')
89
+ })
90
+
91
+ it('readTurnActiveMarkerAgeMs is null once the marker is removed', () => {
92
+ const dir = tempDir()
93
+ writeTurnActiveMarker(dir, { turnKey: 't3', chatId: '1', startedAt: Date.now() })
94
+ expect(statSync(join(dir, TURN_ACTIVE_MARKER_FILE)).isFile()).toBe(true)
95
+ removeTurnActiveMarker(dir)
96
+ expect(readTurnActiveMarkerAgeMs(dir)).toBeNull()
97
+ })
98
+ })
@@ -371,7 +371,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
371
371
  assertSendable: () => {},
372
372
  statusKey: key,
373
373
  streamKey: key,
374
- resolveReplyOwnerTurn: () => null,
374
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
375
+ getLastSubagentHandbackAt: () => null,
375
376
  findTurnByOriginId: () => null,
376
377
  findTurnByQuotedMessageId: () => null,
377
378
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalHeading } from "../../render/line-start-guard.js";
3
+ import { guardAccidentalFormatting } from "../../rich-send.js";
4
+
5
+ // Characterization test for #3464 — glued `#` AFTER a blockquote/list marker.
6
+ //
7
+ // ── The observation (#3464, follow-up from #3463 review) ─────────────────────
8
+ // `guardAccidentalHeading` is `^`-anchored (`ACCIDENTAL_HEADING = /^([ \t]{0,3})
9
+ // (#{1,6})(?=[^\s#])/`), so a `#` glued after a blockquote or list marker on the
10
+ // same line — `> #3460`, `- #3460`, `1. #3460` — is NOT matched, and the seam
11
+ // leaves it untouched. On the RENDERER path this position is incidentally
12
+ // escaped, because render.ts:escapeLineLeadingHash runs on the paragraph's
13
+ // rendered text BEFORE renderBlockquote/renderList prepend the `> `/`- ` marker.
14
+ // On the renderer-BYPASS seam (cards / banners / status / approval sends), no
15
+ // such belt runs, so the glued `#` reaches Telegram unescaped.
16
+ //
17
+ // ── Why this test PINS the current behavior instead of changing it ───────────
18
+ // Whether this is a bug depends on a fact we CANNOT determine from the byte
19
+ // stream: does Telegram's non-spec Bot API rich parser actually promote a
20
+ // space-less `#` to a heading when it sits AFTER a `>`/list marker, the way it
21
+ // demonstrably does at a bare line start (`#3460` → giant heading, #3306/#3463)?
22
+ // - CommonMark treats `> #3460` as a blockquote whose content is the paragraph
23
+ // `#3460` (no ATX heading — no space after `#`); `> # Heading` (WITH space)
24
+ // is a real nested heading. Telegram's promotion of the SPACE-LESS form is
25
+ // the documented non-spec deviation — but only ever OBSERVED at a bare line
26
+ // start, never confirmed inside a blockquote/list.
27
+ // - There is no repo evidence (UAT fixture, doc note, or #3306/#3463 UAT
28
+ // result) establishing that the promotion fires in this nested position.
29
+ // The render.ts belt escaping it is a GENERIC side effect of a `^…#`
30
+ // paragraph regex, not a confirmed-behavior signal.
31
+ // Issue #3464 itself says: "Verify against Telegram live-UAT whether the
32
+ // non-spec heading promotion actually fires inside blockquotes/lists before
33
+ // adding escaping (avoid stray backslashes if it does not)." That live UAT
34
+ // cannot run in vitest. Ken's hard constraint on this guard family is that a
35
+ // wrong "fix" adding stray backslashes would itself corrupt legitimate
36
+ // formatting — the exact thing to avoid. So this test DOCUMENTS the current,
37
+ // deliberately-conservative behavior; if live UAT later confirms Telegram DOES
38
+ // promote here, extend the guard and flip these expectations in the same PR.
39
+
40
+ describe("guardAccidentalHeading — glued `#` after a blockquote/list marker is NOT escaped (#3464, awaits live-UAT)", () => {
41
+ it("leaves `> #3460` untouched (glued hash after a blockquote marker)", () => {
42
+ expect(guardAccidentalHeading("> #3460 done")).toBe("> #3460 done");
43
+ });
44
+
45
+ it("leaves `- #3460` untouched (glued hash after an unordered-list marker)", () => {
46
+ expect(guardAccidentalHeading("- #3460 done")).toBe("- #3460 done");
47
+ });
48
+
49
+ it("leaves `* #3460` untouched (glued hash after a `*` bullet)", () => {
50
+ expect(guardAccidentalHeading("* #3460 x")).toBe("* #3460 x");
51
+ });
52
+
53
+ it("leaves `1. #3460` untouched (glued hash after an ordered-list marker)", () => {
54
+ expect(guardAccidentalHeading("1. #3460 x")).toBe("1. #3460 x");
55
+ });
56
+
57
+ it("still escapes the SAME `#3460` at a bare line start (the confirmed case)", () => {
58
+ // Proves the untouched results above are the `^`-anchor scope, not the guard
59
+ // being disabled: at a real line start the accidental heading IS escaped.
60
+ expect(guardAccidentalHeading("#3460 done")).toBe("\\#3460 done");
61
+ });
62
+ });
63
+
64
+ describe("guardAccidentalHeading — a real nested heading (space form) must stay untouched (#3464)", () => {
65
+ it("leaves `> # Heading` untouched (intended heading inside a blockquote)", () => {
66
+ expect(guardAccidentalHeading("> # Heading")).toBe("> # Heading");
67
+ });
68
+
69
+ it("leaves `- # Heading` untouched (intended heading inside a list item)", () => {
70
+ expect(guardAccidentalHeading("- # Heading")).toBe("- # Heading");
71
+ });
72
+ });
73
+
74
+ describe("guardAccidentalFormatting (universal seam) — same conservative behavior end-to-end (#3464)", () => {
75
+ it("leaves `> #3460` untouched through the full composition", () => {
76
+ expect(guardAccidentalFormatting("> #3460 done")).toBe("> #3460 done");
77
+ });
78
+
79
+ it("leaves `- #3460` untouched through the full composition", () => {
80
+ expect(guardAccidentalFormatting("- #3460 done")).toBe("- #3460 done");
81
+ });
82
+
83
+ it("still escapes a bare line-leading `#3460` at the seam (control)", () => {
84
+ expect(guardAccidentalFormatting("#3460 done")).toBe("\\#3460 done");
85
+ });
86
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalHeading } from "../../render/line-start-guard.js";
3
+ import { guardAccidentalFormatting } from "../../rich-send.js";
4
+
5
+ // Accidental-heading guard (#3306 follow-up): Telegram's Bot API rich-markdown
6
+ // parser is NON-spec and promotes a line-leading `#{1,6}` run to a heading even
7
+ // WITHOUT the CommonMark-required trailing space, so `#3460 done` renders as a
8
+ // giant heading. The guard escapes ONLY the space-less, non-`#`-adjacent form
9
+ // and leaves genuine `# Title` / `## Sub` headings byte-for-byte untouched.
10
+ //
11
+ // The load-bearing assertions run against the REAL universal wire seam
12
+ // `guardAccidentalFormatting` (rich-send.ts) — the composed guard every
13
+ // `{ markdown }` send funnels through — not the renderer, so cards / banners /
14
+ // status / approval sends (which bypass the rich renderer) are proven covered.
15
+
16
+ /** Strip the defusing backslash so we can assert the reader-visible text is
17
+ * byte-identical to the original prose (Telegram consumes the `\`). */
18
+ function copyText(s: string): string {
19
+ return s.replace(/\\#/g, "#");
20
+ }
21
+
22
+ describe("guardAccidentalHeading — accidental Telegram heading is escaped", () => {
23
+ it("escapes ONLY the line-leading `#3460`, not the mid-line `#3462` after `PR `", () => {
24
+ expect(guardAccidentalHeading("#3460 done and up as PR #3462")).toBe(
25
+ "\\#3460 done and up as PR #3462",
26
+ );
27
+ });
28
+
29
+ it("escapes `#foo` (no space, glued to a letter)", () => {
30
+ expect(guardAccidentalHeading("#foo bar")).toBe("\\#foo bar");
31
+ });
32
+
33
+ it("escapes `###x` (multi-hash run, no space)", () => {
34
+ expect(guardAccidentalHeading("###x")).toBe("\\###x");
35
+ });
36
+
37
+ it("reader-visible text is byte-identical after stripping the backslash", () => {
38
+ const s = "#3460 done";
39
+ expect(copyText(guardAccidentalHeading(s))).toBe(s);
40
+ });
41
+ });
42
+
43
+ describe("guardAccidentalHeading — intended headings are NEVER touched", () => {
44
+ it("leaves `# Real Heading` (space form) untouched", () => {
45
+ const s = "# Real Heading";
46
+ expect(guardAccidentalHeading(s)).toBe(s);
47
+ });
48
+
49
+ it("leaves `## Sub` untouched", () => {
50
+ const s = "## Sub";
51
+ expect(guardAccidentalHeading(s)).toBe(s);
52
+ });
53
+
54
+ it("leaves a bare `#` / `##` (end-of-line) untouched", () => {
55
+ expect(guardAccidentalHeading("#")).toBe("#");
56
+ expect(guardAccidentalHeading("##\nbody")).toBe("##\nbody");
57
+ });
58
+
59
+ it("leaves a multi-line mix intact: heading kept, glued ref escaped", () => {
60
+ const s = "# Title\n#3460 is the issue";
61
+ expect(guardAccidentalHeading(s)).toBe("# Title\n\\#3460 is the issue");
62
+ });
63
+
64
+ it("leaves a 4-space indented `#3460` (indented code) untouched", () => {
65
+ const s = " #3460 indented code";
66
+ expect(guardAccidentalHeading(s)).toBe(s);
67
+ });
68
+ });
69
+
70
+ describe("guardAccidentalHeading — code spans / fences are verbatim", () => {
71
+ it("does not escape `#` inside an inline code span", () => {
72
+ expect(guardAccidentalHeading("`#3460`")).toBe("`#3460`");
73
+ });
74
+
75
+ it("does not escape a line-leading `#3460` inside a fenced block", () => {
76
+ const s = "```\n#3460 in code\n```";
77
+ expect(guardAccidentalHeading(s)).toBe(s);
78
+ });
79
+ });
80
+
81
+ describe("guardAccidentalHeading — idempotent + no-op", () => {
82
+ it("running twice equals running once", () => {
83
+ const once = guardAccidentalHeading("#3460 done");
84
+ expect(guardAccidentalHeading(once)).toBe(once);
85
+ });
86
+
87
+ it("is a strict no-op on hash-free text", () => {
88
+ const s = "no hashes here at all";
89
+ expect(guardAccidentalHeading(s)).toBe(s);
90
+ });
91
+ });
92
+
93
+ describe("guardAccidentalFormatting (universal seam) escapes accidental headings", () => {
94
+ it("escapes the line-leading `#3460`, leaves the mid-line `#3462`", () => {
95
+ expect(
96
+ guardAccidentalFormatting("#3460 done and up as PR #3462"),
97
+ ).toBe("\\#3460 done and up as PR #3462");
98
+ });
99
+
100
+ it("leaves `# Real Heading` untouched through the full composition", () => {
101
+ expect(guardAccidentalFormatting("# Real Heading")).toBe("# Real Heading");
102
+ });
103
+
104
+ it("does not double-escape an already-escaped `\\#3460` (renderer belt + seam)", () => {
105
+ // render.ts:escapeLineLeadingHash may already have escaped the `#`; the seam
106
+ // must not turn `\#3460` into `\\#3460`.
107
+ expect(guardAccidentalFormatting("\\#3460 done")).toBe("\\#3460 done");
108
+ });
109
+
110
+ it("is idempotent at the seam", () => {
111
+ const once = guardAccidentalFormatting("#3460 done");
112
+ expect(guardAccidentalFormatting(once)).toBe(once);
113
+ });
114
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalFormatting } from "../../rich-send.js";
3
+
4
+ // Composed-seam rich-corpus regression (#3465).
5
+ //
6
+ // The individual guards (heading / block-construct / emphasis / inline-pair /
7
+ // dollar) each have focused tests, and guard-composition.test.ts proves pairs
8
+ // of guards don't interfere. What was MISSING is a single assertion that a
9
+ // FULL, representative rich-formatting corpus — every construct Ken's
10
+ // rich-formatting directive relies on, together in one message — survives the
11
+ // REAL universal wire seam `guardAccidentalFormatting` (rich-send.ts)
12
+ // BYTE-IDENTICAL. That is the property the whole conservative-guard family
13
+ // exists to protect: intended formatting must NEVER be restricted or corrupted.
14
+ // This test pins it against regression from any future guard that over-reaches.
15
+ //
16
+ // The corpus deliberately contains NO line-leading glued-`#` (e.g. `#3460`), so
17
+ // under the current guards it must be left completely untouched. The second
18
+ // suite proves that untouched-ness is the guards being CORRECT, not the guards
19
+ // being disabled: a line-leading `#3460` in the same corpus position IS escaped.
20
+
21
+ /** One representative message carrying, together: `**bold**`, `_italic_`,
22
+ * `~~strike~~`, inline `code`, a fenced block, a `[link](url#frag)`, a `>`
23
+ * blockquote, an unordered list, an ordered list, and real `# `/`## ` headings.
24
+ * Every construct is in its INTENDED, well-formed shape — none matches a guard's
25
+ * accidental-signal — so the whole thing is expected to pass through verbatim. */
26
+ const RICH_CORPUS = [
27
+ "# Release notes",
28
+ "## Highlights",
29
+ "Shipped **bold wins** and some _italic nuance_ this cycle.",
30
+ "The old flag is ~~deprecated~~ now.",
31
+ "Call `guardAccidentalFormatting` at the seam.",
32
+ "",
33
+ "```ts",
34
+ "const x = renderRich(markdown)",
35
+ "return x",
36
+ "```",
37
+ "",
38
+ "See the [rendering guide](https://ex.com/a#frag) for details.",
39
+ "",
40
+ "> This is a genuine blockquote from the design note.",
41
+ "",
42
+ "Unordered:",
43
+ "- first point",
44
+ "- second point",
45
+ "",
46
+ "Ordered:",
47
+ "1. plan",
48
+ "2. build",
49
+ "3. ship",
50
+ ].join("\n");
51
+
52
+ describe("guardAccidentalFormatting — full rich corpus passes the seam byte-identical (#3465)", () => {
53
+ it("leaves a complete intended-formatting corpus completely untouched", () => {
54
+ expect(guardAccidentalFormatting(RICH_CORPUS)).toBe(RICH_CORPUS);
55
+ });
56
+
57
+ it("is idempotent on the rich corpus (seam re-application is a strict no-op)", () => {
58
+ const once = guardAccidentalFormatting(RICH_CORPUS);
59
+ expect(guardAccidentalFormatting(once)).toBe(once);
60
+ });
61
+ });
62
+
63
+ describe("guardAccidentalFormatting — the untouched result is the guard working, not disabled (#3465)", () => {
64
+ it("STILL escapes a line-leading `#3460` present in the same corpus", () => {
65
+ // Inject an accidental Telegram-only heading (`#3460`, glued to a digit, no
66
+ // space) into the corpus. The seam MUST escape it — proving the byte-identical
67
+ // pass above is the guard correctly finding no accidental signal, not the
68
+ // guard being a no-op / kill-switched.
69
+ const corpusWithGluedHash = RICH_CORPUS + "\n#3460 is the tracking issue";
70
+ const out = guardAccidentalFormatting(corpusWithGluedHash);
71
+ // The rest of the corpus is unchanged; only the glued hash is escaped.
72
+ expect(out).toBe(RICH_CORPUS + "\n\\#3460 is the tracking issue");
73
+ // And the reader-visible text (backslash consumed by Telegram) is intact.
74
+ expect(out.replace(/\\#/g, "#")).toBe(corpusWithGluedHash);
75
+ });
76
+ });
@@ -32,6 +32,7 @@
32
32
  import { describe, it, expect } from 'vitest'
33
33
  import {
34
34
  resolveReplyOwnerTurnId,
35
+ resolveReplyOwnerTier,
35
36
  decideAnswerLatchSuppression,
36
37
  type ReplyOwnerCandidates,
37
38
  type AnswerDeliveredLatch,
@@ -611,3 +612,76 @@ describe('#3429 — flush-armed latch with content evidence', () => {
611
612
  ).toBe(false)
612
613
  })
613
614
  })
615
+
616
+ /**
617
+ * `resolveReplyOwnerTier` (fix/backstop-duplicate-reply) exposes WHICH precedence
618
+ * tier won, so the gateway can apply the #3429 content gate ONLY on the ambiguous
619
+ * `latest-ended` fallback. It shares one precedence with `resolveReplyOwnerTurnId`
620
+ * (asserted equivalent below), so the winning id and the winning tier can never
621
+ * disagree.
622
+ */
623
+ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () => {
624
+ const base: ReplyOwnerCandidates = {
625
+ liveTurnId: null,
626
+ originTurnId: null,
627
+ quotedTurnId: null,
628
+ latestEndedTurnId: null,
629
+ }
630
+
631
+ it('live wins over every lower tier', () => {
632
+ expect(
633
+ resolveReplyOwnerTier({ ...base, liveTurnId: 'L', originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' }),
634
+ ).toBe('live')
635
+ })
636
+
637
+ it('origin wins when no live turn', () => {
638
+ expect(resolveReplyOwnerTier({ ...base, originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('origin')
639
+ })
640
+
641
+ it('quoted wins when no live/origin turn (the positive DM recovery tier)', () => {
642
+ expect(resolveReplyOwnerTier({ ...base, quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('quoted')
643
+ })
644
+
645
+ it('latest-ended is the ambiguous FALLBACK when only it resolves', () => {
646
+ expect(resolveReplyOwnerTier({ ...base, latestEndedTurnId: 'E' })).toBe('latest-ended')
647
+ })
648
+
649
+ it('none when every lookup missed', () => {
650
+ expect(resolveReplyOwnerTier(base)).toBe('none')
651
+ })
652
+
653
+ it('a STALE latest-ended (age > TTL) is NOT accepted → none, never latest-ended', () => {
654
+ const stale: ReplyOwnerCandidates = {
655
+ ...base,
656
+ latestEndedTurnId: 'E',
657
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
658
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
659
+ }
660
+ expect(resolveReplyOwnerTier(stale)).toBe('none')
661
+ // And the id resolver agrees (no destructive authority granted to a stale turn).
662
+ expect(resolveReplyOwnerTurnId(stale)).toBe(null)
663
+ })
664
+
665
+ it('tier and id resolver share one precedence for every candidate shape', () => {
666
+ const shapes: ReplyOwnerCandidates[] = [
667
+ base,
668
+ { ...base, liveTurnId: 'L', quotedTurnId: 'Q', latestEndedTurnId: 'E' },
669
+ { ...base, originTurnId: 'O', latestEndedTurnId: 'E' },
670
+ { ...base, quotedTurnId: 'Q' },
671
+ { ...base, latestEndedTurnId: 'E' },
672
+ ]
673
+ const idForTier = (s: ReplyOwnerCandidates): string | null => {
674
+ switch (resolveReplyOwnerTier(s)) {
675
+ case 'live': return s.liveTurnId
676
+ case 'origin': return s.originTurnId
677
+ case 'quoted': return s.quotedTurnId
678
+ case 'latest-ended': return s.latestEndedTurnId
679
+ case 'none': return null
680
+ }
681
+ }
682
+ for (const s of shapes) {
683
+ // The id the winning tier points at equals what the id resolver returns.
684
+ expect(resolveReplyOwnerTurnId(s)).toBe(idForTier(s))
685
+ }
686
+ })
687
+ })