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.
- package/dist/auth-broker/index.js +104 -11
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- 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
|
})
|