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
|
@@ -43,11 +43,20 @@
|
|
|
43
43
|
// punctuation may be backslash-escaped" rule; Telegram's rich parser strips
|
|
44
44
|
// the backslash the same way `escapeMarkdown` relies on for `~ = | * _`.
|
|
45
45
|
//
|
|
46
|
+
// ── Accidental heading (`#3460`, `#foo`) — guarded by guardAccidentalHeading ─
|
|
47
|
+
// Telegram's Bot API rich-markdown parser is NON-spec: it promotes ANY
|
|
48
|
+
// line-leading `#{1,6}` run to a heading even WITHOUT the CommonMark-required
|
|
49
|
+
// trailing space (`#3460 done` renders as a huge heading). This is
|
|
50
|
+
// deterministically disambiguable: a `#{1,6}` run followed by a space,
|
|
51
|
+
// another `#`, or end-of-line is an INTENDED heading (or ambiguous) and is
|
|
52
|
+
// LEFT ALONE; a run glued to a non-space, non-`#` char (`#3460`, `#foo`,
|
|
53
|
+
// `###x`) is the accidental Telegram-only heading and IS escaped. See
|
|
54
|
+
// `guardAccidentalHeading` below — it mirrors the render.ts:escapeLineLeadingHash
|
|
55
|
+
// fix (#3306) but runs at the universal wire seam, independent of the
|
|
56
|
+
// SWITCHROOM_RICH_RENDER kill-switch, so cards/banners/status/approval sends
|
|
57
|
+
// (which bypass the rich renderer) are also covered.
|
|
58
|
+
//
|
|
46
59
|
// ── What is DEFERRED (left to the rich-formatting workstream) ────────────
|
|
47
|
-
// • Heading `# ` (with the required space): indistinguishable from an
|
|
48
|
-
// INTENDED heading. `#1` / `#foo` (NO space) is not a GFM heading at all
|
|
49
|
-
// (ATX headings require `#`+space) → Telegram renders it literally → no
|
|
50
|
-
// guard needed. So there is no safely-guardable heading sub-case.
|
|
51
60
|
// • Bullet lists `-`/`+`/`*` + space (`- 5 degrees`): genuinely ambiguous
|
|
52
61
|
// with the heavily-used bullet construct; the glued form `-5` (no space)
|
|
53
62
|
// is not a list item → already literal → no guard needed. Escaping the
|
|
@@ -165,3 +174,66 @@ export function guardAccidentalBlockConstructs(text: string): string {
|
|
|
165
174
|
|
|
166
175
|
return out;
|
|
167
176
|
}
|
|
177
|
+
|
|
178
|
+
/** Line-start `#{1,6}` run glued DIRECTLY to a non-space, non-`#` char — the
|
|
179
|
+
* Telegram-non-spec accidental heading (`#3460`, `#foo`, `###x`). The negative
|
|
180
|
+
* lookahead `(?=[^\s#])` requires a following char that is neither whitespace,
|
|
181
|
+
* a `#`, nor end-of-line, so an intended `# Title` / `## Sub` (space-delimited)
|
|
182
|
+
* and a bare `#` / `##` (end-of-line) are NEVER matched. Up to 3 leading spaces
|
|
183
|
+
* are permitted (4+ is indented code, and the `{0,3}` bound then leaves a space
|
|
184
|
+
* before the `#`, so it correctly does not match). Only the first `#` of the run
|
|
185
|
+
* is backslash-escaped, which is sufficient to stop Telegram promoting the line
|
|
186
|
+
* to a heading. Idempotent: an already-escaped `\#3460` starts with `\`, so the
|
|
187
|
+
* `#{1,6}` no longer sits at the (post-indent) line start. */
|
|
188
|
+
const ACCIDENTAL_HEADING = /^([ \t]{0,3})(#{1,6})(?=[^\s#])/;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Escape the accidental heading trigger at the start of ONE line (the string
|
|
192
|
+
* must NOT contain a newline; the caller guarantees a true line start). A no-op
|
|
193
|
+
* unless the line begins with a `#{1,6}` run glued to a non-space, non-`#` char.
|
|
194
|
+
*/
|
|
195
|
+
function escapeAccidentalHeadingLine(line: string): string {
|
|
196
|
+
return line.replace(ACCIDENTAL_HEADING, "$1\\$2");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Neutralise accidental line-start HEADING promotion (`#3460 done` → giant
|
|
201
|
+
* heading) on the FINAL rendered rich-markdown string. Telegram's Bot API
|
|
202
|
+
* parser promotes a line-leading `#{1,6}` run to a heading even without the
|
|
203
|
+
* CommonMark-required trailing space; this escapes ONLY the space-less,
|
|
204
|
+
* non-`#`-adjacent form and leaves genuine `# Title` / `## Sub` headings
|
|
205
|
+
* untouched. Code spans / fenced blocks / link destinations / table rows are
|
|
206
|
+
* never touched (shared `splitProtectedSegments`). Idempotent and a strict
|
|
207
|
+
* no-op absent a real signal. Mirrors render.ts:escapeLineLeadingHash (#3306)
|
|
208
|
+
* but runs at the universal wire seam, so it also covers the card / banner /
|
|
209
|
+
* status / approval sends that bypass the rich renderer.
|
|
210
|
+
*/
|
|
211
|
+
export function guardAccidentalHeading(text: string): string {
|
|
212
|
+
// Cheap short-circuit: no `#` at all => no-op.
|
|
213
|
+
if (!text.includes("#")) return text;
|
|
214
|
+
|
|
215
|
+
const segments = splitProtectedSegments(text);
|
|
216
|
+
let out = "";
|
|
217
|
+
// True at text start and immediately after any emitted `\n`.
|
|
218
|
+
let atLineStart = true;
|
|
219
|
+
|
|
220
|
+
for (const seg of segments) {
|
|
221
|
+
if (seg.code) {
|
|
222
|
+
out += seg.text;
|
|
223
|
+
atLineStart = seg.text.endsWith("\n");
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const lines = seg.text.split("\n");
|
|
227
|
+
for (let k = 0; k < lines.length; k++) {
|
|
228
|
+
// A prose segment can begin MID-LINE (right after an inline code span),
|
|
229
|
+
// so its first line is a real line start only if the running flag says so.
|
|
230
|
+
const lineIsAtStart = k === 0 ? atLineStart : true;
|
|
231
|
+
const processed = lineIsAtStart ? escapeAccidentalHeadingLine(lines[k]) : lines[k];
|
|
232
|
+
out += processed;
|
|
233
|
+
if (k < lines.length - 1) out += "\n";
|
|
234
|
+
}
|
|
235
|
+
atLineStart = seg.text.endsWith("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
@@ -84,6 +84,37 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
|
84
84
|
return age <= ttl
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Which precedence tier resolved a reply's owner turn.
|
|
89
|
+
*
|
|
90
|
+
* - `'live'` / `'origin'` / `'quoted'` are POSITIVE attributions: the reply
|
|
91
|
+
* is tied to a specific turn by a live atom, the model's own echo, or the
|
|
92
|
+
* framework-owned quoted message id — it IS that turn's answer.
|
|
93
|
+
* - `'latest-ended'` is the ambiguous FALLBACK: no positive link exists, so
|
|
94
|
+
* the reply is merely attributed to the chat's most-recently-ended turn.
|
|
95
|
+
* This tier cannot tell the turn's OWN late reply from an async sub-agent
|
|
96
|
+
* handback that has no live turn and no quote linkage — both land here with
|
|
97
|
+
* the same resolved turnId (#3429). The supersede content gate
|
|
98
|
+
* (`flushedAnswerMatchesReply`) is therefore applied ONLY on this tier.
|
|
99
|
+
* - `'none'` — every lookup missed (a genuinely unattributable reply).
|
|
100
|
+
*/
|
|
101
|
+
export type ReplyOwnerTier = 'live' | 'origin' | 'quoted' | 'latest-ended' | 'none'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The tier that WINS owner resolution for these candidates — the single source
|
|
105
|
+
* of the precedence order, consumed by both `resolveReplyOwnerTurnId` (which id)
|
|
106
|
+
* and the gateway (which discrimination the supersede applies). Same precedence,
|
|
107
|
+
* first non-null wins; the destructive latest-ended tier is honoured only when
|
|
108
|
+
* fresh (`latestEndedAccepted`).
|
|
109
|
+
*/
|
|
110
|
+
export function resolveReplyOwnerTier(candidates: ReplyOwnerCandidates): ReplyOwnerTier {
|
|
111
|
+
if (candidates.liveTurnId != null) return 'live'
|
|
112
|
+
if (candidates.originTurnId != null) return 'origin'
|
|
113
|
+
if (candidates.quotedTurnId != null) return 'quoted'
|
|
114
|
+
if (latestEndedAccepted(candidates)) return 'latest-ended'
|
|
115
|
+
return 'none'
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
/**
|
|
88
119
|
* Resolve the turnId that OWNS a landing reply, using the SAME full chain the
|
|
89
120
|
* thread-router uses. Precedence, first non-null wins:
|
|
@@ -98,13 +129,18 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
|
98
129
|
* to delete any turnId-bearing flush record.
|
|
99
130
|
*/
|
|
100
131
|
export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): string | null {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
132
|
+
switch (resolveReplyOwnerTier(candidates)) {
|
|
133
|
+
case 'live':
|
|
134
|
+
return candidates.liveTurnId
|
|
135
|
+
case 'origin':
|
|
136
|
+
return candidates.originTurnId
|
|
137
|
+
case 'quoted':
|
|
138
|
+
return candidates.quotedTurnId
|
|
139
|
+
case 'latest-ended':
|
|
140
|
+
return candidates.latestEndedTurnId
|
|
141
|
+
case 'none':
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
108
144
|
}
|
|
109
145
|
|
|
110
146
|
/**
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { GrammyError } from 'grammy'
|
|
21
21
|
import { guardDollarMath } from './render/dollar-math-guard.js'
|
|
22
22
|
import { guardAccidentalEmphasis } from './render/emphasis-guard.js'
|
|
23
|
-
import { guardAccidentalBlockConstructs } from './render/line-start-guard.js'
|
|
23
|
+
import { guardAccidentalBlockConstructs, guardAccidentalHeading } from './render/line-start-guard.js'
|
|
24
24
|
import { guardAccidentalInlinePairs } from './render/inline-pairs-guard.js'
|
|
25
25
|
|
|
26
26
|
/** The `InputRichMessage` shape grammy 1.44 accepts on send AND edit. */
|
|
@@ -36,6 +36,12 @@ export interface InputRichMessageMarkdown {
|
|
|
36
36
|
* idempotent and safe to apply once per send.
|
|
37
37
|
*
|
|
38
38
|
* ── Ordering (deliberate, not arbitrary) ──────────────────────────────────
|
|
39
|
+
* `guardAccidentalHeading` runs right after emphasis and before block-constructs.
|
|
40
|
+
* It only ever inserts `\` before a line-leading `#{1,6}` run; `#` is inspected
|
|
41
|
+
* and inserted by no other guard (disjoint char set), so its position among the
|
|
42
|
+
* siblings is order-independent — it neither creates nor destroys a signal for
|
|
43
|
+
* any of them.
|
|
44
|
+
*
|
|
39
45
|
* `guardDollarMath` runs LAST. It backslash-escapes `$` → `\$`, and the
|
|
40
46
|
* inline-pairs guard's approximation-tilde signal is `~(?=\$?\.?\d)` — a `~`
|
|
41
47
|
* glued to a `$digit`. If the dollar guard ran first, a body like
|
|
@@ -51,6 +57,7 @@ export interface InputRichMessageMarkdown {
|
|
|
51
57
|
export function guardAccidentalFormatting(markdown: string): string {
|
|
52
58
|
let out = markdown
|
|
53
59
|
out = guardAccidentalEmphasis(out)
|
|
60
|
+
out = guardAccidentalHeading(out)
|
|
54
61
|
out = guardAccidentalBlockConstructs(out)
|
|
55
62
|
out = guardAccidentalInlinePairs(out)
|
|
56
63
|
out = guardDollarMath(out)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct unit tests for `gateway/command-format.ts` — the pure
|
|
3
|
+
* slash-command formatting/keyboard helpers extracted from gateway.ts
|
|
4
|
+
* in switchroom#3461 (testability win tracked by #3460).
|
|
5
|
+
*
|
|
6
|
+
* These assert real rendered outcomes (exact strings, keyboard shapes,
|
|
7
|
+
* thrown errors), not just that the code runs.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
formatSwitchroomOutput,
|
|
12
|
+
stripAnsi,
|
|
13
|
+
escapeHtmlForTg,
|
|
14
|
+
preBlock,
|
|
15
|
+
getCommandArgs,
|
|
16
|
+
hasDemoFlag,
|
|
17
|
+
assertSafeAgentName,
|
|
18
|
+
formatAuthOutputForTelegram,
|
|
19
|
+
buildAuthUrlKeyboard,
|
|
20
|
+
buildDeferredSecretKeyboard,
|
|
21
|
+
renderVaultOpFailure,
|
|
22
|
+
statusIcon,
|
|
23
|
+
renderAuthCodeOutcome,
|
|
24
|
+
buildDoctorScopeKeyboard,
|
|
25
|
+
formatDoctorReport,
|
|
26
|
+
} from '../gateway/command-format.js'
|
|
27
|
+
import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
28
|
+
import type { Context } from 'grammy'
|
|
29
|
+
|
|
30
|
+
describe('formatSwitchroomOutput', () => {
|
|
31
|
+
it('trims and passes short output through unchanged', () => {
|
|
32
|
+
expect(formatSwitchroomOutput(' hello\nworld ')).toBe('hello\nworld')
|
|
33
|
+
})
|
|
34
|
+
it('truncates past the rich-message cap with a marker', () => {
|
|
35
|
+
const big = 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 100)
|
|
36
|
+
const out = formatSwitchroomOutput(big)
|
|
37
|
+
expect(out.endsWith('\n... (truncated)')).toBe(true)
|
|
38
|
+
expect(out.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
|
|
39
|
+
})
|
|
40
|
+
it('honours an explicit maxLen', () => {
|
|
41
|
+
expect(formatSwitchroomOutput('abcdefghij'.repeat(10), 50)).toBe(
|
|
42
|
+
'abcdefghij'.repeat(10).slice(0, 30) + '\n... (truncated)',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('stripAnsi', () => {
|
|
48
|
+
it('removes CSI escape sequences and keeps text', () => {
|
|
49
|
+
expect(stripAnsi('\x1b[31mred\x1b[0m plain')).toBe('red plain')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('escapeHtmlForTg', () => {
|
|
54
|
+
it('backslash-escapes GFM specials', () => {
|
|
55
|
+
expect(escapeHtmlForTg('a*b_c`d[e]f|g~h=i\\j')).toBe(
|
|
56
|
+
'a\\*b\\_c\\`d\\[e\\]f\\|g\\~h\\=i\\\\j',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
it('leaves ordinary text alone', () => {
|
|
60
|
+
expect(escapeHtmlForTg('hello world 123')).toBe('hello world 123')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('preBlock', () => {
|
|
65
|
+
it('wraps content in a fenced code block', () => {
|
|
66
|
+
expect(preBlock('ls -la')).toBe('```\nls -la\n```')
|
|
67
|
+
})
|
|
68
|
+
it('defuses embedded triple-backticks so the fence cannot break', () => {
|
|
69
|
+
const out = preBlock('a```b')
|
|
70
|
+
expect(out.startsWith('```\n')).toBe(true)
|
|
71
|
+
expect(out.endsWith('\n```')).toBe(true)
|
|
72
|
+
// interior fence broken up (zero-width space injected)
|
|
73
|
+
expect(out.slice(4, -4)).not.toContain('```')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('getCommandArgs', () => {
|
|
78
|
+
it('prefers ctx.match when present', () => {
|
|
79
|
+
const ctx = { match: ' foo bar ', msg: { text: '/cmd other' } } as unknown as Context
|
|
80
|
+
expect(getCommandArgs(ctx)).toBe('foo bar')
|
|
81
|
+
})
|
|
82
|
+
it('falls back to parsing the message text', () => {
|
|
83
|
+
const ctx = { match: undefined, msg: { text: '/restart alpha beta' } } as unknown as Context
|
|
84
|
+
expect(getCommandArgs(ctx)).toBe('alpha beta')
|
|
85
|
+
})
|
|
86
|
+
it('returns empty string for a bare command', () => {
|
|
87
|
+
const ctx = { match: '', msg: { text: '/status' } } as unknown as Context
|
|
88
|
+
expect(getCommandArgs(ctx)).toBe('')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('hasDemoFlag', () => {
|
|
93
|
+
it('matches trailing demo token case-insensitively', () => {
|
|
94
|
+
expect(hasDemoFlag('demo')).toBe(true)
|
|
95
|
+
expect(hasDemoFlag('show DEMO')).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
it('does not match demo as a prefix of another token', () => {
|
|
98
|
+
expect(hasDemoFlag('demo-foo')).toBe(false)
|
|
99
|
+
expect(hasDemoFlag('demo mid')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('assertSafeAgentName', () => {
|
|
104
|
+
it('accepts alphanumeric/hyphen/underscore names and the all keyword', () => {
|
|
105
|
+
expect(() => assertSafeAgentName('my-agent_01')).not.toThrow()
|
|
106
|
+
expect(() => assertSafeAgentName('all')).not.toThrow()
|
|
107
|
+
})
|
|
108
|
+
it('rejects shell metacharacters and over-long names', () => {
|
|
109
|
+
expect(() => assertSafeAgentName('a;rm -rf')).toThrow(/invalid agent name/)
|
|
110
|
+
expect(() => assertSafeAgentName('x'.repeat(65))).toThrow(/invalid agent name/)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('formatAuthOutputForTelegram', () => {
|
|
115
|
+
it('returns a plain pre-block when no URL is present', () => {
|
|
116
|
+
const { text, url } = formatAuthOutputForTelegram('\x1b[32mno link here\x1b[0m')
|
|
117
|
+
expect(url).toBeNull()
|
|
118
|
+
expect(text).toBe('```\nno link here\n```')
|
|
119
|
+
})
|
|
120
|
+
it('extracts the URL, drops CLI hint lines, and appends mobile guidance', () => {
|
|
121
|
+
const raw = [
|
|
122
|
+
'Started Claude auth for agent overlord',
|
|
123
|
+
'Open this URL to authorize:',
|
|
124
|
+
'https://claude.ai/oauth/authorize?code=abc123',
|
|
125
|
+
'switchroom auth code overlord <code>',
|
|
126
|
+
'Cancel with: switchroom auth cancel overlord',
|
|
127
|
+
].join('\n')
|
|
128
|
+
const { text, url } = formatAuthOutputForTelegram(raw)
|
|
129
|
+
expect(url).toBe('https://claude.ai/oauth/authorize?code=abc123')
|
|
130
|
+
expect(text).not.toContain('switchroom auth code')
|
|
131
|
+
expect(text).not.toContain('Cancel with:')
|
|
132
|
+
expect(text).toContain('**Started Claude auth for agent overlord**')
|
|
133
|
+
expect(text).toContain('_Open this URL to authorize:_')
|
|
134
|
+
expect(text.trimEnd().endsWith(url as string)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('keyboards', () => {
|
|
139
|
+
it('buildAuthUrlKeyboard produces a single url button', () => {
|
|
140
|
+
const kb = buildAuthUrlKeyboard('https://example.com/x')
|
|
141
|
+
const rows = kb.inline_keyboard
|
|
142
|
+
expect(rows).toHaveLength(1)
|
|
143
|
+
expect(rows[0]).toHaveLength(1)
|
|
144
|
+
expect(rows[0][0]).toMatchObject({ text: '🔐 Open Claude auth', url: 'https://example.com/x' })
|
|
145
|
+
})
|
|
146
|
+
it('buildDeferredSecretKeyboard encodes unlock/cancel callback_data', () => {
|
|
147
|
+
const kb = buildDeferredSecretKeyboard('123:456')
|
|
148
|
+
const row = kb.inline_keyboard[0]
|
|
149
|
+
expect(row.map(b => (b as { callback_data?: string }).callback_data)).toEqual([
|
|
150
|
+
'vd:unlock:123:456',
|
|
151
|
+
'vd:cancel:123:456',
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
it('buildDeferredSecretKeyboard throws on a 64-byte callback_data overflow', () => {
|
|
155
|
+
expect(() => buildDeferredSecretKeyboard('9'.repeat(80))).toThrow(/callback_data overflow/)
|
|
156
|
+
})
|
|
157
|
+
it('buildDoctorScopeKeyboard offers fleet and self scopes', () => {
|
|
158
|
+
const row = buildDoctorScopeKeyboard().inline_keyboard[0]
|
|
159
|
+
expect(row.map(b => (b as { callback_data?: string }).callback_data)).toEqual([
|
|
160
|
+
'dr:fleet',
|
|
161
|
+
'dr:self',
|
|
162
|
+
])
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('renderVaultOpFailure', () => {
|
|
167
|
+
it('falls back to a raw pre-block for unrecognised CLI output', () => {
|
|
168
|
+
const out = renderVaultOpFailure('get', 'some random failure', 'mykey')
|
|
169
|
+
expect(out).toBe('**vault get failed:**\n```\nsome random failure\n```')
|
|
170
|
+
})
|
|
171
|
+
it('routes recognised broker-denied markers through the structured renderer', () => {
|
|
172
|
+
const out = renderVaultOpFailure('get', 'VAULT-BROKER-DENIED: no grant for key', 'mykey')
|
|
173
|
+
expect(out).not.toContain('**vault get failed:**')
|
|
174
|
+
expect(out.length).toBeGreaterThan(0)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('statusIcon', () => {
|
|
179
|
+
it('maps statuses to glyphs', () => {
|
|
180
|
+
expect(statusIcon('running')).toBe('🟢')
|
|
181
|
+
expect(statusIcon('active')).toBe('🟢')
|
|
182
|
+
expect(statusIcon('stopped')).toBe('🔴')
|
|
183
|
+
expect(statusIcon('failed')).toBe('⚠️')
|
|
184
|
+
expect(statusIcon('whatever')).toBe('⚪')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('renderAuthCodeOutcome', () => {
|
|
189
|
+
it('returns null for success or missing outcome', () => {
|
|
190
|
+
expect(renderAuthCodeOutcome(null)).toBeNull()
|
|
191
|
+
expect(renderAuthCodeOutcome({ kind: 'success' } as never)).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
it('renders invalid-code with escaped pane tail', () => {
|
|
194
|
+
const out = renderAuthCodeOutcome({ kind: 'invalid-code', paneTailText: 'bad*code' } as never)
|
|
195
|
+
expect(out).toContain('Code rejected by Claude')
|
|
196
|
+
expect(out).toContain('bad\\*code')
|
|
197
|
+
})
|
|
198
|
+
it('renders pane-not-ready and timeout variants', () => {
|
|
199
|
+
expect(renderAuthCodeOutcome({ kind: 'pane-not-ready' } as never)).toContain('Auth pane not ready')
|
|
200
|
+
expect(renderAuthCodeOutcome({ kind: 'timeout' } as never)).toContain('Still waiting after 2 min')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('formatDoctorReport', () => {
|
|
205
|
+
it('swaps check glyphs for traffic lights inside a pre block', () => {
|
|
206
|
+
const raw = '\x1b[32m✓ ok check\x1b[0m\n✗ bad check\n! warn check'
|
|
207
|
+
expect(formatDoctorReport(raw)).toBe('```\n🟢 ok check\n🔴 bad check\n🟡 warn check\n```')
|
|
208
|
+
})
|
|
209
|
+
it('handles empty output', () => {
|
|
210
|
+
expect(formatDoctorReport(' \x1b[0m ')).toBe('doctor: no output')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -381,3 +381,92 @@ describe('#3429 — decideSupersede new-content gate', () => {
|
|
|
381
381
|
expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
|
|
382
382
|
})
|
|
383
383
|
})
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The tier discriminator (fix/backstop-duplicate-reply). `positiveAttribution`
|
|
387
|
+
* lets the gateway bypass the #3429 content gate when the reply's owner turn was
|
|
388
|
+
* resolved by POSITIVE attribution (live / origin echo / quoted) rather than the
|
|
389
|
+
* ambiguous latest-ended fallback. On a positive tier a genuinely-DIFFERENT
|
|
390
|
+
* (reworded) reply for the same turn still supersedes — closing the reworded
|
|
391
|
+
* same-turn duplicate — while the latest-ended path keeps the content gate so a
|
|
392
|
+
* true async handback still sends fresh.
|
|
393
|
+
*/
|
|
394
|
+
describe('positiveAttribution — tier-gated content check', () => {
|
|
395
|
+
const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
|
|
396
|
+
const REWORDED =
|
|
397
|
+
'So, to wrap up: the incident boiled down to a stale cache entry, and it is fully resolved now.'
|
|
398
|
+
|
|
399
|
+
it('positiveAttribution=true: same turn + DIFFERENT (reworded) content STILL supersedes', () => {
|
|
400
|
+
// Guard: the reworded text is genuinely new vs the flushed blob (would be
|
|
401
|
+
// declined new-content without the tier bypass).
|
|
402
|
+
expect(flushedAnswerMatchesReply(FLUSHED, REWORDED)).toBe(false)
|
|
403
|
+
|
|
404
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
405
|
+
liveTurnId: 'turn-A',
|
|
406
|
+
replyText: REWORDED,
|
|
407
|
+
positiveAttribution: true,
|
|
408
|
+
now: 1_000_010,
|
|
409
|
+
})
|
|
410
|
+
expect(d.supersede).toBe(true)
|
|
411
|
+
expect(d.reason).toBe('supersede')
|
|
412
|
+
expect(d.deleteMessageIds).toEqual([101, 102])
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('positiveAttribution=false (latest-ended): the SAME reworded content declines as new-content', () => {
|
|
416
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
417
|
+
liveTurnId: 'turn-A',
|
|
418
|
+
replyText: REWORDED,
|
|
419
|
+
positiveAttribution: false,
|
|
420
|
+
now: 1_000_010,
|
|
421
|
+
})
|
|
422
|
+
expect(d.supersede).toBe(false)
|
|
423
|
+
expect(d.reason).toBe('new-content')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('positiveAttribution=true still requires turn IDENTITY (different turn never supersedes)', () => {
|
|
427
|
+
const d = decideSupersede(rec({ turnId: 'turn-A', text: FLUSHED }), {
|
|
428
|
+
liveTurnId: 'turn-B',
|
|
429
|
+
replyText: REWORDED,
|
|
430
|
+
positiveAttribution: true,
|
|
431
|
+
now: 1_000_010,
|
|
432
|
+
})
|
|
433
|
+
expect(d.supersede).toBe(false)
|
|
434
|
+
expect(d.reason).toBe('different-turn')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('positiveAttribution=true still respects the TTL (expired record never supersedes)', () => {
|
|
438
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
439
|
+
liveTurnId: 'turn-A',
|
|
440
|
+
replyText: REWORDED,
|
|
441
|
+
positiveAttribution: true,
|
|
442
|
+
now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
|
|
443
|
+
})
|
|
444
|
+
expect(d.supersede).toBe(false)
|
|
445
|
+
expect(d.reason).toBe('expired')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('registry take() threads positiveAttribution through to the decision', () => {
|
|
449
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
450
|
+
const now = 1_000_000
|
|
451
|
+
reg.record('chatT', undefined, { turnId: 'turn-Q', messageIds: [8001], text: FLUSHED }, now)
|
|
452
|
+
|
|
453
|
+
// latest-ended (positiveAttribution falsey): reworded → new-content, kept.
|
|
454
|
+
const ambiguous = reg.peek('chatT', undefined, {
|
|
455
|
+
liveTurnId: 'turn-Q',
|
|
456
|
+
replyText: REWORDED,
|
|
457
|
+
now: now + 5_000,
|
|
458
|
+
})
|
|
459
|
+
expect(ambiguous.reason).toBe('new-content')
|
|
460
|
+
|
|
461
|
+
// positive tier: same reworded reply supersedes and consumes the record.
|
|
462
|
+
const positive = reg.take('chatT', undefined, {
|
|
463
|
+
liveTurnId: 'turn-Q',
|
|
464
|
+
replyText: REWORDED,
|
|
465
|
+
positiveAttribution: true,
|
|
466
|
+
now: now + 6_000,
|
|
467
|
+
})
|
|
468
|
+
expect(positive.supersede).toBe(true)
|
|
469
|
+
expect(positive.deleteMessageIds).toEqual([8001])
|
|
470
|
+
expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -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
|
+
})
|