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.
- package/dist/auth-broker/index.js +7 -3
- 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 +585 -302
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +128 -259
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- 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/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -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/narrative-lane-golden.test.ts +2 -1
- 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/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- 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/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
- 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
|
+
})
|