switchroom 0.16.28 → 0.16.38
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 +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- 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 +1293 -97
- 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/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/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/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -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 +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- 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 +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -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/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/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- 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/rollout-status-wiring.test.ts +81 -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/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- 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/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -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 +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -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 +13 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { GrammyError } from 'grammy'
|
|
3
|
+
import { decidePinAction } from '../status-pin.js'
|
|
4
|
+
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
5
|
+
import type { PinState } from '../status-pin.js'
|
|
6
|
+
|
|
7
|
+
/** A real GrammyError with the given code + description, mirroring the wire
|
|
8
|
+
* shape Telegram returns for the pin-rights failure that crashed marko. */
|
|
9
|
+
function grammyError(error_code: number, description: string): GrammyError {
|
|
10
|
+
return new GrammyError(
|
|
11
|
+
`Call to 'pinChatMessage' failed!`,
|
|
12
|
+
{ ok: false, error_code, description },
|
|
13
|
+
'pinChatMessage',
|
|
14
|
+
{} as never,
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('decidePinAction (pure)', () => {
|
|
19
|
+
it('pins an existing message when work goes in-flight (nothing pinned yet)', () => {
|
|
20
|
+
const action = decidePinAction(null, { pinned: true, messageId: 42 })
|
|
21
|
+
expect(action).toEqual({ kind: 'pin', messageId: 42 })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('unpins when work completes (something pinned)', () => {
|
|
25
|
+
const action = decidePinAction({ messageId: 42 }, { pinned: false })
|
|
26
|
+
expect(action).toEqual({ kind: 'unpin', messageId: 42 })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('noops when nothing is pinned and nothing is wanted', () => {
|
|
30
|
+
const action = decidePinAction(null, { pinned: false })
|
|
31
|
+
expect(action.kind).toBe('noop')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('noops when the wanted message is already pinned', () => {
|
|
35
|
+
const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 42 })
|
|
36
|
+
expect(action.kind).toBe('noop')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('unpins the stale claim when the wanted message id changed (feed re-posted)', () => {
|
|
40
|
+
const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 99 })
|
|
41
|
+
expect(action).toEqual({ kind: 'unpin', messageId: 42 })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** A fake Bot API recording calls, with per-method throw toggles. */
|
|
46
|
+
function fakeApi(opts: { pinThrows?: boolean; unpinThrows?: boolean } = {}) {
|
|
47
|
+
const calls: { verb: string; messageId: number; opts?: unknown }[] = []
|
|
48
|
+
const api: PinBotApi = {
|
|
49
|
+
pinChatMessage: async (_chat, message_id, o) => {
|
|
50
|
+
calls.push({ verb: 'pin', messageId: message_id, opts: o })
|
|
51
|
+
if (opts.pinThrows) throw new Error('pin failed')
|
|
52
|
+
},
|
|
53
|
+
unpinChatMessage: async (_chat, message_id) => {
|
|
54
|
+
calls.push({ verb: 'unpin', messageId: message_id })
|
|
55
|
+
if (opts.unpinThrows) throw new Error('unpin failed')
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return { api, calls }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('reconcilePin (driver)', () => {
|
|
62
|
+
it('pin on in-progress: pins the EXISTING message SILENTLY and claims it', async () => {
|
|
63
|
+
const { api, calls } = fakeApi()
|
|
64
|
+
const next = await reconcilePin({
|
|
65
|
+
api,
|
|
66
|
+
chatId: '123',
|
|
67
|
+
prevState: null,
|
|
68
|
+
desired: { pinned: true, messageId: 42 },
|
|
69
|
+
})
|
|
70
|
+
expect(next).toEqual({ messageId: 42 })
|
|
71
|
+
expect(calls).toHaveLength(1)
|
|
72
|
+
expect(calls[0].verb).toBe('pin')
|
|
73
|
+
expect(calls[0].messageId).toBe(42)
|
|
74
|
+
// Silent — must never buzz the device.
|
|
75
|
+
expect((calls[0].opts as Record<string, unknown>).disable_notification).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('unpin on finalize: unpins and drops the claim', async () => {
|
|
79
|
+
const { api, calls } = fakeApi()
|
|
80
|
+
const prev: PinState = { messageId: 42 }
|
|
81
|
+
const next = await reconcilePin({
|
|
82
|
+
api,
|
|
83
|
+
chatId: '123',
|
|
84
|
+
prevState: prev,
|
|
85
|
+
desired: { pinned: false },
|
|
86
|
+
})
|
|
87
|
+
expect(next).toBeNull()
|
|
88
|
+
expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('CRITICAL: drops the claim even when unpinChatMessage throws', async () => {
|
|
92
|
+
const errors: string[] = []
|
|
93
|
+
const { api, calls } = fakeApi({ unpinThrows: true })
|
|
94
|
+
const next = await reconcilePin({
|
|
95
|
+
api,
|
|
96
|
+
chatId: '123',
|
|
97
|
+
prevState: { messageId: 42 },
|
|
98
|
+
desired: { pinned: false },
|
|
99
|
+
onError: (phase) => errors.push(phase),
|
|
100
|
+
})
|
|
101
|
+
// State MUST be cleared even though the API threw — never stay stuck pinned.
|
|
102
|
+
expect(next).toBeNull()
|
|
103
|
+
expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
|
|
104
|
+
expect(errors).toEqual(['unpin'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does NOT claim a message whose pin failed (retries next reconcile)', async () => {
|
|
108
|
+
const errors: string[] = []
|
|
109
|
+
const { api } = fakeApi({ pinThrows: true })
|
|
110
|
+
const next = await reconcilePin({
|
|
111
|
+
api,
|
|
112
|
+
chatId: '123',
|
|
113
|
+
prevState: null,
|
|
114
|
+
desired: { pinned: true, messageId: 42 },
|
|
115
|
+
onError: (phase) => errors.push(phase),
|
|
116
|
+
})
|
|
117
|
+
// No claim taken — prevState (null) is returned so the next reconcile retries.
|
|
118
|
+
expect(next).toBeNull()
|
|
119
|
+
expect(errors).toEqual(['pin'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('never sends a new message — only pin/unpin the existing id', async () => {
|
|
123
|
+
// The fake has no sendMessage; a driver that tried to send would throw.
|
|
124
|
+
const { api } = fakeApi()
|
|
125
|
+
await reconcilePin({ api, chatId: '1', prevState: null, desired: { pinned: true, messageId: 7 } })
|
|
126
|
+
await reconcilePin({ api, chatId: '1', prevState: { messageId: 7 }, desired: { pinned: false } })
|
|
127
|
+
expect(Object.keys(api)).toEqual(['pinChatMessage', 'unpinChatMessage'])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ── Regression: the marko 2026-07-01 gateway crash ─────────────────────────
|
|
132
|
+
// A pinChatMessage 400 "not enough rights to manage pinned messages" (the bot
|
|
133
|
+
// is not an admin in a supergroup) escaped as an unhandledRejection and shut
|
|
134
|
+
// the whole gateway down. Auto status-pin is best-effort/cosmetic — a
|
|
135
|
+
// pin-rights failure must be absorbed, never fatal, and never reach the
|
|
136
|
+
// process-level unhandledRejection handler.
|
|
137
|
+
describe('reconcilePin — pin-rights 400 must never crash (marko 2026-07-01)', () => {
|
|
138
|
+
it('absorbs the "not enough rights" 400 via onError and never rejects', async () => {
|
|
139
|
+
const rightsErr = grammyError(
|
|
140
|
+
400,
|
|
141
|
+
'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
142
|
+
)
|
|
143
|
+
const captured: { phase: string; code?: number }[] = []
|
|
144
|
+
const api: PinBotApi = {
|
|
145
|
+
pinChatMessage: async () => {
|
|
146
|
+
throw rightsErr
|
|
147
|
+
},
|
|
148
|
+
unpinChatMessage: async () => {},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// reconcilePin resolves (never rejects) even when the pin API throws a
|
|
152
|
+
// real GrammyError — this is the contract the fire-and-forget
|
|
153
|
+
// `void reconcileStatusPin(...)` callsites depend on to not leak a
|
|
154
|
+
// rejection to the process.
|
|
155
|
+
const next = await reconcilePin({
|
|
156
|
+
api,
|
|
157
|
+
chatId: '-1003831053471',
|
|
158
|
+
prevState: null,
|
|
159
|
+
desired: { pinned: true, messageId: 4 },
|
|
160
|
+
onError: (phase, err) =>
|
|
161
|
+
captured.push({
|
|
162
|
+
phase,
|
|
163
|
+
code: err instanceof GrammyError ? err.error_code : undefined,
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// No claim taken (pin failed), error routed through onError, promise
|
|
168
|
+
// resolved cleanly.
|
|
169
|
+
expect(next).toBeNull()
|
|
170
|
+
expect(captured).toEqual([{ phase: 'pin', code: 400 }])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('a fire-and-forget reconcilePin does NOT emit an unhandledRejection', async () => {
|
|
174
|
+
const rejections: unknown[] = []
|
|
175
|
+
const onUnhandled = (err: unknown) => rejections.push(err)
|
|
176
|
+
process.on('unhandledRejection', onUnhandled)
|
|
177
|
+
try {
|
|
178
|
+
const api: PinBotApi = {
|
|
179
|
+
pinChatMessage: async () => {
|
|
180
|
+
throw grammyError(
|
|
181
|
+
400,
|
|
182
|
+
'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
183
|
+
)
|
|
184
|
+
},
|
|
185
|
+
unpinChatMessage: async () => {},
|
|
186
|
+
}
|
|
187
|
+
// Fire-and-forget, exactly as the gateway's auto-pin callsites do.
|
|
188
|
+
void reconcilePin({
|
|
189
|
+
api,
|
|
190
|
+
chatId: '-1003831053471',
|
|
191
|
+
prevState: null,
|
|
192
|
+
desired: { pinned: true, messageId: 4 },
|
|
193
|
+
onError: () => {},
|
|
194
|
+
})
|
|
195
|
+
// Let microtasks + a macrotask flush so any leaked rejection would fire.
|
|
196
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
197
|
+
expect(rejections).toEqual([])
|
|
198
|
+
} finally {
|
|
199
|
+
process.off('unhandledRejection', onUnhandled)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -117,6 +117,45 @@ describe('handleStreamReply', () => {
|
|
|
117
117
|
expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
+
it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
|
|
121
|
+
const state = makeState()
|
|
122
|
+
// Spacer dep replaces every `\n\n` gap with a visible marker so we can
|
|
123
|
+
// assert the rich path ran it (mirrors the real gateway wiring).
|
|
124
|
+
const deps = makeDeps(bot, {
|
|
125
|
+
addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const pending = handleStreamReply(
|
|
129
|
+
{ chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
|
|
130
|
+
state,
|
|
131
|
+
deps,
|
|
132
|
+
)
|
|
133
|
+
await microtaskFlush()
|
|
134
|
+
await pending
|
|
135
|
+
|
|
136
|
+
expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
|
|
137
|
+
expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
|
|
141
|
+
const state = makeState()
|
|
142
|
+
const deps = makeDeps(bot, {
|
|
143
|
+
addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const pending = handleStreamReply(
|
|
147
|
+
{ chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
|
|
148
|
+
state,
|
|
149
|
+
deps,
|
|
150
|
+
)
|
|
151
|
+
await microtaskFlush()
|
|
152
|
+
await pending
|
|
153
|
+
|
|
154
|
+
expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
|
|
155
|
+
// Literal path is byte-exact — no spacer injected.
|
|
156
|
+
expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
|
|
157
|
+
})
|
|
158
|
+
|
|
120
159
|
it('throws when text exceeds the rich-message cap (no silent id:pending)', async () => {
|
|
121
160
|
const state = makeState()
|
|
122
161
|
const deps = makeDeps(bot)
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
repairEscapedWhitespace,
|
|
20
20
|
escapeMarkdown,
|
|
21
21
|
splitMarkdownChunks,
|
|
22
|
+
addParagraphSpacers,
|
|
23
|
+
PARAGRAPH_SPACER,
|
|
22
24
|
RICH_MESSAGE_MAX_CHARS,
|
|
23
25
|
} from '../format.js'
|
|
24
26
|
|
|
@@ -63,6 +65,26 @@ describe('escapeMarkdown', () => {
|
|
|
63
65
|
// `\*` in the input is backslash + star → both get escaped exactly once.
|
|
64
66
|
expect(escapeMarkdown('\\*')).toBe('\\\\\\*')
|
|
65
67
|
})
|
|
68
|
+
|
|
69
|
+
// The de-dup pass (#2695) replaced ~12 local escaper copies with this one
|
|
70
|
+
// canonical function. Every consolidated caller now depends on this exact
|
|
71
|
+
// contract: the FULL metacharacter set in a single string, each escaped.
|
|
72
|
+
test('escapes the full metacharacter set in one combined string', () => {
|
|
73
|
+
// Backslash placed last so it doesn't re-escape the chars after it.
|
|
74
|
+
expect(escapeMarkdown('`*_~=[]|\\')).toBe('\\`\\*\\_\\~\\=\\[\\]\\|\\\\')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('already-safe text passes through unchanged', () => {
|
|
78
|
+
const safe = 'Switched fleet to alice (5h window 29% resets in 2h)'
|
|
79
|
+
expect(escapeMarkdown(safe)).toBe(safe)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// card-format.ts re-exports this same symbol; the consolidated callers
|
|
83
|
+
// import from one or the other. Lock that they are the identical function.
|
|
84
|
+
test('card-format re-exports the identical canonical escaper', async () => {
|
|
85
|
+
const cardFormat = await import('../card-format.js')
|
|
86
|
+
expect(cardFormat.escapeMarkdown).toBe(escapeMarkdown)
|
|
87
|
+
})
|
|
66
88
|
})
|
|
67
89
|
|
|
68
90
|
// ---------------------------------------------------------------------------
|
|
@@ -138,6 +160,72 @@ describe('splitMarkdownChunks', () => {
|
|
|
138
160
|
expect(chunks.length).toBe(1)
|
|
139
161
|
expect(chunks[0]).toBe(giant)
|
|
140
162
|
})
|
|
163
|
+
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
// Chunk-boundary spacer hygiene. addParagraphSpacers injects a
|
|
166
|
+
// `\n\n${PARAGRAPH_SPACER}\n\n` gap between prose paragraphs. When a cut
|
|
167
|
+
// lands inside that gap, the boundary must not leave a chunk that opens or
|
|
168
|
+
// ends with a bare U+00A0 spacer line (a stray blank bubble line).
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
test('no chunk starts or ends with a bare U+00A0 spacer line (reviewer repro)', () => {
|
|
172
|
+
// The exact reviewer repro: a spacer gap straddling a small cap.
|
|
173
|
+
const A = 'Alpha sentence one'
|
|
174
|
+
const B = 'Bravo sentence two'
|
|
175
|
+
const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
|
|
176
|
+
const chunks = splitMarkdownChunks(spaced, 33)
|
|
177
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
178
|
+
const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
|
|
179
|
+
for (const c of chunks) {
|
|
180
|
+
const lines = c.split('\n')
|
|
181
|
+
expect(spacerOnly.test(lines[0])).toBe(false)
|
|
182
|
+
expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('visible paragraph content survives the boundary (no text dropped)', () => {
|
|
187
|
+
const A = 'Alpha sentence one'
|
|
188
|
+
const B = 'Bravo sentence two'
|
|
189
|
+
const spaced = addParagraphSpacers(`${A}.\n\n${B}.`)
|
|
190
|
+
const chunks = splitMarkdownChunks(spaced, 33)
|
|
191
|
+
const rejoined = chunks.join('\n')
|
|
192
|
+
expect(rejoined).toContain(`${A}.`)
|
|
193
|
+
expect(rejoined).toContain(`${B}.`)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('spacer-boundary strip is robust across several gaps and small caps', () => {
|
|
197
|
+
const paras = Array.from({ length: 6 }, (_, i) => `Paragraph ${i} body text here.`)
|
|
198
|
+
const spaced = addParagraphSpacers(paras.join('\n\n'))
|
|
199
|
+
const spacerOnly = new RegExp(`^[ \\t]*${PARAGRAPH_SPACER}[ \\t]*$`)
|
|
200
|
+
for (const cap of [20, 31, 40, 64]) {
|
|
201
|
+
const chunks = splitMarkdownChunks(spaced, cap)
|
|
202
|
+
for (const c of chunks) {
|
|
203
|
+
const lines = c.split('\n')
|
|
204
|
+
expect(spacerOnly.test(lines[0])).toBe(false)
|
|
205
|
+
expect(spacerOnly.test(lines[lines.length - 1])).toBe(false)
|
|
206
|
+
}
|
|
207
|
+
// No visible word is dropped: concatenating the chunks' non-blank,
|
|
208
|
+
// non-spacer tokens reproduces the original word sequence. (Word-level,
|
|
209
|
+
// not line-level, because a small cap may split mid-word — that's the
|
|
210
|
+
// chunker's normal space-boundary behaviour, orthogonal to spacers.)
|
|
211
|
+
const words = (s: string): string[] =>
|
|
212
|
+
s.split(/\s+/).filter((w) => w.length > 0 && w !== PARAGRAPH_SPACER)
|
|
213
|
+
// Join chunks with a space — each chunk is a separate Telegram message,
|
|
214
|
+
// so inter-chunk whitespace is irrelevant; what matters is no word is
|
|
215
|
+
// lost or fused. (A small cap may end a chunk mid-sentence at a space
|
|
216
|
+
// boundary, e.g. "...here." | "Paragraph 1...", which is normal.)
|
|
217
|
+
expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
|
|
222
|
+
const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
|
|
223
|
+
const chunks = splitMarkdownChunks(text, 40)
|
|
224
|
+
// No spacer was ever present, so no chunk gains/loses anything beyond the
|
|
225
|
+
// normal leading-newline strip; content is preserved.
|
|
226
|
+
const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
|
|
227
|
+
expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
|
|
228
|
+
})
|
|
141
229
|
})
|
|
142
230
|
|
|
143
231
|
// ---------------------------------------------------------------------------
|
|
@@ -84,10 +84,10 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
84
84
|
"**→ Reading gateway.ts**",
|
|
85
85
|
);
|
|
86
86
|
expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
|
|
87
|
-
"_✓ Reading gateway.ts_\n**→ Searching memory**",
|
|
87
|
+
"~~_✓ Reading gateway.ts_~~ \n**→ Searching memory**",
|
|
88
88
|
);
|
|
89
89
|
expect(appendActivityLine(lines, "Bash", { command: "ls", description: "List workspace" })).toBe(
|
|
90
|
-
"_✓ Reading gateway.ts_\
|
|
90
|
+
"~~_✓ Reading gateway.ts_~~ \n~~_✓ Searching memory_~~ \n**→ List workspace**",
|
|
91
91
|
);
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -110,14 +110,14 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
110
110
|
const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
|
|
111
111
|
const out = renderActivityFeed(lines)!;
|
|
112
112
|
const hidden = total - STATUS_ROLLING_LINES;
|
|
113
|
-
expect(out.startsWith(`_✓ +${hidden} earlier…_\n`)).toBe(true);
|
|
113
|
+
expect(out.startsWith(`_✓ +${hidden} earlier…_ \n`)).toBe(true);
|
|
114
114
|
// Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
|
|
115
115
|
const firstVisible = total - STATUS_ROLLING_LINES + 1;
|
|
116
|
-
expect(out).toContain(
|
|
116
|
+
expect(out).toContain(`~~_✓ Action ${firstVisible}_~~`);
|
|
117
117
|
expect(out).not.toContain(`Action ${firstVisible - 1}<`);
|
|
118
118
|
// The newest action is the in-progress step (bold →); the rest are done (✓).
|
|
119
119
|
expect(out).toContain(`**→ Action ${total}**`);
|
|
120
|
-
expect(out).toContain(
|
|
120
|
+
expect(out).toContain(`~~_✓ Action ${total - 1}_~~`);
|
|
121
121
|
expect(out).not.toContain(`**→ Action ${total - 1}**`);
|
|
122
122
|
});
|
|
123
123
|
|
|
@@ -145,26 +145,46 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
145
145
|
const lines = ["Reading a.ts", "Searching memory", "Running a command"];
|
|
146
146
|
const out = renderActivityFeed(lines, true)!;
|
|
147
147
|
expect(out).toBe(
|
|
148
|
-
"_✓ Reading a.ts_\
|
|
148
|
+
"~~_✓ Reading a.ts_~~ \n~~_✓ Searching memory_~~ \n~~_✓ Running a command_~~",
|
|
149
149
|
);
|
|
150
150
|
expect(out).not.toContain("→"); // no in-progress arrow anywhere
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
it("final=true on a single line is also done (✓)", () => {
|
|
154
|
-
expect(renderActivityFeed(["Reading a.ts"], true)).toBe("_✓ Reading a.ts_");
|
|
154
|
+
expect(renderActivityFeed(["Reading a.ts"], true)).toBe("~~_✓ Reading a.ts_~~");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it("final defaults false (live render keeps the → in-progress newest line)", () => {
|
|
158
158
|
expect(renderActivityFeed(["Reading a.ts"])).toBe("**→ Reading a.ts**");
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
// Strikethrough contract (operator request): done (✓) step lines render with
|
|
162
|
+
// GFM strikethrough wrapping the italic so completed items visually recede,
|
|
163
|
+
// while the active (→ bold) line is never struck.
|
|
164
|
+
it("done step lines are struck (~~) AND italic; the active → line is not struck", () => {
|
|
165
|
+
const out = renderActivityFeed(["Reading a.ts", "Searching memory"])!;
|
|
166
|
+
// earlier step is done → struck italic
|
|
167
|
+
expect(out).toContain("~~_✓ Reading a.ts_~~");
|
|
168
|
+
// newest step is active → bold, NOT struck
|
|
169
|
+
expect(out).toContain("**→ Searching memory**");
|
|
170
|
+
expect(out).not.toContain("~~_✓ Searching memory_~~");
|
|
171
|
+
expect(out).not.toContain("~~**→");
|
|
172
|
+
// the meta/footer count lines stay UNSTRUCK (kept legible as meta)
|
|
173
|
+
const overflow = Array.from({ length: STATUS_ROLLING_LINES + 2 }, (_, i) => `Action ${i + 1}`);
|
|
174
|
+
const finalOut = renderActivityFeed(overflow, true, "", overflow.length)!;
|
|
175
|
+
expect(finalOut).toContain(`_✓ +${overflow.length - STATUS_ROLLING_LINES} earlier…_`);
|
|
176
|
+
expect(finalOut).toContain(`_✓ ${overflow.length} steps_`);
|
|
177
|
+
expect(finalOut).not.toContain(`~~_✓ +`);
|
|
178
|
+
expect(finalOut).not.toContain(`~~_✓ ${overflow.length} steps_~~`);
|
|
179
|
+
});
|
|
180
|
+
|
|
161
181
|
// liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
|
|
162
182
|
// long single step visibly advances ("→ Pulling Meta data · 18s") even though
|
|
163
183
|
// the feed is pull-only and no new tool label arrived.
|
|
164
184
|
describe("liveSuffix (heartbeat)", () => {
|
|
165
185
|
it("appends the suffix to the newest in-progress line only", () => {
|
|
166
186
|
expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " · 18s")).toBe(
|
|
167
|
-
"_✓ Reading a.ts_\n**→ Running a command · 18s**",
|
|
187
|
+
"~~_✓ Reading a.ts_~~ \n**→ Running a command · 18s**",
|
|
168
188
|
);
|
|
169
189
|
});
|
|
170
190
|
it("single live line gets the suffix", () => {
|
|
@@ -176,7 +196,7 @@ describe("appendActivityLine + renderActivityFeed — accumulating activity feed
|
|
|
176
196
|
const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " · 18s")!;
|
|
177
197
|
expect(out).not.toContain("·");
|
|
178
198
|
expect(out).not.toContain("→");
|
|
179
|
-
expect(out).toBe("_✓ Reading a.ts_\
|
|
199
|
+
expect(out).toBe("~~_✓ Reading a.ts_~~ \n~~_✓ Running a command_~~");
|
|
180
200
|
});
|
|
181
201
|
it("default empty suffix is byte-identical to no suffix", () => {
|
|
182
202
|
expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
|
|
@@ -191,7 +211,7 @@ describe("appendActivityLabel — precomputed label feed (tool_label path)", ()
|
|
|
191
211
|
const lines: string[] = [];
|
|
192
212
|
expect(appendActivityLabel(lines, "Searching memory")).toBe("**→ Searching memory**");
|
|
193
213
|
expect(appendActivityLabel(lines, "List workspace")).toBe(
|
|
194
|
-
"_✓ Searching memory_\n**→ List workspace**",
|
|
214
|
+
"~~_✓ Searching memory_~~ \n**→ List workspace**",
|
|
195
215
|
);
|
|
196
216
|
// consecutive dup collapses
|
|
197
217
|
appendActivityLabel(lines, "List workspace");
|
|
@@ -238,7 +258,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
|
|
|
238
258
|
const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
|
|
239
259
|
const out = renderActivityFeed(lines, true, "", 5)!;
|
|
240
260
|
// All lines are done (✓) and the footer is appended.
|
|
241
|
-
expect(out).toContain("_✓ Running a command_");
|
|
261
|
+
expect(out).toContain("~~_✓ Running a command_~~");
|
|
242
262
|
expect(out).toContain("_✓ 5 steps_");
|
|
243
263
|
expect(out.endsWith("_✓ 5 steps_")).toBe(true);
|
|
244
264
|
});
|
|
@@ -247,7 +267,7 @@ describe("renderActivityFeed — ✓ N steps total (final render, issue #2461)",
|
|
|
247
267
|
const lines = ["Reading CLAUDE.md"];
|
|
248
268
|
const out = renderActivityFeed(lines, true, "", 0)!;
|
|
249
269
|
expect(out).not.toContain("steps");
|
|
250
|
-
expect(out).toBe("_✓ Reading CLAUDE.md_");
|
|
270
|
+
expect(out).toBe("~~_✓ Reading CLAUDE.md_~~");
|
|
251
271
|
});
|
|
252
272
|
|
|
253
273
|
it("stepCount undefined → no footer (live/non-final callers omit it)", () => {
|
|
@@ -307,11 +327,11 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
307
327
|
const child = ["Reading schema.ts", "Looking for foreign keys"];
|
|
308
328
|
const out = renderActivityFeedWithNested(parent, child)!;
|
|
309
329
|
// Parent is blocked at the Task tool → none of its lines is the live step.
|
|
310
|
-
expect(out).toContain("_✓ Searching memory_");
|
|
311
|
-
expect(out).toContain("_✓ Delegating: review the migration_");
|
|
330
|
+
expect(out).toContain("~~_✓ Searching memory_~~");
|
|
331
|
+
expect(out).toContain("~~_✓ Delegating: review the migration_~~");
|
|
312
332
|
expect(out).not.toContain("**→ Delegating");
|
|
313
333
|
// The live → step is the newest nested child line; earlier child = italic.
|
|
314
|
-
expect(out).toContain(" ↳ _Reading schema.ts_");
|
|
334
|
+
expect(out).toContain(" ↳ ~~_Reading schema.ts_~~");
|
|
315
335
|
expect(out).toContain(" ↳ **→ Looking for foreign keys**");
|
|
316
336
|
});
|
|
317
337
|
|
|
@@ -342,13 +362,13 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
342
362
|
["Reading schema.ts", "Looking for foreign keys"],
|
|
343
363
|
true,
|
|
344
364
|
)!;
|
|
345
|
-
expect(out).toContain(" ↳ _Looking for foreign keys_"); // newest now italic done
|
|
365
|
+
expect(out).toContain(" ↳ ~~_Looking for foreign keys_~~"); // newest now struck italic done
|
|
346
366
|
expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
|
|
347
367
|
});
|
|
348
368
|
|
|
349
369
|
it("final=true with no children delegates to the finalized flat render", () => {
|
|
350
370
|
expect(renderActivityFeedWithNested(["Reading a.ts"], [], true)).toBe(
|
|
351
|
-
"_✓ Reading a.ts_",
|
|
371
|
+
"~~_✓ Reading a.ts_~~",
|
|
352
372
|
);
|
|
353
373
|
});
|
|
354
374
|
|
|
@@ -381,7 +401,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
381
401
|
it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
|
|
382
402
|
const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
|
|
383
403
|
expect(out).toContain("_✓ 3 steps_");
|
|
384
|
-
expect(out).toBe("_✓ Reading a.ts_\n_✓ 3 steps_");
|
|
404
|
+
expect(out).toBe("~~_✓ Reading a.ts_~~ \n_✓ 3 steps_");
|
|
385
405
|
});
|
|
386
406
|
|
|
387
407
|
// Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
|
|
@@ -398,7 +418,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
398
418
|
|
|
399
419
|
it("finalizes the placeholder to a done record (no frozen → line)", () => {
|
|
400
420
|
expect(renderActivityFeedWithNested(["Working…"], [], true)).toBe(
|
|
401
|
-
"_✓ Working…_",
|
|
421
|
+
"~~_✓ Working…_~~",
|
|
402
422
|
);
|
|
403
423
|
});
|
|
404
424
|
|
|
@@ -612,8 +632,8 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
|
|
|
612
632
|
expect(out).not.toBeNull();
|
|
613
633
|
expect(out.length).toBeLessThanOrEqual(4000);
|
|
614
634
|
expect(isValidMarkdown(out)).toBe(true);
|
|
615
|
-
expect(out.startsWith("_✓ ")).toBe(true);
|
|
616
|
-
expect(out.endsWith("_")).toBe(true);
|
|
635
|
+
expect(out.startsWith("~~_✓ ")).toBe(true);
|
|
636
|
+
expect(out.endsWith("_~~")).toBe(true);
|
|
617
637
|
});
|
|
618
638
|
|
|
619
639
|
it("renderActivityFeedWithNested: single ~4100-char parent line → ≤ budget and valid markdown", () => {
|
|
@@ -770,7 +790,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
770
790
|
expect(out).toContain("🤖 **Agent**");
|
|
771
791
|
expect(out).toContain("_15s · 7 tools_");
|
|
772
792
|
// Step feed follows the header.
|
|
773
|
-
expect(out).toContain("_✓ Reading CLAUDE.md_");
|
|
793
|
+
expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
|
|
774
794
|
expect(out).toContain("**→ Searching memory**");
|
|
775
795
|
});
|
|
776
796
|
|
|
@@ -784,7 +804,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
784
804
|
const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
|
|
785
805
|
expect(out).toContain("🤖 **Agent**");
|
|
786
806
|
expect(out).toContain("_done · 3 tools · 1m05s_");
|
|
787
|
-
expect(out).toContain("_✓ Reading CLAUDE.md_");
|
|
807
|
+
expect(out).toContain("~~_✓ Reading CLAUDE.md_~~");
|
|
788
808
|
// No in-progress arrow (final=true).
|
|
789
809
|
expect(out).not.toContain("→");
|
|
790
810
|
});
|
|
@@ -824,7 +844,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
824
844
|
expect(out).toContain("🤖 **Agent**");
|
|
825
845
|
expect(out).toContain("_30s · 5 tools_");
|
|
826
846
|
// Parent step is done-styled (child is the live step).
|
|
827
|
-
expect(out).toContain("_✓ Delegating: review_");
|
|
847
|
+
expect(out).toContain("~~_✓ Delegating: review_~~");
|
|
828
848
|
// Child step is the in-progress step.
|
|
829
849
|
expect(out).toContain(" ↳ **→ Reading schema.ts**");
|
|
830
850
|
});
|
|
@@ -847,3 +867,41 @@ describe("describeToolUse — surface-tool suppression is key-agnostic", () => {
|
|
|
847
867
|
expect(describeToolUse("mcp__clerk-telegram__react", {})).toBeNull();
|
|
848
868
|
});
|
|
849
869
|
});
|
|
870
|
+
|
|
871
|
+
describe("status-card body stacks like a reply (#2669 rich-message renderer)", () => {
|
|
872
|
+
// The single most-seen surface: the operator's progress card. Its step lines
|
|
873
|
+
// are styled prose (`~~_✓ …_~~`, `**→ …**`), NOT GFM list items, so a lone `\n`
|
|
874
|
+
// between them is a SOFT break the renderer collapses — bullets ran together
|
|
875
|
+
// into one line. The card now joins via stackCardLines (hard breaks), so the
|
|
876
|
+
// bullets stack exactly as the reply path renders them.
|
|
877
|
+
|
|
878
|
+
it("multiple step bullets stack on their own lines (no soft-break collapse)", () => {
|
|
879
|
+
const out = renderActivityFeed(["Reading a.ts", "Searching memory", "Running tests"], true)!;
|
|
880
|
+
const lines = out.split(" \n");
|
|
881
|
+
expect(lines).toEqual(["~~_✓ Reading a.ts_~~", "~~_✓ Searching memory_~~", "~~_✓ Running tests_~~"]);
|
|
882
|
+
// Not a single lone `\n` survives to collapse two bullets.
|
|
883
|
+
expect(out).not.toMatch(/(?<! {2})\n/);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("the collapsed-inline-bullet case is fixed on a card body specifically", () => {
|
|
887
|
+
// Three labels that, joined by a lone `\n`, would render as a single run-on
|
|
888
|
+
// visual line in the rich renderer. After stacking, each renders distinctly.
|
|
889
|
+
const out = renderActivityFeed(["Editing foo.ts", "Editing bar.ts", "Writing baz.ts"])!;
|
|
890
|
+
// Every adjacent pair is separated by a hard break.
|
|
891
|
+
expect(out.match(/ {2}\n/g)?.length).toBe(2);
|
|
892
|
+
// The newest is the in-progress bold step; earlier are done — all visible.
|
|
893
|
+
expect(out).toContain("~~_✓ Editing foo.ts_~~");
|
|
894
|
+
expect(out).toContain("~~_✓ Editing bar.ts_~~");
|
|
895
|
+
expect(out).toContain("**→ Writing baz.ts**");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("stacking does not corrupt the card's own italic/bold wrapper markup", () => {
|
|
899
|
+
// The status card wraps each step in `_…_` (done) or `**→ …**` (live). The
|
|
900
|
+
// stacking pass only changes the SEPARATOR between lines — the per-line
|
|
901
|
+
// wrapper markup must be byte-identical. (Step TEXT is stripped to plain
|
|
902
|
+
// prose by escapeStepLine; the inline-markup-survives case is covered by
|
|
903
|
+
// the stackCardLines unit test, which operates on pre-rendered lines.)
|
|
904
|
+
const out = renderActivityFeed(["Reading a.ts", "Running tests"], false)!;
|
|
905
|
+
expect(out).toBe("~~_✓ Reading a.ts_~~ \n**→ Running tests**");
|
|
906
|
+
});
|
|
907
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { maybeRotate, TURNS_JSONL_MAX_BYTES, type RotateFs } from '../gateway/turns-jsonl-rotate.js'
|
|
4
|
+
|
|
5
|
+
describe('maybeRotate — turns.jsonl size cap', () => {
|
|
6
|
+
const mkFs = (size: number | undefined) => {
|
|
7
|
+
const rename = vi.fn()
|
|
8
|
+
const fs: RotateFs = { statSize: () => size, rename }
|
|
9
|
+
return { fs, rename }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
it('does not rotate below the cap', () => {
|
|
13
|
+
const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES - 1)
|
|
14
|
+
expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
|
|
15
|
+
expect(rename).not.toHaveBeenCalled()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rotates at/over the cap → renames to <path>.1', () => {
|
|
19
|
+
const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES)
|
|
20
|
+
expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(true)
|
|
21
|
+
expect(rename).toHaveBeenCalledWith('/state/agent/turns.jsonl', '/state/agent/turns.jsonl.1')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('absent file (statSize undefined) → no rotation', () => {
|
|
25
|
+
const { fs, rename } = mkFs(undefined)
|
|
26
|
+
expect(maybeRotate('/state/agent/turns.jsonl', fs)).toBe(false)
|
|
27
|
+
expect(rename).not.toHaveBeenCalled()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('rotation is bounded to one generation (overwrites <path>.1)', () => {
|
|
31
|
+
// A second rotation renames onto the same .1 target — no unbounded
|
|
32
|
+
// accumulation of generations.
|
|
33
|
+
const { fs, rename } = mkFs(TURNS_JSONL_MAX_BYTES * 3)
|
|
34
|
+
maybeRotate('/a/turns.jsonl', fs)
|
|
35
|
+
maybeRotate('/a/turns.jsonl', fs)
|
|
36
|
+
expect(rename).toHaveBeenNthCalledWith(1, '/a/turns.jsonl', '/a/turns.jsonl.1')
|
|
37
|
+
expect(rename).toHaveBeenNthCalledWith(2, '/a/turns.jsonl', '/a/turns.jsonl.1')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -84,6 +84,18 @@ describe('classifyRejection — benign Telegram 400s', () => {
|
|
|
84
84
|
const err = grammyError(400, 'Bad Request: MESSAGE IS NOT MODIFIED: blah')
|
|
85
85
|
expect(classifyRejection(err)).toBe('log_only')
|
|
86
86
|
})
|
|
87
|
+
|
|
88
|
+
it('returns "log_only" for pin-rights 400 "not enough rights to manage pinned messages" (marko 2026-07-01)', () => {
|
|
89
|
+
// The bot is not an admin in a supergroup, so the auto status-pin
|
|
90
|
+
// driver's pinChatMessage returns this 400. It was crashing the whole
|
|
91
|
+
// gateway via a leaked fire-and-forget rejection. Auto-pin is cosmetic —
|
|
92
|
+
// a missing pin right must never be fatal.
|
|
93
|
+
const err = grammyError(
|
|
94
|
+
400,
|
|
95
|
+
'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
96
|
+
)
|
|
97
|
+
expect(classifyRejection(err)).toBe('log_only')
|
|
98
|
+
})
|
|
87
99
|
})
|
|
88
100
|
|
|
89
101
|
describe('classifyRejection — genuine errors still crash', () => {
|