switchroom 0.19.3 → 0.19.5

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 (32) hide show
  1. package/dist/auth-broker/index.js +104 -11
  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 +524 -293
  8. package/telegram-plugin/gateway/command-format.ts +253 -0
  9. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  10. package/telegram-plugin/gateway/gateway.ts +97 -255
  11. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  12. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  13. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  14. package/telegram-plugin/gateway/stream-render.ts +18 -1
  15. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  16. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  17. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  18. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  19. package/telegram-plugin/render/line-start-guard.ts +76 -4
  20. package/telegram-plugin/rich-send.ts +8 -1
  21. package/telegram-plugin/tests/command-format.test.ts +212 -0
  22. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  23. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  24. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  25. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  26. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  27. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  29. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  30. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  31. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  32. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Gateway liveness heartbeat writer (duplicate-message fix).
3
+ *
4
+ * The Stop hook's single-writer election only ALLOWS a stop (handing delivery
5
+ * to the gateway) when the gateway heartbeat file is fresh. This pins the
6
+ * writer: it creates the file, bumps its mtime on subsequent touches, and the
7
+ * hook's freshness reader agrees on the filename + bound.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest'
11
+ import { mkdtempSync, existsSync, rmSync, utimesSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { tmpdir } from 'node:os'
14
+ import {
15
+ touchGatewayHeartbeat,
16
+ startGatewayHeartbeat,
17
+ GATEWAY_HEARTBEAT_FILE,
18
+ } from '../gateway/gateway-heartbeat.js'
19
+ import {
20
+ isGatewayHeartbeatFresh,
21
+ GATEWAY_HEARTBEAT_FILE as HOOK_HEARTBEAT_FILE,
22
+ } from '../hooks/silent-end-scan.mjs'
23
+
24
+ describe('gateway-heartbeat writer', () => {
25
+ it('creates the heartbeat file on first touch under a fresh state dir', () => {
26
+ const dir = mkdtempSync(join(tmpdir(), 'gw-hb-w-'))
27
+ try {
28
+ const path = join(dir, GATEWAY_HEARTBEAT_FILE)
29
+ expect(existsSync(path)).toBe(false)
30
+ touchGatewayHeartbeat(dir)
31
+ expect(existsSync(path)).toBe(true)
32
+ // The hook's freshness reader agrees it's fresh.
33
+ expect(isGatewayHeartbeatFresh(dir)).toBe(true)
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true })
36
+ }
37
+ })
38
+
39
+ it('refreshes an aged heartbeat back to fresh on a subsequent touch', () => {
40
+ const dir = mkdtempSync(join(tmpdir(), 'gw-hb-w-'))
41
+ try {
42
+ const path = join(dir, GATEWAY_HEARTBEAT_FILE)
43
+ touchGatewayHeartbeat(dir)
44
+ // Backdate the mtime to stale, then touch again → back to fresh.
45
+ const old = new Date(Date.now() - 10 * 60_000)
46
+ utimesSync(path, old, old)
47
+ expect(isGatewayHeartbeatFresh(dir)).toBe(false)
48
+ touchGatewayHeartbeat(dir)
49
+ expect(isGatewayHeartbeatFresh(dir)).toBe(true)
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true })
52
+ }
53
+ })
54
+
55
+ it('startGatewayHeartbeat writes immediately and returns an unref-able timer', () => {
56
+ const dir = mkdtempSync(join(tmpdir(), 'gw-hb-w-'))
57
+ try {
58
+ const timer = startGatewayHeartbeat(dir, 60_000)
59
+ expect(existsSync(join(dir, GATEWAY_HEARTBEAT_FILE))).toBe(true)
60
+ clearInterval(timer)
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true })
63
+ }
64
+ })
65
+
66
+ it('the writer filename and the hook reader filename are identical (no drift)', () => {
67
+ expect(GATEWAY_HEARTBEAT_FILE).toBe(HOOK_HEARTBEAT_FILE)
68
+ expect(GATEWAY_HEARTBEAT_FILE).toBe('gateway-heartbeat')
69
+ })
70
+ })
@@ -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
+ })
@@ -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
+ });
@@ -296,4 +296,67 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
296
296
  expect(r.status).toBe(0)
297
297
  expect(r.stdout.trim()).toBe('')
298
298
  })
299
+
300
+ // ── Single-writer election (duplicate-message fix) ─────────────────
301
+ describe('single-writer election end-to-end', () => {
302
+ function writeFreshHeartbeat() {
303
+ writeFileSync(join(stateDir, 'gateway-heartbeat'), String(Date.now()), 'utf8')
304
+ }
305
+
306
+ it('zero-reply ≥200 + FRESH gateway heartbeat → ALLOW (no block re-prompt), state file still written for the flush', () => {
307
+ // The duplicate repro: today this BLOCKS *and* the gateway flush fires →
308
+ // two messages. With a fresh heartbeat the election ALLOWS the stop so
309
+ // the gateway flush is the single writer.
310
+ writeFreshHeartbeat()
311
+ const transcript = writeTranscript(tmp, [
312
+ ENQUEUE,
313
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
314
+ ])
315
+ const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
316
+ expect(r.status).toBe(0)
317
+ // ALLOW → no block JSON on stdout.
318
+ expect(r.stdout.trim()).toBe('')
319
+ expect(r.stderr).toMatch(/single-writer election ALLOWED/)
320
+ // State file IS written so the gateway's captured-prose bridge has its
321
+ // input (turnKey/turnId/pendingText); retryCount stays 0 (not a re-prompt).
322
+ const statePath = join(stateDir, 'silent-end-pending.json')
323
+ expect(existsSync(statePath)).toBe(true)
324
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
325
+ expect(state.retryCount).toBe(0)
326
+ expect(state.turnKey).toBe('111:_')
327
+ expect(state.pendingText).toBe('A'.repeat(300))
328
+ })
329
+
330
+ it('zero-reply ≥200 + STALE/missing heartbeat → BLOCK (never allow into a possibly-dead gateway)', () => {
331
+ // No heartbeat file → the liveness gate forces today's BLOCK behaviour.
332
+ const transcript = writeTranscript(tmp, [
333
+ ENQUEUE,
334
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
335
+ ])
336
+ const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
337
+ expect(r.status).toBe(0)
338
+ expect(JSON.parse(r.stdout).decision).toBe('block')
339
+ const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
340
+ expect(state.retryCount).toBe(1)
341
+ })
342
+
343
+ it('retryCount>0 (prior failed delivery) + fresh heartbeat → BLOCK (preserve #3228 send-failure net)', () => {
344
+ writeFreshHeartbeat()
345
+ // Seed a prior state file with retryCount=1 (a delivery already failed).
346
+ writeFileSync(
347
+ join(stateDir, 'silent-end-pending.json'),
348
+ JSON.stringify({ chatId: '111', threadId: null, turnKey: '111:_', retryCount: 1, timestamp: Date.now() }),
349
+ 'utf8',
350
+ )
351
+ const transcript = writeTranscript(tmp, [
352
+ ENQUEUE,
353
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
354
+ ])
355
+ const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
356
+ expect(r.status).toBe(0)
357
+ // retryCount was 1 → not the exhaustion boundary (MAX=2) → still blocks,
358
+ // and the election does NOT allow (retry-ladder-in-flight).
359
+ expect(JSON.parse(r.stdout).decision).toBe('block')
360
+ })
361
+ })
299
362
  })