switchroom 0.16.23 → 0.16.24
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +671 -430
- package/dist/host-control/main.js +156 -156
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +751 -624
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +36 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +15 -7
- package/telegram-plugin/rich-send.ts +57 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-mode stream-reply behaviour (post-#2669).
|
|
3
|
+
*
|
|
4
|
+
* The parse-mode rotation this file used to cover is GONE: there is now
|
|
5
|
+
* exactly ONE rendering mode (raw GFM markdown via `sendRichMessage` /
|
|
6
|
+
* `editMessageText({ markdown })`). The successor contract is:
|
|
7
|
+
*
|
|
8
|
+
* - Every non-`text` format reuses the SAME draft stream for a
|
|
9
|
+
* chat+thread+lane — no finalize-and-recreate dance, because no baked
|
|
10
|
+
* parse_mode can ever go stale.
|
|
11
|
+
* - The only fork is the literal `format:'text'` path (`literalText`),
|
|
12
|
+
* which sends a plain string instead of a rich-markdown wrapper.
|
|
13
|
+
*
|
|
14
|
+
* This file is retained (rather than deleted) to lock in the
|
|
15
|
+
* single-mode-reuse invariant that replaced rotation, so a future
|
|
16
|
+
* regression that reintroduces per-mode stream churn is caught.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
20
|
+
import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
|
|
21
|
+
import type { DraftStreamHandle } from '../draft-stream.js'
|
|
22
|
+
import { createFakeBotApi, type FakeBot } from './fake-bot-api.js'
|
|
23
|
+
|
|
24
|
+
function makeState(): StreamReplyState {
|
|
25
|
+
return {
|
|
26
|
+
activeDraftStreams: new Map<string, DraftStreamHandle>(),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamReplyDeps {
|
|
31
|
+
return {
|
|
32
|
+
bot: bot as unknown as StreamReplyDeps['bot'],
|
|
33
|
+
repairEscapedWhitespace: (t) => t,
|
|
34
|
+
assertAllowedChat: () => {},
|
|
35
|
+
resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
36
|
+
disableLinkPreview: true,
|
|
37
|
+
// Anything other than the literal 'text' is the rich-markdown path.
|
|
38
|
+
defaultFormat: 'markdown',
|
|
39
|
+
logStreamingEvent: () => {},
|
|
40
|
+
historyEnabled: false,
|
|
41
|
+
recordOutbound: () => {},
|
|
42
|
+
writeError: () => {},
|
|
43
|
+
throttleMs: 0,
|
|
44
|
+
...overrides,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('stream-reply single-mode reuse (#2669)', () => {
|
|
49
|
+
let bot: FakeBot
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
bot = createFakeBotApi({ startMessageId: 500 })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('same chat+thread+lane reuses the one stream — no recreate', async () => {
|
|
56
|
+
const state = makeState()
|
|
57
|
+
const deps = makeDeps(bot)
|
|
58
|
+
|
|
59
|
+
await handleStreamReply({ chat_id: 'c', text: 'v1' }, state, deps)
|
|
60
|
+
const streamBefore = state.activeDraftStreams.get('c:_')
|
|
61
|
+
await handleStreamReply({ chat_id: 'c', text: 'v2' }, state, deps)
|
|
62
|
+
const streamAfter = state.activeDraftStreams.get('c:_')
|
|
63
|
+
|
|
64
|
+
// Identity preserved — there is no rotation to churn the stream.
|
|
65
|
+
expect(streamBefore).toBe(streamAfter)
|
|
66
|
+
// First call sent a rich message; second edited it in place.
|
|
67
|
+
expect(bot.state.sent).toHaveLength(1)
|
|
68
|
+
expect(bot.state.sent[0].rich).toBe(true)
|
|
69
|
+
expect(bot.api.editMessageText).toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('the rich path ships raw GFM markdown unescaped via sendRichMessage', async () => {
|
|
73
|
+
const state = makeState()
|
|
74
|
+
const deps = makeDeps(bot)
|
|
75
|
+
|
|
76
|
+
await handleStreamReply({ chat_id: 'c', text: '**bold** and _italic_' }, state, deps)
|
|
77
|
+
|
|
78
|
+
expect(bot.state.sent).toHaveLength(1)
|
|
79
|
+
expect(bot.state.sent[0].rich).toBe(true)
|
|
80
|
+
// Raw markdown is the wire payload — no HTML, no MarkdownV2 escaping.
|
|
81
|
+
expect(bot.state.sent[0].text).toBe('**bold** and _italic_')
|
|
82
|
+
expect(bot.state.sent[0].parse_mode).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('format:"text" takes the literal plain path (no rich wrapper)', async () => {
|
|
86
|
+
const state = makeState()
|
|
87
|
+
const deps = makeDeps(bot)
|
|
88
|
+
|
|
89
|
+
await handleStreamReply({ chat_id: 'c', text: 'plain < text >', format: 'text' }, state, deps)
|
|
90
|
+
|
|
91
|
+
expect(bot.state.sent).toHaveLength(1)
|
|
92
|
+
expect(bot.state.sent[0].rich).toBeFalsy()
|
|
93
|
+
expect(bot.state.sent[0].text).toBe('plain < text >')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('switching format on the same lane does NOT recreate the stream (single mode)', async () => {
|
|
97
|
+
const state = makeState()
|
|
98
|
+
const deps = makeDeps(bot)
|
|
99
|
+
|
|
100
|
+
// First call as the default rich path…
|
|
101
|
+
await handleStreamReply({ chat_id: 'c', text: 'rich first', format: 'markdown' }, state, deps)
|
|
102
|
+
const first = state.activeDraftStreams.get('c:_')
|
|
103
|
+
|
|
104
|
+
// …then a call nominally requesting a different (non-text) format.
|
|
105
|
+
// Pre-#2669 this rotated the stream; now there's nothing to rotate.
|
|
106
|
+
await handleStreamReply({ chat_id: 'c', text: 'rich second', format: 'gfm' }, state, deps)
|
|
107
|
+
const second = state.activeDraftStreams.get('c:_')
|
|
108
|
+
|
|
109
|
+
expect(second).toBe(first)
|
|
110
|
+
// Still a single message — edited in place, not a second send.
|
|
111
|
+
expect(bot.state.sent).toHaveLength(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('distinct lanes get independent streams', async () => {
|
|
115
|
+
const state = makeState()
|
|
116
|
+
const deps = makeDeps(bot)
|
|
117
|
+
|
|
118
|
+
await handleStreamReply({ chat_id: 'c', text: 'main', lane: 'main' }, state, deps)
|
|
119
|
+
await handleStreamReply({ chat_id: 'c', text: 'activity', lane: 'activity' }, state, deps)
|
|
120
|
+
|
|
121
|
+
expect(state.activeDraftStreams.size).toBe(2)
|
|
122
|
+
expect(bot.state.sent).toHaveLength(2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('distinct threads get independent streams', async () => {
|
|
126
|
+
const state = makeState()
|
|
127
|
+
const deps = makeDeps(bot)
|
|
128
|
+
|
|
129
|
+
await handleStreamReply({ chat_id: 'c', text: 'thread-a', message_thread_id: '1' }, state, deps)
|
|
130
|
+
await handleStreamReply({ chat_id: 'c', text: 'thread-b', message_thread_id: '2' }, state, deps)
|
|
131
|
+
|
|
132
|
+
expect(state.activeDraftStreams.size).toBe(2)
|
|
133
|
+
expect(bot.state.sent).toHaveLength(2)
|
|
134
|
+
expect(bot.state.sent[0].message_thread_id).toBe(1)
|
|
135
|
+
expect(bot.state.sent[1].message_thread_id).toBe(2)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for telegram-plugin/gateway/skill-proposal-card.ts (#2670).
|
|
3
|
+
*
|
|
4
|
+
* Pure builders — no SQLite, no gateway. Contract under test:
|
|
5
|
+
* - parseSkillProposalCallback round-trips the keyboard's callback_data.
|
|
6
|
+
* - renderSkillProposalCard escapes HTML and shows lesson + steps.
|
|
7
|
+
* - buildSkillProposalApplyInbound pins source='skill_proposal_apply' +
|
|
8
|
+
* meta fields the bridge keys on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'bun:test'
|
|
12
|
+
import {
|
|
13
|
+
parseSkillProposalCallback,
|
|
14
|
+
skillProposalKeyboard,
|
|
15
|
+
renderSkillProposalCard,
|
|
16
|
+
buildSkillProposalApplyInbound,
|
|
17
|
+
previewSteps,
|
|
18
|
+
SKILL_PROPOSAL_CALLBACK_PREFIX,
|
|
19
|
+
} from '../gateway/skill-proposal-card.js'
|
|
20
|
+
|
|
21
|
+
describe('skill-proposal callback round-trip', () => {
|
|
22
|
+
it('keyboard callback_data parses back to action + id', () => {
|
|
23
|
+
const kb = skillProposalKeyboard('abc-123')
|
|
24
|
+
const [approve, deny] = kb.inline_keyboard[0]!
|
|
25
|
+
expect(parseSkillProposalCallback(approve!.callback_data)).toEqual({
|
|
26
|
+
action: 'approve',
|
|
27
|
+
id: 'abc-123',
|
|
28
|
+
})
|
|
29
|
+
expect(parseSkillProposalCallback(deny!.callback_data)).toEqual({
|
|
30
|
+
action: 'deny',
|
|
31
|
+
id: 'abc-123',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejects foreign / malformed callback_data', () => {
|
|
36
|
+
expect(parseSkillProposalCallback('vra:approve:x')).toBeNull()
|
|
37
|
+
expect(parseSkillProposalCallback(`${SKILL_PROPOSAL_CALLBACK_PREFIX}bogus:x`)).toBeNull()
|
|
38
|
+
expect(parseSkillProposalCallback(`${SKILL_PROPOSAL_CALLBACK_PREFIX}approve:`)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('callback_data fits Telegram 64-byte limit for a UUID id', () => {
|
|
42
|
+
const kb = skillProposalKeyboard('00000000-0000-0000-0000-000000000000')
|
|
43
|
+
for (const row of kb.inline_keyboard) {
|
|
44
|
+
for (const b of row) {
|
|
45
|
+
expect(Buffer.byteLength(b.callback_data, 'utf8')).toBeLessThanOrEqual(64)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('renderSkillProposalCard', () => {
|
|
52
|
+
it('shows lesson, evidence, and escapes HTML', () => {
|
|
53
|
+
const text = renderSkillProposalCard({
|
|
54
|
+
id: 'x',
|
|
55
|
+
skill_slug: 'deploy-checklist',
|
|
56
|
+
is_new: true,
|
|
57
|
+
lesson: 'Always run <smoke> first',
|
|
58
|
+
evidence: 'seen across 3 sessions',
|
|
59
|
+
skill_md: '---\nname: deploy-checklist\ndescription: d\n---\n\n1. step one\n2. step two',
|
|
60
|
+
})
|
|
61
|
+
expect(text).toContain('New personal skill')
|
|
62
|
+
expect(text).toContain('deploy-checklist')
|
|
63
|
+
expect(text).toContain('<smoke>') // escaped
|
|
64
|
+
expect(text).toContain('seen across 3 sessions')
|
|
65
|
+
expect(text).toContain('step one')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('previewSteps drops frontmatter + headings', () => {
|
|
69
|
+
const steps = previewSteps('---\nname: x\n---\n\n# Title\n1. alpha\n- beta\n')
|
|
70
|
+
expect(steps).toEqual(['alpha', 'beta'])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('buildSkillProposalApplyInbound', () => {
|
|
75
|
+
it('pins source and meta for a new skill', () => {
|
|
76
|
+
const inb = buildSkillProposalApplyInbound({
|
|
77
|
+
ctx: { agent: 'klanker', chat_id: '12345' },
|
|
78
|
+
proposalId: 'p1',
|
|
79
|
+
skillSlug: 'deploy-checklist',
|
|
80
|
+
isNew: true,
|
|
81
|
+
operatorId: '999',
|
|
82
|
+
nowMs: 1000,
|
|
83
|
+
})
|
|
84
|
+
expect(inb.meta.source).toBe('skill_proposal_apply')
|
|
85
|
+
expect(inb.meta.proposal_id).toBe('p1')
|
|
86
|
+
expect(inb.meta.skill_slug).toBe('deploy-checklist')
|
|
87
|
+
expect(inb.meta.is_new).toBe('true')
|
|
88
|
+
expect(inb.chatId).toBe('12345')
|
|
89
|
+
expect(inb.text).toContain('skill_init_personal')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('points at the edit tool when isNew=false', () => {
|
|
93
|
+
const inb = buildSkillProposalApplyInbound({
|
|
94
|
+
ctx: { agent: 'klanker', chat_id: '12345' },
|
|
95
|
+
proposalId: 'p2',
|
|
96
|
+
skillSlug: 'deploy-checklist',
|
|
97
|
+
isNew: false,
|
|
98
|
+
operatorId: '999',
|
|
99
|
+
})
|
|
100
|
+
expect(inb.text).toContain('skill_edit_personal')
|
|
101
|
+
expect(inb.meta.is_new).toBe('false')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -65,13 +65,14 @@ describe('refreshBanner — transitions from clean state', () => {
|
|
|
65
65
|
expect(next).not.toBeNull();
|
|
66
66
|
expect(next?.slot).toBe('personal');
|
|
67
67
|
expect(next?.messageId).toBe(100);
|
|
68
|
-
//
|
|
69
|
-
expect(bot.api.
|
|
68
|
+
// sendRichMessage + pinChatMessage called in that order (#2669).
|
|
69
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
|
|
70
70
|
expect(bot.api.pinChatMessage).toHaveBeenCalledTimes(1);
|
|
71
|
-
const sendArgs = bot.api.
|
|
71
|
+
const sendArgs = bot.api.sendRichMessage.mock.calls[0];
|
|
72
72
|
expect(sendArgs[0]).toBe(OWNER);
|
|
73
|
-
|
|
74
|
-
expect(
|
|
73
|
+
const md = (sendArgs[1] as { markdown: string }).markdown;
|
|
74
|
+
expect(md).toContain('clerk');
|
|
75
|
+
expect(md).toContain('personal');
|
|
75
76
|
// Pin call should disable notifications so users don't get pinged.
|
|
76
77
|
const pinArgs = bot.api.pinChatMessage.mock.calls[0];
|
|
77
78
|
expect(pinArgs[2]).toMatchObject({ disable_notification: true });
|
|
@@ -100,6 +101,7 @@ describe('refreshBanner — transitions from pinned state', () => {
|
|
|
100
101
|
prior = seeded;
|
|
101
102
|
// Reset the spy counts so the under-test transition starts from zero.
|
|
102
103
|
bot.api.sendMessage.mockClear();
|
|
104
|
+
bot.api.sendRichMessage.mockClear();
|
|
103
105
|
bot.api.editMessageText.mockClear();
|
|
104
106
|
bot.api.pinChatMessage.mockClear();
|
|
105
107
|
bot.api.unpinChatMessage.mockClear();
|
|
@@ -132,10 +134,12 @@ describe('refreshBanner — transitions from pinned state', () => {
|
|
|
132
134
|
expect(next).toEqual({ messageId: prior.messageId, slot: 'work' });
|
|
133
135
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1);
|
|
134
136
|
expect(bot.api.sendMessage).not.toHaveBeenCalled();
|
|
137
|
+
expect(bot.api.sendRichMessage).not.toHaveBeenCalled();
|
|
135
138
|
const editArgs = bot.api.editMessageText.mock.calls[0];
|
|
136
139
|
expect(editArgs[0]).toBe(OWNER);
|
|
137
140
|
expect(editArgs[1]).toBe(prior.messageId);
|
|
138
|
-
|
|
141
|
+
// #2669: the edit carries the rich { markdown } wrapper, not a string.
|
|
142
|
+
expect((editArgs[2] as { markdown: string }).markdown).toContain('work');
|
|
139
143
|
});
|
|
140
144
|
|
|
141
145
|
it('return to default slot unpins, clears state', async () => {
|
|
@@ -216,7 +220,7 @@ describe('refreshBanner — full lifecycle (sequenced)', () => {
|
|
|
216
220
|
expect(state).toBeNull();
|
|
217
221
|
|
|
218
222
|
// Final tally on the chat model.
|
|
219
|
-
expect(bot.api.
|
|
223
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
|
|
220
224
|
expect(bot.api.editMessageText).toHaveBeenCalledTimes(1);
|
|
221
225
|
expect(bot.api.unpinChatMessage).toHaveBeenCalledTimes(1);
|
|
222
226
|
expect(bot.api.pinChatMessage).toHaveBeenCalledTimes(1);
|
|
@@ -241,13 +245,13 @@ describe('refreshBanner — error paths', () => {
|
|
|
241
245
|
onError,
|
|
242
246
|
});
|
|
243
247
|
expect(next).toBeNull(); // prior state was null, preserved
|
|
244
|
-
expect(bot.api.
|
|
248
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
|
|
245
249
|
expect(bot.api.pinChatMessage).toHaveBeenCalledTimes(1);
|
|
246
250
|
expect(onError).toHaveBeenCalledWith('pin', expect.any(Error));
|
|
247
251
|
});
|
|
248
252
|
|
|
249
|
-
it('
|
|
250
|
-
bot.faults.next('
|
|
253
|
+
it('sendRichMessage failure surfaces but does not throw', async () => {
|
|
254
|
+
bot.faults.next('sendRichMessage', errors.forbidden('sendRichMessage'));
|
|
251
255
|
const onError = vi.fn();
|
|
252
256
|
const next = await refreshBanner({
|
|
253
257
|
bot,
|
|
@@ -311,30 +315,33 @@ describe('refreshBanner — banner content', () => {
|
|
|
311
315
|
defaultSlot: 'default',
|
|
312
316
|
prevState: null,
|
|
313
317
|
});
|
|
314
|
-
const
|
|
318
|
+
const rich = bot.api.sendRichMessage.mock.calls[0][1] as { markdown: string };
|
|
319
|
+
const text = rich.markdown;
|
|
315
320
|
expect(text).toContain('klanker');
|
|
316
321
|
expect(text).toContain('backup');
|
|
317
322
|
expect(text).toContain('default');
|
|
318
323
|
expect(text.toLowerCase()).toMatch(/failover/);
|
|
319
324
|
});
|
|
320
325
|
|
|
321
|
-
it('escapes
|
|
326
|
+
it('markdown-escapes emphasis specials in agent name; slot names ride in code spans (#2669)', async () => {
|
|
322
327
|
await refreshBanner({
|
|
323
328
|
bot,
|
|
324
329
|
ownerChatId: OWNER,
|
|
325
|
-
agentName: '
|
|
326
|
-
currentSlot: '
|
|
327
|
-
defaultSlot: '
|
|
330
|
+
agentName: 'a_b*c',
|
|
331
|
+
currentSlot: 'slot_1',
|
|
332
|
+
defaultSlot: 'def_2',
|
|
328
333
|
prevState: null,
|
|
329
334
|
});
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
expect(text).toContain('
|
|
334
|
-
|
|
335
|
+
const rich = bot.api.sendRichMessage.mock.calls[0][1] as { markdown: string };
|
|
336
|
+
const text = rich.markdown;
|
|
337
|
+
// The agent name is bolded — its emphasis specials are escaped.
|
|
338
|
+
expect(text).toContain('**a\\_b\\*c**');
|
|
339
|
+
// Slot names ride in `code spans` — literal, never escaped.
|
|
340
|
+
expect(text).toContain('`slot_1`');
|
|
341
|
+
expect(text).toContain('`def_2`');
|
|
335
342
|
});
|
|
336
343
|
|
|
337
|
-
it('
|
|
344
|
+
it('sends via the rich path (no parse_mode, no link_preview_options) #2669', async () => {
|
|
338
345
|
await refreshBanner({
|
|
339
346
|
bot,
|
|
340
347
|
ownerChatId: OWNER,
|
|
@@ -343,8 +350,13 @@ describe('refreshBanner — banner content', () => {
|
|
|
343
350
|
defaultSlot: DEFAULT,
|
|
344
351
|
prevState: null,
|
|
345
352
|
});
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
expect(
|
|
353
|
+
// The banner ships raw GFM markdown via sendRichMessage — no parse_mode,
|
|
354
|
+
// and sendRichMessage doesn't accept link_preview_options (it's omitted).
|
|
355
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(bot.api.sendMessage).not.toHaveBeenCalled();
|
|
357
|
+
const opts = bot.api.sendRichMessage.mock.calls[0][2] as Record<string, unknown>;
|
|
358
|
+
expect(opts.parse_mode).toBeUndefined();
|
|
359
|
+
expect(opts.link_preview_options).toBeUndefined();
|
|
360
|
+
expect(opts.disable_notification).toBe(true);
|
|
349
361
|
});
|
|
350
362
|
});
|
|
@@ -58,12 +58,15 @@ describe('decideBannerAction — non-default state', () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
describe('formatBannerHtml', () => {
|
|
61
|
-
it('escapes
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect(text).toContain('
|
|
61
|
+
it('markdown-escapes emphasis specials in the agent name (#2669)', () => {
|
|
62
|
+
// The agent name is interpolated into a **bold** run, so an emphasis
|
|
63
|
+
// special in it must be backslash-escaped or it would break the run.
|
|
64
|
+
// Slot names live in `code spans` (literal) and need no escaping.
|
|
65
|
+
const text = formatBannerHtml('a_b*c', 'slot_1', 'def_2');
|
|
66
|
+
expect(text).toContain('**a\\_b\\*c**');
|
|
67
|
+
// Slot names ride in code spans verbatim (literal inside backticks).
|
|
68
|
+
expect(text).toContain('`slot_1`');
|
|
69
|
+
expect(text).toContain('`def_2`');
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
it('mentions the failover-from default for context', () => {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the inline status-accent header feature (issue #320 fallback).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Post-#2669 the header is GFM markdown (every outbound goes through the
|
|
5
|
+
* rich-message path), and the body ships as raw markdown via
|
|
6
|
+
* `sendRichMessage` / `editMessageText({ markdown })`.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
9
|
import { buildAccentHeader, handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
|
|
@@ -13,16 +13,16 @@ import { createMockBot, installBotResetHook, microtaskFlush } from './bot-api.ha
|
|
|
13
13
|
// ─── buildAccentHeader unit tests ────────────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
describe('buildAccentHeader', () => {
|
|
16
|
-
it("'in-progress' returns the blue circle header", () => {
|
|
17
|
-
expect(buildAccentHeader('in-progress')).toBe('🔵
|
|
16
|
+
it("'in-progress' returns the blue circle markdown header", () => {
|
|
17
|
+
expect(buildAccentHeader('in-progress')).toBe('🔵 _In progress…_\n\n')
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
it("'done' returns the checkmark header", () => {
|
|
21
|
-
expect(buildAccentHeader('done')).toBe('✅
|
|
20
|
+
it("'done' returns the checkmark markdown header", () => {
|
|
21
|
+
expect(buildAccentHeader('done')).toBe('✅ **Done**\n\n')
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
it("'issue' returns the warning header", () => {
|
|
25
|
-
expect(buildAccentHeader('issue')).toBe('⚠️
|
|
24
|
+
it("'issue' returns the warning markdown header", () => {
|
|
25
|
+
expect(buildAccentHeader('issue')).toBe('⚠️ **Issue**\n\n')
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
it('undefined returns empty string (no header)', () => {
|
|
@@ -41,7 +41,6 @@ describe('buildAccentHeader', () => {
|
|
|
41
41
|
function makeState(): StreamReplyState {
|
|
42
42
|
return {
|
|
43
43
|
activeDraftStreams: new Map<string, DraftStreamHandle>(),
|
|
44
|
-
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
45
44
|
}
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -51,13 +50,11 @@ function makeDeps(
|
|
|
51
50
|
): StreamReplyDeps {
|
|
52
51
|
return {
|
|
53
52
|
bot,
|
|
54
|
-
markdownToHtml: (t) => `<b>${t}</b>`,
|
|
55
|
-
escapeMarkdownV2: (t) => `\\${t}\\`,
|
|
56
53
|
repairEscapedWhitespace: (t) => t,
|
|
57
54
|
assertAllowedChat: () => {},
|
|
58
55
|
resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
59
56
|
disableLinkPreview: true,
|
|
60
|
-
defaultFormat: '
|
|
57
|
+
defaultFormat: 'markdown',
|
|
61
58
|
logStreamingEvent: () => {},
|
|
62
59
|
historyEnabled: false,
|
|
63
60
|
recordOutbound: () => {},
|
|
@@ -67,6 +64,13 @@ function makeDeps(
|
|
|
67
64
|
}
|
|
68
65
|
}
|
|
69
66
|
|
|
67
|
+
function sentMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
|
|
68
|
+
return (bot.api.sendRichMessage.mock.calls[i][1] as { markdown: string }).markdown
|
|
69
|
+
}
|
|
70
|
+
function editedMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
|
|
71
|
+
return (bot.api.editMessageText.mock.calls[i][2] as { markdown: string }).markdown
|
|
72
|
+
}
|
|
73
|
+
|
|
70
74
|
describe('handleStreamReply accent integration', () => {
|
|
71
75
|
const bot = createMockBot()
|
|
72
76
|
installBotResetHook(bot)
|
|
@@ -74,7 +78,7 @@ describe('handleStreamReply accent integration', () => {
|
|
|
74
78
|
beforeEach(() => vi.useFakeTimers())
|
|
75
79
|
afterEach(() => vi.useRealTimers())
|
|
76
80
|
|
|
77
|
-
it("accent='in-progress' prepends the blue-circle header before the body", async () => {
|
|
81
|
+
it("accent='in-progress' prepends the blue-circle markdown header before the body", async () => {
|
|
78
82
|
const state = makeState()
|
|
79
83
|
const deps = makeDeps(bot)
|
|
80
84
|
|
|
@@ -86,12 +90,12 @@ describe('handleStreamReply accent integration', () => {
|
|
|
86
90
|
await microtaskFlush()
|
|
87
91
|
await pending
|
|
88
92
|
|
|
89
|
-
const sent = bot
|
|
90
|
-
expect(sent).toMatch(/^🔵
|
|
91
|
-
expect(sent).toBe('🔵
|
|
93
|
+
const sent = sentMarkdown(bot)
|
|
94
|
+
expect(sent).toMatch(/^🔵 _In progress…_\n\n/)
|
|
95
|
+
expect(sent).toBe('🔵 _In progress…_\n\nStill working...')
|
|
92
96
|
})
|
|
93
97
|
|
|
94
|
-
it("accent='done' prepends the checkmark header before the body", async () => {
|
|
98
|
+
it("accent='done' prepends the checkmark markdown header before the body", async () => {
|
|
95
99
|
const state = makeState()
|
|
96
100
|
const deps = makeDeps(bot)
|
|
97
101
|
|
|
@@ -103,11 +107,10 @@ describe('handleStreamReply accent integration', () => {
|
|
|
103
107
|
await microtaskFlush()
|
|
104
108
|
await pending
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
expect(sent).toBe('✅ <b>Done</b>\n\n<b>Task complete.</b>')
|
|
110
|
+
expect(sentMarkdown(bot)).toBe('✅ **Done**\n\nTask complete.')
|
|
108
111
|
})
|
|
109
112
|
|
|
110
|
-
it("accent='issue' prepends the warning header before the body", async () => {
|
|
113
|
+
it("accent='issue' prepends the warning markdown header before the body", async () => {
|
|
111
114
|
const state = makeState()
|
|
112
115
|
const deps = makeDeps(bot)
|
|
113
116
|
|
|
@@ -119,11 +122,10 @@ describe('handleStreamReply accent integration', () => {
|
|
|
119
122
|
await microtaskFlush()
|
|
120
123
|
await pending
|
|
121
124
|
|
|
122
|
-
|
|
123
|
-
expect(sent).toBe('⚠️ <b>Issue</b>\n\n<b>Blocked on X.</b>')
|
|
125
|
+
expect(sentMarkdown(bot)).toBe('⚠️ **Issue**\n\nBlocked on X.')
|
|
124
126
|
})
|
|
125
127
|
|
|
126
|
-
it('no accent —
|
|
128
|
+
it('no accent — body ships as raw markdown unchanged (regression guard)', async () => {
|
|
127
129
|
const state = makeState()
|
|
128
130
|
const deps = makeDeps(bot)
|
|
129
131
|
|
|
@@ -135,8 +137,7 @@ describe('handleStreamReply accent integration', () => {
|
|
|
135
137
|
await microtaskFlush()
|
|
136
138
|
await pending
|
|
137
139
|
|
|
138
|
-
|
|
139
|
-
expect(sent).toBe('<b>Hello world</b>')
|
|
140
|
+
expect(sentMarkdown(bot)).toBe('Hello world')
|
|
140
141
|
})
|
|
141
142
|
|
|
142
143
|
it('invalid accent is silently ignored — output equals no-accent path', async () => {
|
|
@@ -151,15 +152,13 @@ describe('handleStreamReply accent integration', () => {
|
|
|
151
152
|
await microtaskFlush()
|
|
152
153
|
await pending
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
expect(sent).toBe('<b>Hello world</b>')
|
|
155
|
+
expect(sentMarkdown(bot)).toBe('Hello world')
|
|
156
156
|
})
|
|
157
157
|
|
|
158
158
|
it('accent header is included on every call that passes it (full-text replace model)', async () => {
|
|
159
159
|
const state = makeState()
|
|
160
160
|
const deps = makeDeps(bot)
|
|
161
161
|
|
|
162
|
-
// First call
|
|
163
162
|
const p1 = handleStreamReply(
|
|
164
163
|
{ chat_id: '1', text: 'Part one', accent: 'in-progress' },
|
|
165
164
|
state,
|
|
@@ -168,7 +167,6 @@ describe('handleStreamReply accent integration', () => {
|
|
|
168
167
|
await microtaskFlush()
|
|
169
168
|
await p1
|
|
170
169
|
|
|
171
|
-
// Second call — same turn, same accent
|
|
172
170
|
vi.advanceTimersByTime(1000)
|
|
173
171
|
const p2 = handleStreamReply(
|
|
174
172
|
{ chat_id: '1', text: 'Part one Part two', accent: 'in-progress' },
|
|
@@ -178,7 +176,6 @@ describe('handleStreamReply accent integration', () => {
|
|
|
178
176
|
await microtaskFlush()
|
|
179
177
|
await p2
|
|
180
178
|
|
|
181
|
-
|
|
182
|
-
expect(edited).toBe('🔵 <i>In progress…</i>\n\n<b>Part one Part two</b>')
|
|
179
|
+
expect(editedMarkdown(bot)).toBe('🔵 _In progress…_\n\nPart one Part two')
|
|
183
180
|
})
|
|
184
181
|
})
|