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,151 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
projectTranscriptLine,
|
|
4
|
+
projectSubagentLine,
|
|
5
|
+
assistantLineCarriesAnswerSurface,
|
|
6
|
+
type SessionEvent,
|
|
7
|
+
} from '../session-tail.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CONTRACT / CANARY: the Claude Code transcript event shapes the liveness
|
|
11
|
+
* surface depends on.
|
|
12
|
+
*
|
|
13
|
+
* switchroom has NO stream-json path — the status/liveness surface is derived
|
|
14
|
+
* entirely by scraping Claude Code's on-disk per-session JSONL transcript
|
|
15
|
+
* (see session-tail.ts header). Those shapes are UNDOCUMENTED and
|
|
16
|
+
* version-fragile: a 2.1.x release changed a single logical assistant message
|
|
17
|
+
* from "one JSONL line carrying all content blocks in source order" to
|
|
18
|
+
* "multiple JSONL lines sharing one message.id, one content-block per line,
|
|
19
|
+
* with the terminal stop_reason stamped on EVERY split line". That silently
|
|
20
|
+
* dropped background sub-agent handbacks (the terminal fired on the leading
|
|
21
|
+
* thinking split line before the answer text line was projected).
|
|
22
|
+
*
|
|
23
|
+
* This file PINS every wire shape the projector consumes, using fixtures
|
|
24
|
+
* captured verbatim from a live 2.1.199 transcript. If an upstream release
|
|
25
|
+
* changes the shape such that the projector stops emitting the expected
|
|
26
|
+
* SessionEvents, THIS TEST FAILS LOUDLY at CI — instead of the card silently
|
|
27
|
+
* blanking in production. When it fails: re-capture the fixtures from a real
|
|
28
|
+
* transcript, confirm the projector still emits the same SessionEvents, and
|
|
29
|
+
* update the fixtures + this contract deliberately.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const kinds = (evs: SessionEvent[]): string[] => evs.map((e) => e.kind)
|
|
33
|
+
|
|
34
|
+
describe('Claude Code event-stream contract (canary)', () => {
|
|
35
|
+
it('queue-operation enqueue carries the channel meta the gateway keys currentTurn on', () => {
|
|
36
|
+
// enqueue is the ONLY event that carries chatId — losing it kills the
|
|
37
|
+
// whole per-turn liveness surface (progress card, draft-mirror, floor).
|
|
38
|
+
const line = JSON.stringify({
|
|
39
|
+
type: 'queue-operation',
|
|
40
|
+
operation: 'enqueue',
|
|
41
|
+
content:
|
|
42
|
+
'<channel source="telegram" chat_id="12345" message_id="678" message_thread_id="9">hi</channel>',
|
|
43
|
+
})
|
|
44
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
45
|
+
{
|
|
46
|
+
kind: 'enqueue',
|
|
47
|
+
chatId: '12345',
|
|
48
|
+
messageId: '678',
|
|
49
|
+
threadId: '9',
|
|
50
|
+
rawContent:
|
|
51
|
+
'<channel source="telegram" chat_id="12345" message_id="678" message_thread_id="9">hi</channel>',
|
|
52
|
+
},
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('assistant thinking block projects to a thinking event (phase-distinct signal)', () => {
|
|
57
|
+
// 2.1.x thinking block shape: { type, thinking, signature }.
|
|
58
|
+
const line = JSON.stringify({
|
|
59
|
+
type: 'assistant',
|
|
60
|
+
message: {
|
|
61
|
+
id: 'msg_a',
|
|
62
|
+
stop_reason: 'tool_use',
|
|
63
|
+
stop_details: { type: 'tool_use' }, // new field in 2.1.x — must be ignored gracefully
|
|
64
|
+
content: [{ type: 'thinking', thinking: 'let me look', signature: 'sig' }],
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
expect(kinds(projectTranscriptLine(line))).toEqual(['thinking'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('assistant tool_use block still carries {id, name, input}', () => {
|
|
71
|
+
const line = JSON.stringify({
|
|
72
|
+
type: 'assistant',
|
|
73
|
+
message: {
|
|
74
|
+
id: 'msg_b',
|
|
75
|
+
stop_reason: 'tool_use',
|
|
76
|
+
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }],
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
80
|
+
{ kind: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_1', input: { command: 'ls' } },
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('user tool_result block still carries tool_use_id + is_error', () => {
|
|
85
|
+
const line = JSON.stringify({
|
|
86
|
+
type: 'user',
|
|
87
|
+
message: {
|
|
88
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', is_error: true, content: 'boom' }],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
const evs = projectTranscriptLine(line)
|
|
92
|
+
expect(evs).toHaveLength(1)
|
|
93
|
+
expect(evs[0]).toMatchObject({ kind: 'tool_result', toolUseId: 'toolu_1', isError: true })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('system/turn_duration remains the main-agent terminal', () => {
|
|
97
|
+
const line = JSON.stringify({ type: 'system', subtype: 'turn_duration', durationMs: 4200 })
|
|
98
|
+
expect(projectTranscriptLine(line)).toEqual([{ kind: 'turn_end', durationMs: 4200 }])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('SPLIT assistant message: each content-block is its own line sharing message.id', () => {
|
|
102
|
+
// The 2.1.x split shape, captured verbatim. text and tool_use are NEVER
|
|
103
|
+
// co-located anymore; each block is a separate line. The projector must
|
|
104
|
+
// still emit each block's event exactly once, in order, across lines.
|
|
105
|
+
const thinking = JSON.stringify({
|
|
106
|
+
type: 'assistant',
|
|
107
|
+
message: { id: 'msg_split', stop_reason: 'tool_use', content: [{ type: 'thinking', thinking: 't', signature: 's' }] },
|
|
108
|
+
})
|
|
109
|
+
const tool = JSON.stringify({
|
|
110
|
+
type: 'assistant',
|
|
111
|
+
message: { id: 'msg_split', stop_reason: 'tool_use', content: [{ type: 'tool_use', id: 'toolu_9', name: 'Read', input: { file_path: '/x' } }] },
|
|
112
|
+
})
|
|
113
|
+
const all = [...projectTranscriptLine(thinking), ...projectTranscriptLine(tool)]
|
|
114
|
+
expect(kinds(all)).toEqual(['thinking', 'tool_use'])
|
|
115
|
+
// No duplicate emission of the tool_use across the split lines.
|
|
116
|
+
expect(all.filter((e) => e.kind === 'tool_use')).toHaveLength(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('CANARY: end_turn stamped on the thinking-only split line must NOT be a terminal', () => {
|
|
120
|
+
// The exact shape that dropped sub-agent handbacks: line 1 of a split
|
|
121
|
+
// terminal message is thinking-only but carries stop_reason end_turn.
|
|
122
|
+
expect(
|
|
123
|
+
assistantLineCarriesAnswerSurface([{ type: 'thinking', thinking: 't', signature: 's' }]),
|
|
124
|
+
).toBe(false)
|
|
125
|
+
// A content-bearing line (the real final answer) DOES carry the surface.
|
|
126
|
+
expect(assistantLineCarriesAnswerSurface([{ type: 'text', text: 'final answer' }])).toBe(true)
|
|
127
|
+
// Empty/whitespace text is not an answer surface.
|
|
128
|
+
expect(assistantLineCarriesAnswerSurface([{ type: 'text', text: ' ' }])).toBe(false)
|
|
129
|
+
|
|
130
|
+
const st = { hasEmittedStart: true }
|
|
131
|
+
const thinkingEndTurn = JSON.stringify({
|
|
132
|
+
type: 'assistant',
|
|
133
|
+
message: { id: 'msg_t', stop_reason: 'end_turn', content: [{ type: 'thinking', thinking: 't', signature: 's' }] },
|
|
134
|
+
})
|
|
135
|
+
expect(
|
|
136
|
+
projectSubagentLine(thinkingEndTurn, 'agentX', st).some((e) => e.kind === 'sub_agent_turn_end'),
|
|
137
|
+
).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('sub-agent kickoff: first user message string prompt fires sub_agent_started', () => {
|
|
141
|
+
const st = { hasEmittedStart: false }
|
|
142
|
+
const line = JSON.stringify({
|
|
143
|
+
type: 'user',
|
|
144
|
+
isSidechain: true,
|
|
145
|
+
message: { role: 'user', content: 'Review PR #123 and report back.' },
|
|
146
|
+
})
|
|
147
|
+
expect(projectSubagentLine(line, 'agentX', st)).toEqual([
|
|
148
|
+
{ kind: 'sub_agent_started', agentId: 'agentX', firstPromptText: 'Review PR #123 and report back.' },
|
|
149
|
+
])
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden regression tests for GFM code-span escaping (#2695 / #2701).
|
|
3
|
+
*
|
|
4
|
+
* Inside a GFM inline code span (`` `…` ``) content is LITERAL — backslash
|
|
5
|
+
* escaping does NOT apply. #2695 renamed `escapeHtml`→`escapeMarkdown`
|
|
6
|
+
* blindly across the auth/quota/usage surfaces, so identifiers containing
|
|
7
|
+
* `_ * ~ [ ] | =` were rendered with visible backslashes inside code spans
|
|
8
|
+
* (e.g. `openai\_key` instead of `openai_key`), and the /usage table cell
|
|
9
|
+
* corrupted the row when a label carried a `_`/`*`.
|
|
10
|
+
*
|
|
11
|
+
* These goldens are the ONLY guard for this class of bug: the rendered
|
|
12
|
+
* rich-message content isn't observable through the mtcute UAT harness
|
|
13
|
+
* (it can't read message text formatting), so a unit assertion on the
|
|
14
|
+
* exact string is the regression fence. If any of these re-introduce a
|
|
15
|
+
* stray backslash before an identifier char inside a code span, they fail.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { codeSpanSafe, escapeMarkdown } from '../format.js';
|
|
19
|
+
import {
|
|
20
|
+
renderAuthSnapshotFormat2,
|
|
21
|
+
renderFallbackAnnouncement,
|
|
22
|
+
type AccountSnapshot,
|
|
23
|
+
} from '../auth-snapshot-format.js';
|
|
24
|
+
import {
|
|
25
|
+
buildThrottlingMessage,
|
|
26
|
+
buildRecoveryMessage,
|
|
27
|
+
} from '../quota-watch.js';
|
|
28
|
+
import type { QuotaUtilization } from '../quota-check.js';
|
|
29
|
+
|
|
30
|
+
const NOW = new Date('2026-05-15T00:53:00Z');
|
|
31
|
+
|
|
32
|
+
// Two representative payloads that broke under escapeMarkdown-in-code-span:
|
|
33
|
+
// an email/label with an underscore, and a vault-key-shaped secret ref.
|
|
34
|
+
const LABEL_UNDERSCORE = 'ken_max';
|
|
35
|
+
const SECRET_REF = 'secret:OPENAI_API_KEY';
|
|
36
|
+
|
|
37
|
+
function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
|
|
38
|
+
return {
|
|
39
|
+
fiveHourUtilizationPct: 0,
|
|
40
|
+
sevenDayUtilizationPct: 0,
|
|
41
|
+
fiveHourResetAt: null,
|
|
42
|
+
sevenDayResetAt: null,
|
|
43
|
+
representativeClaim: null,
|
|
44
|
+
overageStatus: null,
|
|
45
|
+
overageDisabledReason: null,
|
|
46
|
+
...part,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function snap(part: Partial<AccountSnapshot>): AccountSnapshot {
|
|
51
|
+
return {
|
|
52
|
+
label: 'unset@example.com',
|
|
53
|
+
isActive: false,
|
|
54
|
+
quota: null,
|
|
55
|
+
...part,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Assert `value` renders LITERALLY inside a code span somewhere in `rendered`:
|
|
61
|
+
* - the exact `` `value` `` span is present, and
|
|
62
|
+
* - no backslash-escaped variant of the value leaked (no `\_`, `\*`, etc.).
|
|
63
|
+
*/
|
|
64
|
+
function expectLiteralInCodeSpan(rendered: string, value: string) {
|
|
65
|
+
expect(rendered).toContain('`' + value + '`');
|
|
66
|
+
// The buggy output would be `` `secret:OPENAI\_API\_KEY` `` — assert no
|
|
67
|
+
// backslash immediately precedes any char of the value's active set.
|
|
68
|
+
const escaped = escapeMarkdown(value);
|
|
69
|
+
if (escaped !== value) {
|
|
70
|
+
// escapeMarkdown would have produced a different (backslashed) string;
|
|
71
|
+
// that exact string must NOT appear.
|
|
72
|
+
expect(rendered).not.toContain('`' + escaped + '`');
|
|
73
|
+
}
|
|
74
|
+
// Belt-and-suspenders: no stray backslash before an identifier char.
|
|
75
|
+
expect(rendered).not.toMatch(/\\[_*~=\[\]|]/);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('codeSpanSafe helper', () => {
|
|
79
|
+
it('leaves identifier chars untouched (no backslash)', () => {
|
|
80
|
+
expect(codeSpanSafe(LABEL_UNDERSCORE)).toBe(LABEL_UNDERSCORE);
|
|
81
|
+
expect(codeSpanSafe(SECRET_REF)).toBe(SECRET_REF);
|
|
82
|
+
expect(codeSpanSafe('a_b*c~d=e[f]g|h')).toBe('a_b*c~d=e[f]g|h');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('neutralizes an embedded backtick so it cannot close the span', () => {
|
|
86
|
+
const out = codeSpanSafe('a`b');
|
|
87
|
+
// A raw backtick would close the surrounding span; the transform must
|
|
88
|
+
// insert a separator so `` `${out}` `` cannot be split into two spans.
|
|
89
|
+
expect(out).not.toBe('a`b');
|
|
90
|
+
expect(out.startsWith('a`')).toBe(true);
|
|
91
|
+
// The zero-width space sits right after the backtick.
|
|
92
|
+
expect(out).toContain('`');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('escapeMarkdown (the WRONG helper for code spans) would backslash these', () => {
|
|
96
|
+
// Guards the premise: if someone reverts to escapeMarkdown, the output
|
|
97
|
+
// differs — proving these goldens actually catch the regression.
|
|
98
|
+
expect(escapeMarkdown(LABEL_UNDERSCORE)).toBe('ken\\_max');
|
|
99
|
+
expect(escapeMarkdown(SECRET_REF)).toContain('\\_');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('/auth card — code-span labels render literally (#2695)', () => {
|
|
104
|
+
it('renders an underscore label literally in the /usage table account cell', () => {
|
|
105
|
+
const rendered = renderAuthSnapshotFormat2(
|
|
106
|
+
[snap({ label: LABEL_UNDERSCORE, isActive: true, quota: quota({ fiveHourUtilizationPct: 10, sevenDayUtilizationPct: 20 }) })],
|
|
107
|
+
{ now: NOW },
|
|
108
|
+
);
|
|
109
|
+
// The account cell wraps the label (plus the (active) suffix) in a code span.
|
|
110
|
+
expect(rendered).toContain('`' + LABEL_UNDERSCORE + ' (active)`');
|
|
111
|
+
expect(rendered).not.toContain('ken\\_max');
|
|
112
|
+
expect(rendered).not.toMatch(/\\[_*~=\[\]|]/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renders a secret-ref-shaped label literally in the account cell', () => {
|
|
116
|
+
const rendered = renderAuthSnapshotFormat2(
|
|
117
|
+
[snap({ label: SECRET_REF, quota: quota({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 5 }) })],
|
|
118
|
+
{ now: NOW },
|
|
119
|
+
);
|
|
120
|
+
expect(rendered).toContain('`' + SECRET_REF + '`');
|
|
121
|
+
expect(rendered).not.toMatch(/\\[_*~=\[\]|]/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('renders the label literally in the fallback-announcement code spans', () => {
|
|
125
|
+
const rendered = renderFallbackAnnouncement({
|
|
126
|
+
oldLabel: LABEL_UNDERSCORE,
|
|
127
|
+
newLabel: 'new_acct',
|
|
128
|
+
triggerAgent: 'clerk',
|
|
129
|
+
oldQuota: null,
|
|
130
|
+
newQuota: quota({ fiveHourUtilizationPct: 3, sevenDayUtilizationPct: 4 }),
|
|
131
|
+
now: NOW,
|
|
132
|
+
});
|
|
133
|
+
// Code spans are literal (the fix). The **bold** header legitimately
|
|
134
|
+
// escapes the label — that is emphasis context, out of scope here — so we
|
|
135
|
+
// only assert the code-span form, not a global no-backslash rule.
|
|
136
|
+
expect(rendered).toContain('`' + LABEL_UNDERSCORE + '`');
|
|
137
|
+
expect(rendered).toContain('`new_acct`');
|
|
138
|
+
expect(rendered).not.toContain('`ken\\_max`');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('quota card — code-span labels render literally (#2695)', () => {
|
|
143
|
+
it('throttling card renders an underscore label literally', () => {
|
|
144
|
+
const rendered = buildThrottlingMessage(
|
|
145
|
+
'clerk',
|
|
146
|
+
snap({ label: LABEL_UNDERSCORE, isActive: true, quota: quota({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 40 }) }),
|
|
147
|
+
);
|
|
148
|
+
expectLiteralInCodeSpan(rendered, LABEL_UNDERSCORE);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('throttling card renders a secret-ref label literally (non-active use hint)', () => {
|
|
152
|
+
const rendered = buildThrottlingMessage(
|
|
153
|
+
'clerk',
|
|
154
|
+
snap({ label: SECRET_REF, isActive: false, quota: quota({ fiveHourUtilizationPct: 90, sevenDayUtilizationPct: 30 }) }),
|
|
155
|
+
);
|
|
156
|
+
expectLiteralInCodeSpan(rendered, SECRET_REF);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('recovery card renders an underscore label literally', () => {
|
|
160
|
+
const rendered = buildRecoveryMessage(
|
|
161
|
+
'clerk',
|
|
162
|
+
snap({ label: LABEL_UNDERSCORE, isActive: true, quota: quota({ fiveHourUtilizationPct: 10, sevenDayUtilizationPct: 5 }) }),
|
|
163
|
+
);
|
|
164
|
+
expectLiteralInCodeSpan(rendered, LABEL_UNDERSCORE);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -188,6 +188,24 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
|
|
|
188
188
|
// The underscore is escaped so it can't open an italic run inside **…**.
|
|
189
189
|
expect(d.message).toContain("ev\\_il");
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
// Boundary-escaping after the #2695 escaper de-dup: the entry message escapes
|
|
193
|
+
// BOTH the agent name and the raw reason via the consolidated `escapeMarkdown`
|
|
194
|
+
// import. A metacharacter-laden value through each site proves the import swap
|
|
195
|
+
// preserved escaping at this call-site.
|
|
196
|
+
it("escapes metacharacters in both the agent name and the cached reason (#2695)", () => {
|
|
197
|
+
const d = evaluateCreditState({
|
|
198
|
+
agentName: "a_b*c",
|
|
199
|
+
currentReason: "weird_reason*x",
|
|
200
|
+
prev: HEALTHY,
|
|
201
|
+
now: NOW,
|
|
202
|
+
fatalReasons: new Set(["weird_reason*x"]),
|
|
203
|
+
});
|
|
204
|
+
expect(d.kind).toBe("notify");
|
|
205
|
+
if (d.kind !== "notify") return;
|
|
206
|
+
expect(d.message).toContain("a\\_b\\*c");
|
|
207
|
+
expect(d.message).toContain("weird\\_reason\\*x");
|
|
208
|
+
});
|
|
191
209
|
});
|
|
192
210
|
|
|
193
211
|
describe("evaluateCreditState — subscription-only default (the fix)", () => {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the fleet-wide consistent-formatting bundle:
|
|
3
|
+
*
|
|
4
|
+
* 1. addParagraphSpacers extension — a visible U+00A0 spacer line at EVERY
|
|
5
|
+
* block transition (paragraph→list, list→paragraph, heading→anything,
|
|
6
|
+
* blockquote/table boundaries), never inside a list/table interior.
|
|
7
|
+
* 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
|
|
8
|
+
* list markers → `- `, on code-masked text, idempotent.
|
|
9
|
+
* 3. stripExcessBold — over-bold tripwire: >30% bold or fully-bolded
|
|
10
|
+
* paragraphs/lists lose their bold markers; short messages exempt.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
addParagraphSpacers,
|
|
15
|
+
normalizeParagraphBreaks,
|
|
16
|
+
normalizePunctuation,
|
|
17
|
+
stripExcessBold,
|
|
18
|
+
splitMarkdownChunks,
|
|
19
|
+
PARAGRAPH_SPACER,
|
|
20
|
+
} from '../format.js'
|
|
21
|
+
|
|
22
|
+
const SP = PARAGRAPH_SPACER // U+00A0
|
|
23
|
+
|
|
24
|
+
describe('addParagraphSpacers — uniform block spacing', () => {
|
|
25
|
+
test('still spaces prose→prose (existing behaviour)', () => {
|
|
26
|
+
const out = addParagraphSpacers('Alpha.\n\nBravo.')
|
|
27
|
+
expect(out).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('spaces paragraph→list transition', () => {
|
|
31
|
+
const out = addParagraphSpacers('Intro.\n\n- one\n- two')
|
|
32
|
+
expect(out).toBe(`Intro.\n\n${SP}\n\n- one\n- two`)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('spaces list→paragraph transition', () => {
|
|
36
|
+
const out = addParagraphSpacers('- one\n- two\n\nOutro.')
|
|
37
|
+
expect(out).toBe(`- one\n- two\n\n${SP}\n\nOutro.`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('spaces heading→anything', () => {
|
|
41
|
+
expect(addParagraphSpacers('# Title\n\nBody.')).toBe(`# Title\n\n${SP}\n\nBody.`)
|
|
42
|
+
expect(addParagraphSpacers('# Title\n\n- a\n- b')).toBe(`# Title\n\n${SP}\n\n- a\n- b`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('spaces blockquote and table boundaries', () => {
|
|
46
|
+
expect(addParagraphSpacers('> quoted\n\nProse.')).toBe(`> quoted\n\n${SP}\n\nProse.`)
|
|
47
|
+
const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
|
48
|
+
expect(addParagraphSpacers(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${SP}\n\n${table}`)
|
|
49
|
+
expect(addParagraphSpacers(`${table}\n\nProse.`)).toBe(`${table}\n\n${SP}\n\nProse.`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('does NOT space between items of the same loose list', () => {
|
|
53
|
+
const input = '- one\n\n- two\n\n- three'
|
|
54
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('does NOT space inside a tight list or table interior (single \\n)', () => {
|
|
58
|
+
const list = 'Intro.\n\n- a\n- b\n- c'
|
|
59
|
+
expect(addParagraphSpacers(list)).toBe(`Intro.\n\n${SP}\n\n- a\n- b\n- c`)
|
|
60
|
+
const table = '| a |\n| --- |\n| 1 |\n| 2 |'
|
|
61
|
+
expect(addParagraphSpacers(table)).toBe(table)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('idempotent across every transition kind', () => {
|
|
65
|
+
const input = '# H\n\nProse one.\n\n- a\n- b\n\nProse two.\n\n> quote'
|
|
66
|
+
const once = addParagraphSpacers(input)
|
|
67
|
+
expect(addParagraphSpacers(once)).toBe(once)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('never touches code fences', () => {
|
|
71
|
+
const input = '```\nA\n\nB\n```\n\n```\nC\n```'
|
|
72
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('full pipeline: mixed prose+list+heading message gets uniform gaps', () => {
|
|
76
|
+
const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
|
|
77
|
+
const out = addParagraphSpacers(normalizeParagraphBreaks(raw))
|
|
78
|
+
// Every block transition carries exactly one visible spacer line.
|
|
79
|
+
expect(out).toBe(
|
|
80
|
+
`Summary line.\n\n${SP}\n\n- item one\n- item two\n\n${SP}\n\nClosing prose.`,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('chunk-boundary interaction: a cut in a spacer gap strips the spacer', () => {
|
|
85
|
+
const a = 'A'.repeat(60)
|
|
86
|
+
const b = 'B'.repeat(60)
|
|
87
|
+
const text = `${a}\n\n${SP}\n\n${b}`
|
|
88
|
+
const chunks = splitMarkdownChunks(text, 80)
|
|
89
|
+
expect(chunks.length).toBe(2)
|
|
90
|
+
expect(chunks[0]).toBe(a)
|
|
91
|
+
expect(chunks[1]).toBe(b)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('normalizePunctuation', () => {
|
|
96
|
+
test('spaced em-dash → comma', () => {
|
|
97
|
+
expect(normalizePunctuation('voice came back — three PRs stacked')).toBe(
|
|
98
|
+
'voice came back, three PRs stacked',
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('spaced en-dash → comma', () => {
|
|
103
|
+
expect(normalizePunctuation('one thing – another thing')).toBe('one thing, another thing')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('bare em-dash between words → comma', () => {
|
|
107
|
+
expect(normalizePunctuation('word—word')).toBe('word, word')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('bare en-dash between words → hyphen (ranges survive as ASCII)', () => {
|
|
111
|
+
expect(normalizePunctuation('2019–2024')).toBe('2019-2024')
|
|
112
|
+
expect(normalizePunctuation('pre–war')).toBe('pre-war')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('digit-flanked spaced dash → hyphen range, not comma', () => {
|
|
116
|
+
expect(normalizePunctuation('3 – 5 days')).toBe('3-5 days')
|
|
117
|
+
expect(normalizePunctuation('10 — 20')).toBe('10-20')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('leading • and · bullets → `- ` (indent preserved)', () => {
|
|
121
|
+
expect(normalizePunctuation('• one\n• two')).toBe('- one\n- two')
|
|
122
|
+
expect(normalizePunctuation(' · indented')).toBe(' - indented')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('mid-line bullet glyph untouched', () => {
|
|
126
|
+
expect(normalizePunctuation('rated 4.5 • 120 reviews')).toBe('rated 4.5 • 120 reviews')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('never touches code spans or fences', () => {
|
|
130
|
+
const input = 'run `a — b` now\n\n```\nx — y\n• bullet\n```'
|
|
131
|
+
expect(normalizePunctuation(input)).toBe(input)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('idempotent', () => {
|
|
135
|
+
const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
|
|
136
|
+
expect(normalizePunctuation(once)).toBe(once)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('consecutive spaced dashes all normalize in ONE pass (#2755 finding 2)', () => {
|
|
140
|
+
expect(normalizePunctuation('a — b — c')).toBe('a, b, c')
|
|
141
|
+
expect(normalizePunctuation('one — two — three — four')).toBe('one, two, three, four')
|
|
142
|
+
expect(normalizePunctuation('x—y—z')).toBe('x, y, z')
|
|
143
|
+
expect(normalizePunctuation('1–2–3')).toBe('1-2-3')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('cross-path ordering: normalizePunctuation before scrubVoice yields the comma treatment', async () => {
|
|
147
|
+
// The stream path runs normalizePunctuation BEFORE scrubVoice, same as
|
|
148
|
+
// reply/edit — so a spaced em-dash gets the comma substitution on every
|
|
149
|
+
// path, and scrubVoice (period substitution) finds no dash left. Pins
|
|
150
|
+
// the #2755 finding-1 ordering contract.
|
|
151
|
+
const { scrubVoice } = await import('../text-voice-scrub.js')
|
|
152
|
+
const input = 'voice came back — three PRs stacked'
|
|
153
|
+
const normalized = normalizePunctuation(input)
|
|
154
|
+
const scrub = scrubVoice(normalized)
|
|
155
|
+
expect(normalized).toBe('voice came back, three PRs stacked')
|
|
156
|
+
expect(scrub.replaced).toBe(0)
|
|
157
|
+
expect(scrub.scrubbed).toBe(normalized)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('stripExcessBold', () => {
|
|
162
|
+
const filler =
|
|
163
|
+
'This is an ordinary paragraph of connected prose that provides enough plain ' +
|
|
164
|
+
'characters to clear the one-hundred character exemption comfortably.'
|
|
165
|
+
|
|
166
|
+
test('short messages (<100 chars) exempt even when fully bold', () => {
|
|
167
|
+
const input = '**Everything here is bold.**'
|
|
168
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('strips all bold when >30% of non-code chars are bold', () => {
|
|
172
|
+
const bold = '**' + 'B'.repeat(80) + '**'
|
|
173
|
+
const input = `${bold} plus a little plain text tail here.`
|
|
174
|
+
const out = stripExcessBold(input)
|
|
175
|
+
expect(out).not.toContain('**')
|
|
176
|
+
expect(out).toContain('B'.repeat(80))
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('keeps bold when clearly under threshold', () => {
|
|
180
|
+
const input = `${filler} The key fact is **42**.`
|
|
181
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('strips a fully-bolded multi-line paragraph, leaves others', () => {
|
|
185
|
+
const input = `${filler}\n\n**This whole paragraph is bold.**\n**Every single line of it.**`
|
|
186
|
+
const out = stripExcessBold(input)
|
|
187
|
+
expect(out).toContain('This whole paragraph is bold.')
|
|
188
|
+
expect(out).not.toContain('**This whole paragraph is bold.**')
|
|
189
|
+
expect(out.startsWith(filler)).toBe(true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('single short fully-bolded line (pseudo-heading) survives', () => {
|
|
193
|
+
const input = `**Summary**\n\n${filler}`
|
|
194
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('strips a list whose EVERY item is fully bolded', () => {
|
|
198
|
+
const input = `${filler}\n\n- **alpha item**\n- **bravo item**\n- **charlie item**`
|
|
199
|
+
const out = stripExcessBold(input)
|
|
200
|
+
expect(out).toContain('- alpha item')
|
|
201
|
+
expect(out).not.toContain('**alpha item**')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('leaves a list where only some items are bolded', () => {
|
|
205
|
+
const input = `${filler}\n\n- **alpha item**\n- plain bravo\n- plain charlie`
|
|
206
|
+
expect(stripExcessBold(input)).toBe(input)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('code regions neither counted nor modified', () => {
|
|
210
|
+
const fence = '```\n**not really bold**\n' + 'x'.repeat(400) + '\n```'
|
|
211
|
+
const input = `${filler} Key: **fact**.\n\n${fence}`
|
|
212
|
+
const out = stripExcessBold(input)
|
|
213
|
+
expect(out).toContain('**not really bold**')
|
|
214
|
+
expect(out).toContain('**fact**')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('idempotent', () => {
|
|
218
|
+
const bold = '**' + 'B'.repeat(80) + '**'
|
|
219
|
+
const input = `${bold} plus a little plain text tail.`
|
|
220
|
+
const once = stripExcessBold(input)
|
|
221
|
+
expect(stripExcessBold(once)).toBe(once)
|
|
222
|
+
})
|
|
223
|
+
})
|