switchroom 0.16.29 → 0.16.46
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/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse-level formatting regression suite.
|
|
3
|
+
*
|
|
4
|
+
* GOAL: catch formatting regressions at the *parse* level, not the string
|
|
5
|
+
* level. For every torture-set fixture we run the raw intent through the SAME
|
|
6
|
+
* outbound transform pipeline the gateway uses, then assert TWO independent
|
|
7
|
+
* signals via `rich-markdown-oracle.ts` (an independent CommonMark/GFM
|
|
8
|
+
* validator — NOT the formatter echoing itself):
|
|
9
|
+
*
|
|
10
|
+
* (a) PARSE-ACCEPT — every emitted chunk is well-formed enough that
|
|
11
|
+
* Telegram's rich-message path would not 400 on it (balanced fences,
|
|
12
|
+
* terminated code spans, closed links, complete table rows, balanced
|
|
13
|
+
* emphasis). This is the signal that would have caught "we shipped
|
|
14
|
+
* markdown Telegram rejects."
|
|
15
|
+
*
|
|
16
|
+
* (b) STRUCTURE — the transformed body parses into the entity structure the
|
|
17
|
+
* fixture intended (bold/italic/code/pre/link at the right inner text).
|
|
18
|
+
*
|
|
19
|
+
* The pipeline mirrors `telegram-plugin/gateway/gateway.ts`:
|
|
20
|
+
* repairEscapedWhitespace -> normalizeParagraphBreaks -> addParagraphSpacers
|
|
21
|
+
* -> splitMarkdownChunks. (Card-surface fixtures also apply `normalizeDashes`
|
|
22
|
+
* first — that is where F1's voice scrub lives.)
|
|
23
|
+
*
|
|
24
|
+
* This suite is TEST-ONLY. It changes no production formatting behaviour. If a
|
|
25
|
+
* fixture ever fails signal (a), it means the formatter emits markdown Telegram
|
|
26
|
+
* would reject — a real regression, not a test to relax.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, test, expect } from 'vitest'
|
|
30
|
+
import {
|
|
31
|
+
repairEscapedWhitespace,
|
|
32
|
+
normalizeParagraphBreaks,
|
|
33
|
+
addParagraphSpacers,
|
|
34
|
+
splitMarkdownChunks,
|
|
35
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
36
|
+
} from '../format.js'
|
|
37
|
+
import { normalizeDashes } from '../text-voice-scrub.js'
|
|
38
|
+
import {
|
|
39
|
+
validateRichMarkdown,
|
|
40
|
+
isRichMarkdownValid,
|
|
41
|
+
parseRichEntities,
|
|
42
|
+
entitiesOfType,
|
|
43
|
+
scanFences,
|
|
44
|
+
type RichEntity,
|
|
45
|
+
} from './rich-markdown-oracle.js'
|
|
46
|
+
import { TORTURE_SET, type TortureFixture, type ExpectEntity } from './formatting-torture-set.js'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The outbound transform, mirroring the gateway. `cardSurfaceScrub` prepends
|
|
50
|
+
* the voice-scrub pass (F1's surface). `cap` overrides the chunk cap so the
|
|
51
|
+
* F7 hard-slice path is reachable in a test without a 32k fixture.
|
|
52
|
+
*/
|
|
53
|
+
function transform(input: string, opts: { cardSurfaceScrub?: boolean; cap?: number } = {}): {
|
|
54
|
+
chunks: string[]
|
|
55
|
+
/** The full transformed body BEFORE chunking — used for structure assertions. */
|
|
56
|
+
body: string
|
|
57
|
+
} {
|
|
58
|
+
let text = input
|
|
59
|
+
if (opts.cardSurfaceScrub) text = normalizeDashes(text)
|
|
60
|
+
text = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
61
|
+
const body = addParagraphSpacers(text)
|
|
62
|
+
const chunks = splitMarkdownChunks(body, opts.cap ?? RICH_MESSAGE_MAX_CHARS)
|
|
63
|
+
return { chunks, body }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Assert an expected entity is present among `got` (membership, not index). */
|
|
67
|
+
function expectEntityPresent(got: ReadonlyArray<RichEntity>, want: ExpectEntity): void {
|
|
68
|
+
const match = got.find(
|
|
69
|
+
(e) =>
|
|
70
|
+
e.type === want.type &&
|
|
71
|
+
e.text === want.text &&
|
|
72
|
+
(want.url == null || e.url === want.url) &&
|
|
73
|
+
(want.lang == null || e.lang === want.lang),
|
|
74
|
+
)
|
|
75
|
+
expect(
|
|
76
|
+
match,
|
|
77
|
+
`expected a ${want.type} entity with text ${JSON.stringify(want.text)}` +
|
|
78
|
+
(want.url != null ? ` url ${want.url}` : '') +
|
|
79
|
+
(want.lang != null ? ` lang ${want.lang}` : '') +
|
|
80
|
+
`; got: ${JSON.stringify(got)}`,
|
|
81
|
+
).toBeDefined()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Oracle self-tests — prove the oracle is a real, non-vacuous validator.
|
|
86
|
+
// If the oracle accepted everything (signal a) or found nothing (signal b),
|
|
87
|
+
// the whole suite would be a rubber stamp. These guard against that.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('rich-markdown-oracle — self-tests (guards against a vacuous oracle)', () => {
|
|
91
|
+
test('parse-accept: rejects an UNCLOSED fenced code block', () => {
|
|
92
|
+
const bad = 'intro\n```bash\necho hi\n' // no closing ```
|
|
93
|
+
const issues = validateRichMarkdown(bad)
|
|
94
|
+
expect(issues.some((i) => i.kind === 'unbalanced-fence')).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('parse-accept: rejects an unterminated inline code span', () => {
|
|
98
|
+
const issues = validateRichMarkdown('here is `unclosed code')
|
|
99
|
+
expect(issues.some((i) => i.kind === 'unterminated-code-span')).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('parse-accept: rejects an unclosed inline link destination', () => {
|
|
103
|
+
const issues = validateRichMarkdown('see [label](https://example.com/path')
|
|
104
|
+
expect(issues.some((i) => i.kind === 'malformed-link')).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('parse-accept: rejects a bisected (half) table row', () => {
|
|
108
|
+
const issues = validateRichMarkdown('| a | b')
|
|
109
|
+
expect(issues.some((i) => i.kind === 'incomplete-table-row')).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('parse-accept: rejects an unbalanced ** bold delimiter', () => {
|
|
113
|
+
const issues = validateRichMarkdown('this is **bold with no close')
|
|
114
|
+
expect(issues.some((i) => i.kind === 'unbalanced-emphasis')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('parse-accept: ACCEPTS well-formed markdown with mixed entities', () => {
|
|
118
|
+
const good =
|
|
119
|
+
'Intro **bold** and _italic_ and `code`.\n\n```js\nconst x = 1\n```\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nSee [x](https://e.com).'
|
|
120
|
+
expect(validateRichMarkdown(good)).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('structure: extracts bold/italic/code/pre/link at the right text', () => {
|
|
124
|
+
const md = 'A **b** and _i_ and `c`.\n```py\nprint(1)\n```\n[lab](https://u.co)'
|
|
125
|
+
const ents = parseRichEntities(md)
|
|
126
|
+
expect(ents).toContainEqual({ type: 'bold', text: 'b' })
|
|
127
|
+
expect(ents).toContainEqual({ type: 'italic', text: 'i' })
|
|
128
|
+
expect(ents).toContainEqual({ type: 'code', text: 'c' })
|
|
129
|
+
expect(ents).toContainEqual({ type: 'pre', text: 'print(1)', lang: 'py' })
|
|
130
|
+
expect(ents).toContainEqual({ type: 'link', text: 'lab', url: 'https://u.co' })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('inline code interior is verbatim — reserved chars do not leak as entities', () => {
|
|
134
|
+
// `*not bold*` inside code must NOT produce a bold/italic entity.
|
|
135
|
+
const ents = parseRichEntities('literal `*x*` here')
|
|
136
|
+
expect(ents.filter((e) => e.type === 'bold' || e.type === 'italic')).toEqual([])
|
|
137
|
+
expect(ents).toContainEqual({ type: 'code', text: '*x*' })
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// The torture set — signal (a) + (b) for every fixture.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe('formatting torture-set — parse-accept + structure', () => {
|
|
146
|
+
// F7 is the deliberate degraded path: a >cap indivisible fenced block is
|
|
147
|
+
// hard-sliced, so its individual chunks are intentionally NOT balanced
|
|
148
|
+
// markdown. Handled separately below.
|
|
149
|
+
const standard = TORTURE_SET.filter((f) => f.name !== 'F7-over-cap-hard-slice')
|
|
150
|
+
|
|
151
|
+
for (const fx of standard) {
|
|
152
|
+
describe(fx.name, () => {
|
|
153
|
+
test(`(intent) ${fx.intent}`, () => {
|
|
154
|
+
// (a) PARSE-ACCEPT — every emitted chunk is well-formed.
|
|
155
|
+
const { chunks, body } = transform(fx.input, { cardSurfaceScrub: fx.cardSurfaceScrub })
|
|
156
|
+
for (const c of chunks) {
|
|
157
|
+
const issues = validateRichMarkdown(c)
|
|
158
|
+
expect(
|
|
159
|
+
issues,
|
|
160
|
+
`chunk failed parse-accept for fixture "${fx.name}":\n${c}\nissues: ${JSON.stringify(issues)}`,
|
|
161
|
+
).toEqual([])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// (b) STRUCTURE — the transformed body parses into the intended shape.
|
|
165
|
+
const ents = parseRichEntities(body)
|
|
166
|
+
for (const want of fx.expect) expectEntityPresent(ents, want)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── F1 extra assertion: the em/en-dash glyph is actually gone. ────────────
|
|
172
|
+
test('F1: card-surface scrub removes the em/en-dash glyph before the wire', () => {
|
|
173
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F1-card-surface-dash-scrub')!
|
|
174
|
+
const { body } = transform(fx.input, { cardSurfaceScrub: true })
|
|
175
|
+
expect(body).not.toContain('—') // em dash
|
|
176
|
+
expect(body).not.toContain('–') // en dash
|
|
177
|
+
// And the scrubbed body is still parse-accept clean.
|
|
178
|
+
expect(isRichMarkdownValid(body)).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ── F2 extra assertion: the paragraph break survives (not collapsed). ─────
|
|
182
|
+
test('F2: a loose interior pipe does not swallow the following paragraph break', () => {
|
|
183
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F2-stray-pipe-keeps-paragraph-spacing')!
|
|
184
|
+
const { body } = transform(fx.input)
|
|
185
|
+
// Both paragraphs are present and separated by a blank line (>= one \n\n).
|
|
186
|
+
expect(body).toContain('plan A | plan B')
|
|
187
|
+
expect(body).toContain('one pass')
|
|
188
|
+
expect(body).toMatch(/\n\s*\n/) // a real paragraph gap survived
|
|
189
|
+
// The loose pipe line must NOT have been turned into / read as a table
|
|
190
|
+
// row — the oracle would flag a half-row; it must be clean.
|
|
191
|
+
expect(isRichMarkdownValid(body)).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ── F4 extra assertion: a blank line now separates the fence from prose. ──
|
|
195
|
+
test('F4: a blank line is inserted between a closed fence and the following prose', () => {
|
|
196
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F4-blank-line-after-closed-fence')!
|
|
197
|
+
const { body } = transform(fx.input)
|
|
198
|
+
// The fence stays balanced (closed) and the trailing prose survives.
|
|
199
|
+
const { fences } = scanFences(body)
|
|
200
|
+
expect(fences.length).toBe(1)
|
|
201
|
+
expect(fences[0].closed).toBe(true)
|
|
202
|
+
expect(body).toContain('And it ships today.')
|
|
203
|
+
// The line immediately following the fence close is blank (the F4 fix).
|
|
204
|
+
const lines = body.split('\n')
|
|
205
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && /^```/.test(l.trim()) && lines[i - 1] !== '```')
|
|
206
|
+
// Find the LAST fence-close line then assert the next non-fence line has a
|
|
207
|
+
// blank between it and the fence.
|
|
208
|
+
const fenceCloseLine = lines.map((l) => l.trim()).lastIndexOf('```')
|
|
209
|
+
expect(fenceCloseLine).toBeGreaterThan(0)
|
|
210
|
+
expect(lines[fenceCloseLine + 1] ?? '').toBe('')
|
|
211
|
+
void closeIdx
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// F7 — over-cap indivisible block hard-slices (degraded-but-delivered).
|
|
217
|
+
// Signal (a) does NOT apply per-chunk here by design (a hard slice can bisect
|
|
218
|
+
// a fence). What MUST hold: no content is dropped and every chunk fits the cap.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe('F7-over-cap-hard-slice', () => {
|
|
222
|
+
const fx = TORTURE_SET.find((f) => f.name === 'F7-over-cap-hard-slice')!
|
|
223
|
+
const CAP = 100
|
|
224
|
+
|
|
225
|
+
test('an oversized indivisible fenced block is sliced to <= cap chunks, losing no content', () => {
|
|
226
|
+
const { chunks } = transform(fx.input, { cap: CAP })
|
|
227
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
228
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(CAP)
|
|
229
|
+
// No content dropped: the slices concatenate back to the original body.
|
|
230
|
+
// (The transform is a no-op on this fixture apart from chunking — it has no
|
|
231
|
+
// prose paragraphs to space, so body === input here.)
|
|
232
|
+
expect(chunks.join('')).toContain('x'.repeat(600))
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('the UN-chunked body is itself parse-accept valid (the fence is balanced)', () => {
|
|
236
|
+
// Sanity: the corruption F7 accepts is ONLY the intra-chunk fence bisection
|
|
237
|
+
// forced by the cap — the body as a whole is well-formed. This guards
|
|
238
|
+
// against F7 silently masking a genuine unbalanced-fence bug in the source.
|
|
239
|
+
const { body } = transform(fx.input, { cap: RICH_MESSAGE_MAX_CHARS })
|
|
240
|
+
expect(isRichMarkdownValid(body)).toBe(true)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Wiring-level: the fake bot API surfaces parsed entities on the rich path so
|
|
246
|
+
// a test can assert structure at the send boundary (deliverable 3). This uses
|
|
247
|
+
// the SAME independent oracle, keeping the check non-circular.
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe('wiring: sendRichMessage entity structure at the send boundary', () => {
|
|
251
|
+
test('a rich send of a torture body lands parse-valid with the intended entities', async () => {
|
|
252
|
+
const { createFakeBotApi } = await import('./fake-bot-api.js')
|
|
253
|
+
const bot = createFakeBotApi()
|
|
254
|
+
const fx = TORTURE_SET.find((f) => f.name === 'links')!
|
|
255
|
+
const { body } = transform(fx.input)
|
|
256
|
+
|
|
257
|
+
await bot.api.sendRichMessage('chat-1', { markdown: body })
|
|
258
|
+
const sent = bot.messagesIn('chat-1')
|
|
259
|
+
expect(sent).toHaveLength(1)
|
|
260
|
+
expect(sent[0].rich).toBe(true)
|
|
261
|
+
|
|
262
|
+
// Re-derive entities from what actually landed on the wire (sent[0].text is
|
|
263
|
+
// the rich markdown body) using the independent oracle.
|
|
264
|
+
const landed = sent[0].text
|
|
265
|
+
expect(isRichMarkdownValid(landed)).toBe(true)
|
|
266
|
+
for (const want of fx.expect) expectEntityPresent(parseRichEntities(landed), want)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Keep a reference so unused-import lint never trips on entitiesOfType even if
|
|
271
|
+
// a future edit drops its only direct use.
|
|
272
|
+
void entitiesOfType
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formatting-torture-set — curated fixtures for parse-level formatting
|
|
3
|
+
* regression testing.
|
|
4
|
+
*
|
|
5
|
+
* Each fixture is an INTENT (`input`) plus the STRUCTURE we expect the
|
|
6
|
+
* formatter's output to parse into (checked by `rich-markdown-oracle.ts`).
|
|
7
|
+
* The `input` is the raw text as a model/card surface would author it — the
|
|
8
|
+
* regression test runs it through the SAME outbound transform pipeline the
|
|
9
|
+
* gateway uses (repairEscapedWhitespace -> normalizeParagraphBreaks ->
|
|
10
|
+
* addParagraphSpacers -> splitMarkdownChunks) and then asserts:
|
|
11
|
+
*
|
|
12
|
+
* (a) every emitted chunk is parse-accept valid (no rich-path 400), and
|
|
13
|
+
* (b) the concatenated output parses into `expect` (entity structure).
|
|
14
|
+
*
|
|
15
|
+
* The `expect` block describes WHAT should survive, not a byte-for-byte echo
|
|
16
|
+
* of formatter output — the oracle is independent of the formatter, so this is
|
|
17
|
+
* a genuine cross-check, not a circular self-comparison.
|
|
18
|
+
*
|
|
19
|
+
* F1/F2/F4/F7 fixtures reproduce the specific formatting fixes from PR #2737
|
|
20
|
+
* (see `telegram-plugin/format.ts` and `text-voice-scrub.ts`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { EntityType } from './rich-markdown-oracle.js'
|
|
24
|
+
|
|
25
|
+
export interface ExpectEntity {
|
|
26
|
+
readonly type: EntityType
|
|
27
|
+
/** Exact inner text the entity should carry. */
|
|
28
|
+
readonly text: string
|
|
29
|
+
/** For links, the destination. */
|
|
30
|
+
readonly url?: string
|
|
31
|
+
/** For fenced blocks, the language info string. */
|
|
32
|
+
readonly lang?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TortureFixture {
|
|
36
|
+
readonly name: string
|
|
37
|
+
/** One-line description of the intent this fixture pins. */
|
|
38
|
+
readonly intent: string
|
|
39
|
+
/** Raw authored input, before the outbound transform pipeline. */
|
|
40
|
+
readonly input: string
|
|
41
|
+
/**
|
|
42
|
+
* Entities we expect to find in the transformed output. The test asserts
|
|
43
|
+
* every listed entity is PRESENT (membership), not that these are the only
|
|
44
|
+
* entities — spacer/structure passes may add nothing observable to the
|
|
45
|
+
* entity set. Empty ⇒ only signal (a) is asserted for this fixture.
|
|
46
|
+
*/
|
|
47
|
+
readonly expect: ReadonlyArray<ExpectEntity>
|
|
48
|
+
/**
|
|
49
|
+
* When set, the raw input is EXPECTED to be scrubbed by the card-surface
|
|
50
|
+
* voice pass (F1) before it reaches the rich path — the test applies
|
|
51
|
+
* `normalizeDashes` first and asserts the em/en-dash glyph is gone.
|
|
52
|
+
*/
|
|
53
|
+
readonly cardSurfaceScrub?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const TORTURE_SET: ReadonlyArray<TortureFixture> = [
|
|
57
|
+
// ── Multi-item bullet list — one fact per bullet ──────────────────────────
|
|
58
|
+
{
|
|
59
|
+
name: 'multi-item-bullet-list',
|
|
60
|
+
intent: 'A plain multi-item bullet list stays parse-valid and keeps its items intact',
|
|
61
|
+
input: [
|
|
62
|
+
'Findings:',
|
|
63
|
+
'- Master Bath 1 is clean',
|
|
64
|
+
'- Master Bath 2 shows 33% loss',
|
|
65
|
+
'- Cabinet is clean',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
expect: [],
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// ── Middot (·) separator INSIDE a bullet — the collapsed-inline case ──────
|
|
71
|
+
{
|
|
72
|
+
name: 'middot-separator-inside-bullet',
|
|
73
|
+
intent: 'A collapsed one-line bullet list using `·` separators splits without corrupting entities',
|
|
74
|
+
input: '- Master Bath 1, clean · **Master Bath 2**, 33% loss · Cabinet, clean',
|
|
75
|
+
expect: [{ type: 'bold', text: 'Master Bath 2' }],
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// ── Em-dash in prose (voice scrub is a DIFFERENT surface — see F1) ────────
|
|
79
|
+
{
|
|
80
|
+
name: 'em-dash-in-prose',
|
|
81
|
+
intent: 'Prose containing an em-dash is parse-valid on the rich path (raw, un-scrubbed)',
|
|
82
|
+
input: 'The audit is done — every surface checked, nothing left open.',
|
|
83
|
+
expect: [],
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// ── Wide / long code block ────────────────────────────────────────────────
|
|
87
|
+
{
|
|
88
|
+
name: 'wide-long-code-block',
|
|
89
|
+
intent: 'A fenced code block with a long line and reserved chars stays balanced and verbatim',
|
|
90
|
+
input: [
|
|
91
|
+
'Run this:',
|
|
92
|
+
'```bash',
|
|
93
|
+
'grep -rnE "foo\\|bar\\|baz" src/ | awk -F: \'{print $1}\' | sort -u # a very very very long command line that keeps going and going',
|
|
94
|
+
'echo "done"',
|
|
95
|
+
'```',
|
|
96
|
+
].join('\n'),
|
|
97
|
+
expect: [
|
|
98
|
+
{
|
|
99
|
+
type: 'pre',
|
|
100
|
+
lang: 'bash',
|
|
101
|
+
text: [
|
|
102
|
+
'grep -rnE "foo\\|bar\\|baz" src/ | awk -F: \'{print $1}\' | sort -u # a very very very long command line that keeps going and going',
|
|
103
|
+
'echo "done"',
|
|
104
|
+
].join('\n'),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// ── A GFM table ───────────────────────────────────────────────────────────
|
|
110
|
+
{
|
|
111
|
+
name: 'gfm-table',
|
|
112
|
+
intent: 'A GFM table stays row-complete (no bisected/half rows) and parse-valid',
|
|
113
|
+
input: [
|
|
114
|
+
'Results:',
|
|
115
|
+
'',
|
|
116
|
+
'| Surface | Status | Loss |',
|
|
117
|
+
'| --- | --- | --- |',
|
|
118
|
+
'| Bath 1 | clean | 0% |',
|
|
119
|
+
'| Bath 2 | flagged | 33% |',
|
|
120
|
+
].join('\n'),
|
|
121
|
+
expect: [],
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// ── A nested list ─────────────────────────────────────────────────────────
|
|
125
|
+
{
|
|
126
|
+
name: 'nested-list',
|
|
127
|
+
intent: 'A nested bullet list stays parse-valid with inline emphasis preserved',
|
|
128
|
+
input: [
|
|
129
|
+
'- Top level one',
|
|
130
|
+
' - Nested **bold** item',
|
|
131
|
+
' - Nested plain item',
|
|
132
|
+
'- Top level two',
|
|
133
|
+
].join('\n'),
|
|
134
|
+
expect: [{ type: 'bold', text: 'bold' }],
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// ── A long line that wraps ────────────────────────────────────────────────
|
|
138
|
+
{
|
|
139
|
+
name: 'long-wrapping-line',
|
|
140
|
+
intent: 'A single long prose line is parse-valid and preserves inline entities',
|
|
141
|
+
input:
|
|
142
|
+
'This is a single very long line of prose that would wrap on a phone screen and keeps going with more and more words to exceed a comfortable width, and it ends with an inline `code token` plus a **bold phrase** to make sure entities survive the length.',
|
|
143
|
+
expect: [
|
|
144
|
+
{ type: 'code', text: 'code token' },
|
|
145
|
+
{ type: 'bold', text: 'bold phrase' },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// ── Links ─────────────────────────────────────────────────────────────────
|
|
150
|
+
{
|
|
151
|
+
name: 'links',
|
|
152
|
+
intent: 'Inline links parse into link entities with the right label + destination',
|
|
153
|
+
input:
|
|
154
|
+
'See the [switchroom repo](https://github.com/switchroom/switchroom) and the [docs](https://example.com/docs) for details.',
|
|
155
|
+
expect: [
|
|
156
|
+
{ type: 'link', text: 'switchroom repo', url: 'https://github.com/switchroom/switchroom' },
|
|
157
|
+
{ type: 'link', text: 'docs', url: 'https://example.com/docs' },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// ── Inline code containing reserved chars ─────────────────────────────────
|
|
162
|
+
{
|
|
163
|
+
name: 'inline-code-reserved-chars',
|
|
164
|
+
intent: 'Inline code holding *, _, [, ], | renders verbatim without breaking entities',
|
|
165
|
+
input: 'The pattern `a_b*c[d]|e` is matched literally, and `f*g` too.',
|
|
166
|
+
expect: [
|
|
167
|
+
{ type: 'code', text: 'a_b*c[d]|e' },
|
|
168
|
+
{ type: 'code', text: 'f*g' },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// ── F1 — dash-scrub on the card surface ───────────────────────────────────
|
|
173
|
+
// The card/worker-narration surface runs `normalizeDashes` (voice scrub)
|
|
174
|
+
// before the rich path; an em-dash must not survive to the wire. Assert the
|
|
175
|
+
// scrub removes the glyph AND the scrubbed text is parse-valid.
|
|
176
|
+
{
|
|
177
|
+
name: 'F1-card-surface-dash-scrub',
|
|
178
|
+
intent: 'F1: em/en-dash is scrubbed off the card surface and the result stays parse-valid',
|
|
179
|
+
input: 'Cabinet clean — 33% loss noted, second pass pending – rechecking now.',
|
|
180
|
+
expect: [],
|
|
181
|
+
cardSurfaceScrub: true,
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ── F2 — a stray pipe must NOT eat paragraph spacing ──────────────────────
|
|
185
|
+
// A prose line containing a loose ` | ` used to be misread as a table row,
|
|
186
|
+
// suppressing the paragraph break that follows. The two paragraphs must stay
|
|
187
|
+
// separated and the whole thing must be parse-valid (no half-table-row).
|
|
188
|
+
{
|
|
189
|
+
name: 'F2-stray-pipe-keeps-paragraph-spacing',
|
|
190
|
+
intent: 'F2: a loose interior pipe in prose does not collapse the following paragraph break',
|
|
191
|
+
input:
|
|
192
|
+
'You can choose plan A | plan B depending on scale.\n\nEither way the migration completes in one pass.',
|
|
193
|
+
expect: [],
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// ── F4 — blank line after a closed code fence ─────────────────────────────
|
|
197
|
+
// Prose glued directly onto a fence close (single `\n`) can be swallowed.
|
|
198
|
+
// The block-boundary pass inserts a blank line; the fence must stay balanced
|
|
199
|
+
// and the trailing prose must survive as parse-valid text.
|
|
200
|
+
{
|
|
201
|
+
name: 'F4-blank-line-after-closed-fence',
|
|
202
|
+
intent: 'F4: a closed fence followed immediately by prose stays balanced and the prose survives',
|
|
203
|
+
input: ['Here is the fix:', '```', 'const x = 1', '```', 'And it ships today.'].join('\n'),
|
|
204
|
+
expect: [{ type: 'pre', text: 'const x = 1' }],
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// ── F7 — over-cap chunk falls back to hard-slice, not dropped ─────────────
|
|
208
|
+
// An indivisible fenced block larger than the cap must be hard-sliced into
|
|
209
|
+
// <= cap pieces rather than emitted whole (which Telegram rejects) or dropped.
|
|
210
|
+
// The test drives this with a small cap so the giant block cannot fit; it
|
|
211
|
+
// asserts no content is lost and every emitted chunk fits the cap.
|
|
212
|
+
{
|
|
213
|
+
name: 'F7-over-cap-hard-slice',
|
|
214
|
+
intent: 'F7: an oversized indivisible fenced block is hard-sliced to <= cap, losing no content',
|
|
215
|
+
input: '```\n' + 'x'.repeat(600) + '\n```',
|
|
216
|
+
expect: [],
|
|
217
|
+
},
|
|
218
|
+
]
|
|
@@ -28,19 +28,55 @@ afterEach(() => {
|
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
describe('initHistory', () => {
|
|
31
|
-
it('creates history.db with chmod
|
|
31
|
+
it('creates history.db with chmod 0644', () => {
|
|
32
32
|
initHistory(stateDir, 30)
|
|
33
33
|
const dbPath = join(stateDir, 'history.db')
|
|
34
34
|
expect(existsSync(dbPath)).toBe(true)
|
|
35
35
|
const st = statSync(dbPath)
|
|
36
36
|
// Mask off the file-type bits — only the perm bits matter.
|
|
37
|
-
|
|
37
|
+
// 0644: the web container (uid 1000) opens history.db read-only to
|
|
38
|
+
// stream replies back to Hermes Desktop. Content is redacted on write,
|
|
39
|
+
// so world-readable is safe (owner-approved behaviour change).
|
|
40
|
+
expect(st.mode & 0o777).toBe(0o644)
|
|
38
41
|
})
|
|
39
42
|
|
|
40
43
|
it('is idempotent — second call is a no-op', () => {
|
|
41
44
|
initHistory(stateDir, 30)
|
|
42
45
|
expect(() => initHistory(stateDir, 30)).not.toThrow()
|
|
43
46
|
})
|
|
47
|
+
|
|
48
|
+
// Regression for HERMES #2689 review defect #1: the web process (a different
|
|
49
|
+
// uid than the agent) opens history.db READ-ONLY to stream Telegram replies
|
|
50
|
+
// back to Hermes Desktop. If the db/WAL sidecars were left 0600, that open
|
|
51
|
+
// fails and the real-time sync poller silently no-ops. Assert both the perm
|
|
52
|
+
// bits AND that a readonly open + the poller's exact SELECT succeed.
|
|
53
|
+
it('history.db is world-readable and the Hermes poller can open it read-only', async () => {
|
|
54
|
+
initHistory(stateDir, 30)
|
|
55
|
+
recordInbound({ chat_id: '1', thread_id: null, message_id: 1, user: 'a', user_id: '1', ts: 100, text: 'hi' })
|
|
56
|
+
recordOutbound({ chat_id: '1', thread_id: null, message_ids: [2], texts: ['hello'], ts: 200 })
|
|
57
|
+
|
|
58
|
+
const dbPath = join(stateDir, 'history.db')
|
|
59
|
+
// Force a WAL sidecar to exist so we exercise the -wal/-shm perm path too.
|
|
60
|
+
for (const suffix of ['', '-shm', '-wal']) {
|
|
61
|
+
const f = dbPath + suffix
|
|
62
|
+
if (existsSync(f)) expect(statSync(f).mode & 0o777).toBe(0o644)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reproduce the poller's open (src/web/hermes-adapter.ts startHistoryPoll):
|
|
66
|
+
// readonly connection + MAX(rowid) + assistant-row SELECT.
|
|
67
|
+
const { Database } = await import('bun:sqlite')
|
|
68
|
+
const rdb = new Database(dbPath, { readonly: true })
|
|
69
|
+
try {
|
|
70
|
+
const max = rdb.prepare('SELECT MAX(rowid) AS r FROM messages').get() as { r: number | null }
|
|
71
|
+
expect(max.r).toBeGreaterThan(0)
|
|
72
|
+
const rows = rdb
|
|
73
|
+
.prepare("SELECT rowid, role, text FROM messages WHERE rowid > ? AND role = 'assistant' ORDER BY rowid ASC")
|
|
74
|
+
.all(0) as Array<{ role: string; text: string }>
|
|
75
|
+
expect(rows.some((r) => r.text === 'hello')).toBe(true)
|
|
76
|
+
} finally {
|
|
77
|
+
rdb.close()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
44
80
|
})
|
|
45
81
|
|
|
46
82
|
describe('recordInbound + query', () => {
|
|
@@ -20,39 +20,83 @@ describe("formatIdleFooter", () => {
|
|
|
20
20
|
|
|
21
21
|
it("single old turn ended 1h ago → idle 1h ago", () => {
|
|
22
22
|
const r = row({ startedAt: NOW - 70 * 60_000, endedAt: NOW - 60 * 60_000 });
|
|
23
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 1h ago");
|
|
23
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **1h ago**");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it("single turn ended 3m ago → idle 3m ago", () => {
|
|
27
27
|
const r = row({ startedAt: NOW - 5 * 60_000, endedAt: NOW - 3 * 60_000 });
|
|
28
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 3m ago");
|
|
28
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **3m ago**");
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it("single turn ended 10s ago → idle <1m ago", () => {
|
|
32
32
|
const r = row({ startedAt: NOW - 30_000, endedAt: NOW - 10_000 });
|
|
33
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply
|
|
33
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **<1m ago**");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("currently running turn started 2m ago → working since 2m ago", () => {
|
|
36
|
+
it("currently running turn started 2m ago → working since **2m ago**", () => {
|
|
37
37
|
const r = row({ startedAt: NOW - 2 * 60_000 });
|
|
38
|
-
expect(formatIdleFooter([r], NOW)).toBe("⚙️ working since 2m ago");
|
|
38
|
+
expect(formatIdleFooter([r], NOW)).toBe("⚙️ working since **2m ago**");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("multiple turns, most recent is running → working since recent", () => {
|
|
42
42
|
const old = row({ turnKey: "t1", startedAt: NOW - 10 * 60_000, endedAt: NOW - 8 * 60_000 });
|
|
43
43
|
const running = row({ turnKey: "t2", startedAt: NOW - 90_000 }); // 1m 30s ago
|
|
44
|
-
expect(formatIdleFooter([old, running], NOW)).toBe("⚙️ working since 1m ago");
|
|
44
|
+
expect(formatIdleFooter([old, running], NOW)).toBe("⚙️ working since **1m ago**");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("multiple turns, most recent is ended → idle last reply", () => {
|
|
48
48
|
const old = row({ turnKey: "t1", startedAt: NOW - 30 * 60_000, endedAt: NOW - 28 * 60_000 });
|
|
49
49
|
const recent = row({ turnKey: "t2", startedAt: NOW - 6 * 60_000, endedAt: NOW - 4 * 60_000 });
|
|
50
|
-
expect(formatIdleFooter([old, recent], NOW)).toBe("🟢 idle · last reply 4m ago");
|
|
50
|
+
expect(formatIdleFooter([old, recent], NOW)).toBe("🟢 idle · last reply **4m ago**");
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it("day-old turn → 1d ago", () => {
|
|
54
54
|
const r = row({ startedAt: NOW - 26 * 3_600_000, endedAt: NOW - 25 * 3_600_000 });
|
|
55
|
-
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply 1d ago");
|
|
55
|
+
expect(formatIdleFooter([r], NOW)).toBe("🟢 idle · last reply **1d ago**");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("no-data (quiet) state carries NO ** markup", () => {
|
|
59
|
+
const out = formatIdleFooter([], NOW);
|
|
60
|
+
expect(out).toBe("🟡 quiet · no turns yet");
|
|
61
|
+
expect(out).not.toContain("**");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("working state bolds ONLY the elapsed value, not the whole line", () => {
|
|
65
|
+
const r = row({ startedAt: NOW - 4 * 60_000 });
|
|
66
|
+
const out = formatIdleFooter([r], NOW);
|
|
67
|
+
// The bold span wraps the time and only the time.
|
|
68
|
+
const bold = out.match(/\*\*(.+?)\*\*/);
|
|
69
|
+
expect(bold).not.toBeNull();
|
|
70
|
+
expect(bold![1]).toBe("4m ago");
|
|
71
|
+
// The label "⚙️ working since " is outside the bold span.
|
|
72
|
+
expect(out).toBe("⚙️ working since **4m ago**");
|
|
73
|
+
// Exactly one bold span (two `**` delimiters).
|
|
74
|
+
expect((out.match(/\*\*/g) ?? []).length).toBe(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("idle state bolds ONLY the last-reply value, not the whole line", () => {
|
|
78
|
+
const r = row({ startedAt: NOW - 6 * 60_000, endedAt: NOW - 3 * 60_000 });
|
|
79
|
+
const out = formatIdleFooter([r], NOW);
|
|
80
|
+
const bold = out.match(/\*\*(.+?)\*\*/);
|
|
81
|
+
expect(bold).not.toBeNull();
|
|
82
|
+
expect(bold![1]).toBe("3m ago");
|
|
83
|
+
expect(out).toBe("🟢 idle · last reply **3m ago**");
|
|
84
|
+
expect((out.match(/\*\*/g) ?? []).length).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Table-driven across elapsed magnitudes: a future change to formatAgo that
|
|
88
|
+
// drops the bold (or widens it past the time value) is caught here.
|
|
89
|
+
it.each([
|
|
90
|
+
{ label: "seconds", deltaMs: 10_000, ago: "<1m ago" },
|
|
91
|
+
{ label: "minutes", deltaMs: 7 * 60_000, ago: "7m ago" },
|
|
92
|
+
{ label: "hours", deltaMs: 5 * 3_600_000, ago: "5h ago" },
|
|
93
|
+
{ label: "days", deltaMs: 3 * 86_400_000, ago: "3d ago" },
|
|
94
|
+
])("idle footer bolds the $label-magnitude elapsed value", ({ deltaMs, ago }) => {
|
|
95
|
+
const r = row({ startedAt: NOW - deltaMs - 1000, endedAt: NOW - deltaMs });
|
|
96
|
+
const out = formatIdleFooter([r], NOW);
|
|
97
|
+
expect(out).toBe(`🟢 idle · last reply **${ago}**`);
|
|
98
|
+
const bold = out.match(/\*\*(.+?)\*\*/);
|
|
99
|
+
expect(bold![1]).toBe(ago);
|
|
56
100
|
});
|
|
57
101
|
|
|
58
102
|
it("unsorted rows — picks max startedAt", () => {
|
|
@@ -61,6 +105,6 @@ describe("formatIdleFooter", () => {
|
|
|
61
105
|
const r2 = row({ turnKey: "t2", startedAt: NOW - 5 * 60_000, endedAt: NOW - 2 * 60_000 });
|
|
62
106
|
const r3 = row({ turnKey: "t3", startedAt: NOW - 40 * 60_000, endedAt: NOW - 38 * 60_000 });
|
|
63
107
|
// r2 has the largest startedAt, endedAt is 2m ago
|
|
64
|
-
expect(formatIdleFooter([r1, r2, r3], NOW)).toBe("🟢 idle · last reply 2m ago");
|
|
108
|
+
expect(formatIdleFooter([r1, r2, r3], NOW)).toBe("🟢 idle · last reply **2m ago**");
|
|
65
109
|
});
|
|
66
110
|
});
|