switchroom 0.19.48 → 0.20.1
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/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- package/vendor/hindsight-memory/scripts/recall.py +7 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply-to buffer fallback + role tag + native partial-quote preference.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THESE PIN (post-reset conversation continuity)
|
|
5
|
+
* ---------------------------------------------------
|
|
6
|
+
* The real incident: after a session reset (`resume_mode: handoff` → fresh
|
|
7
|
+
* session, transcript gone) a user native-REPLIED to one of the BOT's OWN
|
|
8
|
+
* messages ("is this added as a calendar invite yet?"). Telegram delivers
|
|
9
|
+
* `reply_to_message.message_id` on such a reply but NOT the message's `.text`
|
|
10
|
+
* (it omits the text when the reply target is the bot's own message), so the
|
|
11
|
+
* live update carried no antecedent — and the agent had to guess. The bot had
|
|
12
|
+
* already persisted that outbound to `history.db` via `recordOutbound`
|
|
13
|
+
* (role='assistant'); the fix reads it back.
|
|
14
|
+
*
|
|
15
|
+
* These are OUTCOME tests against the real pure builders, not the code paths:
|
|
16
|
+
* - `resolveReplyToFromBuffer` recovers the text AND the role from a buffer
|
|
17
|
+
* hit, truncates to the cap, and — critically — sets BOTH the raw
|
|
18
|
+
* `replyToText` (which the gateway's recordInbound write persists, so the
|
|
19
|
+
* DB row is non-NULL for future briefings) and the escaped form (for the
|
|
20
|
+
* envelope). A test that only checked the envelope would pass on the
|
|
21
|
+
* rejected envelope-only design and miss the NULL-row regression.
|
|
22
|
+
* - The history-disabled / lookup-throws guard degrades silently (no throw).
|
|
23
|
+
* - A non-empty LIVE reply text is never overwritten.
|
|
24
|
+
* - `buildReplyForwardContext` prefers `message.quote.text` (a native
|
|
25
|
+
* partial quote) over the full parent `.text`.
|
|
26
|
+
* - `buildInboundEnvelope` emits `reply_to_role` when known and `reply_to_text`
|
|
27
|
+
* from the recovered escaped form.
|
|
28
|
+
*
|
|
29
|
+
* The persisted-row-non-NULL half of the 1a contract (which needs a real
|
|
30
|
+
* bun:sqlite history.db) lives in reply-to-buffer-history.test.ts (bun).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest'
|
|
34
|
+
import type { Context } from 'grammy'
|
|
35
|
+
import {
|
|
36
|
+
buildReplyForwardContext,
|
|
37
|
+
resolveReplyToFromBuffer,
|
|
38
|
+
buildInboundEnvelope,
|
|
39
|
+
type EnvelopeBuildParams,
|
|
40
|
+
} from '../gateway/inbound-router.js'
|
|
41
|
+
|
|
42
|
+
const REPLY_TO_TEXT_MAX = 200
|
|
43
|
+
|
|
44
|
+
/** A minimal grammy Context carrying only the message fields the builders read. */
|
|
45
|
+
function makeCtx(message: Record<string, unknown>): Context {
|
|
46
|
+
return { message: { date: 1_700_000_000, ...message } } as unknown as Context
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('resolveReplyToFromBuffer — reply-to buffer fallback (1a)', () => {
|
|
50
|
+
it('recovers a bot-authored antecedent: sets raw text, escaped text, AND role', () => {
|
|
51
|
+
// Live update: a native reply to the bot's own message → id present, text empty.
|
|
52
|
+
const out = resolveReplyToFromBuffer({
|
|
53
|
+
replyToMessageId: 42,
|
|
54
|
+
replyToText: undefined,
|
|
55
|
+
replyToTextEscaped: undefined,
|
|
56
|
+
historyEnabled: true,
|
|
57
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
58
|
+
lookup: (id) =>
|
|
59
|
+
id === 42
|
|
60
|
+
? { role: 'assistant', text: 'Added the calendar invite for Friday 3pm.' }
|
|
61
|
+
: null,
|
|
62
|
+
})
|
|
63
|
+
// Raw text (for the SQLite recordInbound write — NOT envelope-only).
|
|
64
|
+
expect(out.replyToText).toBe('Added the calendar invite for Friday 3pm.')
|
|
65
|
+
// Escaped form (for the channel-meta reply_to_text).
|
|
66
|
+
expect(out.replyToTextEscaped).toBe('Added the calendar invite for Friday 3pm.')
|
|
67
|
+
// Role disambiguates "you are replying to the bot's own message".
|
|
68
|
+
expect(out.replyToRole).toBe('assistant')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("tags a person's message as role='user'", () => {
|
|
72
|
+
const out = resolveReplyToFromBuffer({
|
|
73
|
+
replyToMessageId: 7,
|
|
74
|
+
replyToText: undefined,
|
|
75
|
+
replyToTextEscaped: undefined,
|
|
76
|
+
historyEnabled: true,
|
|
77
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
78
|
+
lookup: () => ({ role: 'user', text: 'the thing I asked earlier' }),
|
|
79
|
+
})
|
|
80
|
+
expect(out.replyToRole).toBe('user')
|
|
81
|
+
expect(out.replyToText).toBe('the thing I asked earlier')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('truncates the recovered text to REPLY_TO_TEXT_MAX (raw and escaped)', () => {
|
|
85
|
+
const long = 'x'.repeat(500)
|
|
86
|
+
const out = resolveReplyToFromBuffer({
|
|
87
|
+
replyToMessageId: 1,
|
|
88
|
+
replyToText: undefined,
|
|
89
|
+
replyToTextEscaped: undefined,
|
|
90
|
+
historyEnabled: true,
|
|
91
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
92
|
+
lookup: () => ({ role: 'assistant', text: long }),
|
|
93
|
+
})
|
|
94
|
+
// Raw: sliced to max-1 chars + ellipsis = exactly max glyphs.
|
|
95
|
+
expect([...(out.replyToText ?? '')].length).toBe(REPLY_TO_TEXT_MAX)
|
|
96
|
+
expect(out.replyToText?.endsWith('…')).toBe(true)
|
|
97
|
+
expect([...(out.replyToTextEscaped ?? '')].length).toBe(REPLY_TO_TEXT_MAX)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does NOT overwrite a non-empty LIVE reply text (reply to a person, or a partial quote)', () => {
|
|
101
|
+
const lookup = () => {
|
|
102
|
+
throw new Error('lookup must not be called when live text is present')
|
|
103
|
+
}
|
|
104
|
+
const out = resolveReplyToFromBuffer({
|
|
105
|
+
replyToMessageId: 9,
|
|
106
|
+
replyToText: 'live raw text',
|
|
107
|
+
replyToTextEscaped: 'live escaped text',
|
|
108
|
+
historyEnabled: true,
|
|
109
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
110
|
+
lookup,
|
|
111
|
+
})
|
|
112
|
+
expect(out.replyToText).toBe('live raw text')
|
|
113
|
+
expect(out.replyToTextEscaped).toBe('live escaped text')
|
|
114
|
+
expect(out.replyToRole).toBeUndefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('history-disabled guard: never calls lookup, degrades to id-only, does not throw', () => {
|
|
118
|
+
let called = false
|
|
119
|
+
const call = () =>
|
|
120
|
+
resolveReplyToFromBuffer({
|
|
121
|
+
replyToMessageId: 5,
|
|
122
|
+
replyToText: undefined,
|
|
123
|
+
replyToTextEscaped: undefined,
|
|
124
|
+
historyEnabled: false,
|
|
125
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
126
|
+
lookup: () => {
|
|
127
|
+
called = true
|
|
128
|
+
throw new Error('requireDb would throw with history disabled')
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
expect(call).not.toThrow()
|
|
132
|
+
expect(called).toBe(false)
|
|
133
|
+
expect(call().replyToText).toBeUndefined()
|
|
134
|
+
expect(call().replyToRole).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('a lookup that throws (requireDb mid-run) degrades silently', () => {
|
|
138
|
+
let out: ReturnType<typeof resolveReplyToFromBuffer> | undefined
|
|
139
|
+
expect(() => {
|
|
140
|
+
out = resolveReplyToFromBuffer({
|
|
141
|
+
replyToMessageId: 5,
|
|
142
|
+
replyToText: undefined,
|
|
143
|
+
replyToTextEscaped: undefined,
|
|
144
|
+
historyEnabled: true,
|
|
145
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
146
|
+
lookup: () => {
|
|
147
|
+
throw new Error('SQLITE_ERROR')
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
}).not.toThrow()
|
|
151
|
+
expect(out?.replyToText).toBeUndefined()
|
|
152
|
+
expect(out?.replyToRole).toBeUndefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('a missing row (reacted-to message predates retention) yields id-only', () => {
|
|
156
|
+
const out = resolveReplyToFromBuffer({
|
|
157
|
+
replyToMessageId: 999,
|
|
158
|
+
replyToText: undefined,
|
|
159
|
+
replyToTextEscaped: undefined,
|
|
160
|
+
historyEnabled: true,
|
|
161
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
162
|
+
lookup: () => null,
|
|
163
|
+
})
|
|
164
|
+
expect(out.replyToText).toBeUndefined()
|
|
165
|
+
expect(out.replyToRole).toBeUndefined()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('a row with empty text still surfaces the role (authorship known, text redacted)', () => {
|
|
169
|
+
const out = resolveReplyToFromBuffer({
|
|
170
|
+
replyToMessageId: 3,
|
|
171
|
+
replyToText: undefined,
|
|
172
|
+
replyToTextEscaped: undefined,
|
|
173
|
+
historyEnabled: true,
|
|
174
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
175
|
+
lookup: () => ({ role: 'assistant', text: '' }),
|
|
176
|
+
})
|
|
177
|
+
expect(out.replyToRole).toBe('assistant')
|
|
178
|
+
expect(out.replyToText).toBeUndefined()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('buildReplyForwardContext — native partial-quote preference (2a)', () => {
|
|
183
|
+
it('prefers message.quote.text over the full parent .text', () => {
|
|
184
|
+
const ctx = makeCtx({
|
|
185
|
+
reply_to_message: { message_id: 100, text: 'the entire long parent message body' },
|
|
186
|
+
quote: { text: 'the exact fragment I selected', position: 5, is_manual: true },
|
|
187
|
+
})
|
|
188
|
+
const out = buildReplyForwardContext({ ctx, coalescedForwardOrigins: undefined, replyToTextMax: REPLY_TO_TEXT_MAX })
|
|
189
|
+
expect(out.replyToMessageId).toBe(100)
|
|
190
|
+
expect(out.replyToText).toBe('the exact fragment I selected')
|
|
191
|
+
expect(out.replyToTextEscaped).toBe('the exact fragment I selected')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('falls back to the parent .text when there is no quote', () => {
|
|
195
|
+
const ctx = makeCtx({
|
|
196
|
+
reply_to_message: { message_id: 101, text: 'parent body only' },
|
|
197
|
+
})
|
|
198
|
+
const out = buildReplyForwardContext({ ctx, coalescedForwardOrigins: undefined, replyToTextMax: REPLY_TO_TEXT_MAX })
|
|
199
|
+
expect(out.replyToText).toBe('parent body only')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('leaves reply text empty when the bot-authored parent carries no text (the 1a trigger)', () => {
|
|
203
|
+
const ctx = makeCtx({ reply_to_message: { message_id: 102 } })
|
|
204
|
+
const out = buildReplyForwardContext({ ctx, coalescedForwardOrigins: undefined, replyToTextMax: REPLY_TO_TEXT_MAX })
|
|
205
|
+
expect(out.replyToMessageId).toBe(102)
|
|
206
|
+
expect(out.replyToText).toBeUndefined()
|
|
207
|
+
expect(out.replyToTextEscaped).toBeUndefined()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
|
|
212
|
+
return {
|
|
213
|
+
ctx: makeCtx({}),
|
|
214
|
+
chat_id: '5550001',
|
|
215
|
+
messageThreadId: undefined,
|
|
216
|
+
msgId: 200,
|
|
217
|
+
effectiveText: 'is this added as a calendar invite yet?',
|
|
218
|
+
imagePath: undefined,
|
|
219
|
+
attachment: undefined,
|
|
220
|
+
attachmentCount: 0,
|
|
221
|
+
extraMeta: {},
|
|
222
|
+
from: { id: 111, username: 'alice' },
|
|
223
|
+
access: { groups: {} },
|
|
224
|
+
isSteering: false,
|
|
225
|
+
isQueuedPrefix: false,
|
|
226
|
+
isQueuedMidTurn: false,
|
|
227
|
+
priorTurnInProgress: false,
|
|
228
|
+
secondsSinceTurnStart: undefined,
|
|
229
|
+
priorAssistantPreview: undefined,
|
|
230
|
+
replyToMessageId: 42,
|
|
231
|
+
replyToTextEscaped: undefined,
|
|
232
|
+
replyToRole: undefined,
|
|
233
|
+
forwardOriginMeta: {},
|
|
234
|
+
topicFramingEnabled: false,
|
|
235
|
+
personDirectory: { byTelegramKey: {} },
|
|
236
|
+
isDmChatId: () => true,
|
|
237
|
+
...overrides,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
describe('buildInboundEnvelope — reply_to_role + reply_to_text emission', () => {
|
|
242
|
+
it('emits reply_to_role and reply_to_text when the buffer fallback recovered them', () => {
|
|
243
|
+
const msg = buildInboundEnvelope(
|
|
244
|
+
makeEnvelopeParams({
|
|
245
|
+
replyToMessageId: 42,
|
|
246
|
+
replyToTextEscaped: 'Added the calendar invite for Friday 3pm.',
|
|
247
|
+
replyToRole: 'assistant',
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
expect(msg.meta?.reply_to_message_id).toBe('42')
|
|
251
|
+
expect(msg.meta?.reply_to_text).toBe('Added the calendar invite for Friday 3pm.')
|
|
252
|
+
expect(msg.meta?.reply_to_role).toBe('assistant')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('omits reply_to_role when unknown (no buffer hit) but still emits the id', () => {
|
|
256
|
+
const msg = buildInboundEnvelope(
|
|
257
|
+
makeEnvelopeParams({ replyToMessageId: 42, replyToTextEscaped: undefined, replyToRole: undefined }),
|
|
258
|
+
)
|
|
259
|
+
expect(msg.meta?.reply_to_message_id).toBe('42')
|
|
260
|
+
expect('reply_to_role' in (msg.meta ?? {})).toBe(false)
|
|
261
|
+
expect('reply_to_text' in (msg.meta ?? {})).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("emits reply_to_role='user' for a recovered person message", () => {
|
|
265
|
+
const msg = buildInboundEnvelope(
|
|
266
|
+
makeEnvelopeParams({
|
|
267
|
+
replyToTextEscaped: 'the thing I asked earlier',
|
|
268
|
+
replyToRole: 'user',
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
271
|
+
expect(msg.meta?.reply_to_role).toBe('user')
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reply-to buffer fallback — REAL history.db integration (1a persistence half).
|
|
3
|
+
*
|
|
4
|
+
* The pure-builder outcomes live in reply-to-buffer-fallback.test.ts (vitest).
|
|
5
|
+
* This file pins the half that needs a real bun:sqlite `history.db`: that the
|
|
6
|
+
* recovered antecedent is actually PERSISTED to the inbound row (reply_to_text
|
|
7
|
+
* non-NULL), not just emitted on the envelope. Envelope-only was the rejected
|
|
8
|
+
* design — it leaves the DB row NULL and starves the next handoff briefing,
|
|
9
|
+
* which reads reply_to_text back out of this store. So this drives the exact
|
|
10
|
+
* production chain: recordOutbound (the bot's own message) → lookup it back →
|
|
11
|
+
* resolveReplyToFromBuffer → recordInbound(reply_to_text) → read the row.
|
|
12
|
+
*
|
|
13
|
+
* Runs under `bun test` (bun:sqlite is a Bun built-in vitest/Node can't
|
|
14
|
+
* resolve); vitest-excluded in vitest.config.ts, covered by the bun `tests/`
|
|
15
|
+
* target in telegram-plugin/scripts/bun-test-ci.sh.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
19
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
|
20
|
+
import { tmpdir } from 'os'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import {
|
|
23
|
+
initHistory,
|
|
24
|
+
recordInbound,
|
|
25
|
+
recordOutbound,
|
|
26
|
+
lookupMessageRoleAndText,
|
|
27
|
+
query,
|
|
28
|
+
_resetForTests,
|
|
29
|
+
} from '../history.js'
|
|
30
|
+
import { resolveReplyToFromBuffer } from '../gateway/inbound-router.js'
|
|
31
|
+
|
|
32
|
+
const REPLY_TO_TEXT_MAX = 200
|
|
33
|
+
const CHAT = '5550001'
|
|
34
|
+
|
|
35
|
+
let stateDir: string
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
stateDir = mkdtempSync(join(tmpdir(), 'reply-to-buffer-'))
|
|
39
|
+
initHistory(stateDir, 30)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
_resetForTests()
|
|
44
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('reply-to buffer fallback persists the recovered antecedent (1a)', () => {
|
|
48
|
+
it('a native reply to the bot own message recovers text+role from history and writes a non-NULL row', () => {
|
|
49
|
+
// 1. The bot sent a message earlier; recordOutbound persisted it (role='assistant').
|
|
50
|
+
const botMsgId = 4242
|
|
51
|
+
recordOutbound({
|
|
52
|
+
chat_id: CHAT,
|
|
53
|
+
thread_id: null,
|
|
54
|
+
message_ids: [botMsgId],
|
|
55
|
+
texts: ['Added the calendar invite for Friday 3pm.'],
|
|
56
|
+
ts: 1000,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// 2. Session reset happened (transcript gone). The user native-replies to
|
|
60
|
+
// that bot message. Telegram gives us the id but NOT the text, so the
|
|
61
|
+
// live reply text is empty — exactly the incident condition.
|
|
62
|
+
const recovered = resolveReplyToFromBuffer({
|
|
63
|
+
replyToMessageId: botMsgId,
|
|
64
|
+
replyToText: undefined,
|
|
65
|
+
replyToTextEscaped: undefined,
|
|
66
|
+
historyEnabled: true,
|
|
67
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
68
|
+
lookup: (id) => lookupMessageRoleAndText(CHAT, id),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// The buffer recovered both the text and the authorship.
|
|
72
|
+
expect(recovered.replyToText).toBe('Added the calendar invite for Friday 3pm.')
|
|
73
|
+
expect(recovered.replyToRole).toBe('assistant')
|
|
74
|
+
|
|
75
|
+
// 3. The gateway records the inbound with the recovered reply_to_text.
|
|
76
|
+
const userMsgId = 4300
|
|
77
|
+
recordInbound({
|
|
78
|
+
chat_id: CHAT,
|
|
79
|
+
thread_id: null,
|
|
80
|
+
message_id: userMsgId,
|
|
81
|
+
user: 'alice',
|
|
82
|
+
user_id: '111',
|
|
83
|
+
ts: 2000,
|
|
84
|
+
text: 'is this added as a calendar invite yet?',
|
|
85
|
+
reply_to_message_id: botMsgId,
|
|
86
|
+
reply_to_text: recovered.replyToText ?? null,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// 4. THE outcome that would fail on the old bug and the rejected
|
|
90
|
+
// envelope-only design: the persisted inbound row's reply_to_text is
|
|
91
|
+
// non-NULL and carries the recovered antecedent.
|
|
92
|
+
const rows = query({ chat_id: CHAT, thread_id: null, limit: 10 })
|
|
93
|
+
const inboundRow = rows.find((r) => r.message_id === userMsgId) as
|
|
94
|
+
| { reply_to_text?: string | null; reply_to_message_id?: number | null }
|
|
95
|
+
| undefined
|
|
96
|
+
expect(inboundRow).toBeDefined()
|
|
97
|
+
expect(inboundRow!.reply_to_message_id).toBe(botMsgId)
|
|
98
|
+
expect(inboundRow!.reply_to_text).not.toBeNull()
|
|
99
|
+
expect(inboundRow!.reply_to_text).toBe('Added the calendar invite for Friday 3pm.')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('a reply target absent from history (predates retention) writes a NULL row without throwing — no regression', () => {
|
|
103
|
+
const missingId = 99999
|
|
104
|
+
const recovered = resolveReplyToFromBuffer({
|
|
105
|
+
replyToMessageId: missingId,
|
|
106
|
+
replyToText: undefined,
|
|
107
|
+
replyToTextEscaped: undefined,
|
|
108
|
+
historyEnabled: true,
|
|
109
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
110
|
+
lookup: (id) => lookupMessageRoleAndText(CHAT, id),
|
|
111
|
+
})
|
|
112
|
+
expect(recovered.replyToText).toBeUndefined()
|
|
113
|
+
expect(recovered.replyToRole).toBeUndefined()
|
|
114
|
+
|
|
115
|
+
const userMsgId = 4301
|
|
116
|
+
recordInbound({
|
|
117
|
+
chat_id: CHAT,
|
|
118
|
+
thread_id: null,
|
|
119
|
+
message_id: userMsgId,
|
|
120
|
+
user: 'alice',
|
|
121
|
+
user_id: '111',
|
|
122
|
+
ts: 2000,
|
|
123
|
+
text: 'what about this one?',
|
|
124
|
+
reply_to_message_id: missingId,
|
|
125
|
+
reply_to_text: recovered.replyToText ?? null,
|
|
126
|
+
})
|
|
127
|
+
const rows = query({ chat_id: CHAT, thread_id: null, limit: 10 })
|
|
128
|
+
const inboundRow = rows.find((r) => r.message_id === userMsgId) as
|
|
129
|
+
| { reply_to_text?: string | null }
|
|
130
|
+
| undefined
|
|
131
|
+
expect(inboundRow).toBeDefined()
|
|
132
|
+
expect(inboundRow!.reply_to_text ?? null).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
emitTransportTransientEvent,
|
|
4
|
+
flushDeferredUserNotices,
|
|
5
|
+
noteTransportTransientAndShouldEscalate,
|
|
6
|
+
renderTransportEscalationCard,
|
|
7
|
+
resetTransportTransientEscalation,
|
|
8
|
+
type UserFailureNoticeDeps,
|
|
9
|
+
} from '../gateway/user-failure-notices.js'
|
|
10
|
+
import type { OperatorEvent } from '../operator-events.js'
|
|
11
|
+
import type { PendingUserNotice } from '../pending-user-notice.js'
|
|
12
|
+
|
|
13
|
+
// ─── Fake deps: capture every side effect, drive time explicitly ─────────────
|
|
14
|
+
|
|
15
|
+
interface Capture {
|
|
16
|
+
recorded: OperatorEvent[]
|
|
17
|
+
scheduled: Array<{ chatIds: string[]; agent: string; kind: string; key: string | undefined; atMs: number }>
|
|
18
|
+
sent: Array<{ chatId: string; text: string; hasKeyboard: boolean }>
|
|
19
|
+
logs: string[]
|
|
20
|
+
/** Notices the fake gate will release on the next resolveNotices call. */
|
|
21
|
+
resolvedQueue: PendingUserNotice[]
|
|
22
|
+
lastResolve?: { delivered: boolean; key: string }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeDeps(over?: {
|
|
26
|
+
allowFrom?: string[]
|
|
27
|
+
liveTurnKey?: string | undefined
|
|
28
|
+
now?: number
|
|
29
|
+
}): { deps: UserFailureNoticeDeps; cap: Capture } {
|
|
30
|
+
const cap: Capture = { recorded: [], scheduled: [], sent: [], logs: [], resolvedQueue: [] }
|
|
31
|
+
const deps: UserFailureNoticeDeps = {
|
|
32
|
+
now: () => over?.now ?? 1_000,
|
|
33
|
+
allowFrom: () => over?.allowFrom ?? ['op', 'user-a', 'user-b'],
|
|
34
|
+
liveTurnKey: () => ('liveTurnKey' in (over ?? {}) ? over!.liveTurnKey : 'chat:topic'),
|
|
35
|
+
record: (e) => cap.recorded.push(e),
|
|
36
|
+
scheduleUserNotice: (i) => cap.scheduled.push(i),
|
|
37
|
+
resolveNotices: (delivered, key) => {
|
|
38
|
+
cap.lastResolve = { delivered, key }
|
|
39
|
+
// Emulate the real gate: a delivered reply drops everything.
|
|
40
|
+
return delivered ? [] : cap.resolvedQueue
|
|
41
|
+
},
|
|
42
|
+
send: (chatId, text, keyboard) => cap.sent.push({ chatId, text, hasKeyboard: keyboard != null }),
|
|
43
|
+
log: (m) => cap.logs.push(m),
|
|
44
|
+
}
|
|
45
|
+
return { deps, cap }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ev(overrides?: Partial<OperatorEvent>): OperatorEvent {
|
|
49
|
+
return {
|
|
50
|
+
kind: 'transport-transient',
|
|
51
|
+
agent: 'gymbro',
|
|
52
|
+
detail: 'API Error: Connection closed mid-response',
|
|
53
|
+
suggestedActions: [],
|
|
54
|
+
firstSeenAt: new Date('2026-08-02T00:00:00Z'),
|
|
55
|
+
...overrides,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => resetTransportTransientEscalation())
|
|
60
|
+
|
|
61
|
+
// ─── emitTransportTransientEvent — outcomes ──────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe('emitTransportTransientEvent', () => {
|
|
64
|
+
it('records history and schedules a deferred user notice, but sends NO card (single event)', () => {
|
|
65
|
+
const { deps, cap } = makeDeps()
|
|
66
|
+
emitTransportTransientEvent(ev(), deps)
|
|
67
|
+
// history recorded for /status
|
|
68
|
+
expect(cap.recorded).toHaveLength(1)
|
|
69
|
+
expect(cap.recorded[0].kind).toBe('transport-transient')
|
|
70
|
+
// user notice scheduled to ALL allowlist chats, keyed to the live turn
|
|
71
|
+
expect(cap.scheduled).toHaveLength(1)
|
|
72
|
+
expect(cap.scheduled[0].chatIds).toEqual(['op', 'user-a', 'user-b'])
|
|
73
|
+
expect(cap.scheduled[0].key).toBe('chat:topic')
|
|
74
|
+
// NO broadcast / escalation card for a single event
|
|
75
|
+
expect(cap.sent).toHaveLength(0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('records even when there is no allowlist, but schedules nothing', () => {
|
|
79
|
+
const { deps, cap } = makeDeps({ allowFrom: [] })
|
|
80
|
+
emitTransportTransientEvent(ev(), deps)
|
|
81
|
+
expect(cap.recorded).toHaveLength(1)
|
|
82
|
+
expect(cap.scheduled).toHaveLength(0)
|
|
83
|
+
expect(cap.sent).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('carries an undefined notice key when there is no live turn', () => {
|
|
87
|
+
const { deps, cap } = makeDeps({ liveTurnKey: undefined })
|
|
88
|
+
emitTransportTransientEvent(ev(), deps)
|
|
89
|
+
expect(cap.scheduled[0].key).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ─── Escalation: >=3 within the window → exactly ONE operator-only card ───────
|
|
94
|
+
|
|
95
|
+
describe('transport-transient escalation bound', () => {
|
|
96
|
+
it('sends exactly ONE operator-only Dismiss-only card at the 3rd event in the window', () => {
|
|
97
|
+
const { deps, cap } = makeDeps()
|
|
98
|
+
emitTransportTransientEvent(ev(), deps) // 1 — no card
|
|
99
|
+
emitTransportTransientEvent(ev(), deps) // 2 — no card
|
|
100
|
+
expect(cap.sent).toHaveLength(0)
|
|
101
|
+
emitTransportTransientEvent(ev(), deps) // 3 — ONE card
|
|
102
|
+
expect(cap.sent).toHaveLength(1)
|
|
103
|
+
// operator-only: goes to the allowlist HEAD, and carries a keyboard (Dismiss)
|
|
104
|
+
expect(cap.sent[0].chatId).toBe('op')
|
|
105
|
+
expect(cap.sent[0].hasKeyboard).toBe(true)
|
|
106
|
+
expect(cap.sent[0].text).toContain('Repeated stream failures')
|
|
107
|
+
// and only ONE — a 4th event in the same (reset) window does not re-fire yet
|
|
108
|
+
emitTransportTransientEvent(ev(), deps) // 4
|
|
109
|
+
expect(cap.sent).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does not escalate when the events fall outside the window', () => {
|
|
113
|
+
// Two events far apart never reach threshold-3-within-window.
|
|
114
|
+
expect(noteTransportTransientAndShouldEscalate('gymbro', 0)).toBe(false)
|
|
115
|
+
expect(noteTransportTransientAndShouldEscalate('gymbro', 60 * 60_000)).toBe(false)
|
|
116
|
+
expect(noteTransportTransientAndShouldEscalate('gymbro', 120 * 60_000)).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('counts per-agent — one agent bursting does not escalate another', () => {
|
|
120
|
+
expect(noteTransportTransientAndShouldEscalate('a', 0)).toBe(false)
|
|
121
|
+
expect(noteTransportTransientAndShouldEscalate('a', 1)).toBe(false)
|
|
122
|
+
expect(noteTransportTransientAndShouldEscalate('b', 2)).toBe(false)
|
|
123
|
+
// a's 3rd crosses; b has only seen 1
|
|
124
|
+
expect(noteTransportTransientAndShouldEscalate('a', 3)).toBe(true)
|
|
125
|
+
expect(noteTransportTransientAndShouldEscalate('b', 4)).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('renderTransportEscalationCard', () => {
|
|
130
|
+
it('is Dismiss-only with NO Reauth button', () => {
|
|
131
|
+
const { keyboard, text } = renderTransportEscalationCard('gymbro')
|
|
132
|
+
const buttons = keyboard.inline_keyboard.flat()
|
|
133
|
+
expect(buttons.some((b) => b.callback_data?.includes('dismiss'))).toBe(true)
|
|
134
|
+
expect(buttons.some((b) => b.callback_data?.includes('reauth'))).toBe(false)
|
|
135
|
+
expect(text).toContain('gymbro')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// ─── flushDeferredUserNotices — turn-outcome gate ────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('flushDeferredUserNotices', () => {
|
|
142
|
+
it('sends nothing when the turn delivered a reply (notice dropped)', () => {
|
|
143
|
+
const { deps, cap } = makeDeps()
|
|
144
|
+
cap.resolvedQueue = [{ chatIds: ['user-a'], text: 'notice', agent: 'gymbro', kind: 'transport-transient', atMs: 1, key: 'k' }]
|
|
145
|
+
flushDeferredUserNotices(/* turnDeliveredReply */ true, 'k', deps)
|
|
146
|
+
expect(cap.lastResolve).toEqual({ delivered: true, key: 'k' })
|
|
147
|
+
expect(cap.sent).toHaveLength(0)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('flushes the plain notice (no keyboard) when the turn ended reply-less', () => {
|
|
151
|
+
const { deps, cap } = makeDeps()
|
|
152
|
+
cap.resolvedQueue = [{ chatIds: ['user-a', 'user-b'], text: 'notice', agent: 'gymbro', kind: 'transport-transient', atMs: 1, key: 'k' }]
|
|
153
|
+
flushDeferredUserNotices(/* turnDeliveredReply */ false, 'k', deps)
|
|
154
|
+
expect(cap.sent.map((s) => s.chatId)).toEqual(['user-a', 'user-b'])
|
|
155
|
+
// plain user notice — never a card/keyboard
|
|
156
|
+
expect(cap.sent.every((s) => s.hasKeyboard === false)).toBe(true)
|
|
157
|
+
expect(cap.sent.every((s) => s.text === 'notice')).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('does nothing when no notices resolve', () => {
|
|
161
|
+
const { deps, cap } = makeDeps()
|
|
162
|
+
flushDeferredUserNotices(false, 'k', deps)
|
|
163
|
+
expect(cap.sent).toHaveLength(0)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -431,6 +431,11 @@ export function normalizeForSpeech(input: string): string {
|
|
|
431
431
|
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
432
432
|
|
|
433
433
|
// 3. Links [text](url) → text ; drop the URL entirely.
|
|
434
|
+
// FUTURE (not built): the Kokoro sidecar's misaki G2P (docker/voice-sidecar/
|
|
435
|
+
// server.py) accepts a per-word phoneme override via `[word](/phoneme/)`
|
|
436
|
+
// markup. Wiring a caller-supplied pronunciation override end-to-end would
|
|
437
|
+
// mean detecting that `/…/` form HERE and passing it through instead of
|
|
438
|
+
// collapsing it to the link text below. Deliberately left as a hook.
|
|
434
439
|
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
435
440
|
|
|
436
441
|
// 4. Autolinks <https://…> and bare URLs → "a link" (never spell a URL).
|
|
@@ -136,6 +136,10 @@ _VERIFY_BLOCK_REASON = (
|
|
|
136
136
|
"UNLESS an equivalent active directive already exists (see the "
|
|
137
137
|
"<active_directives> block for this turn) — in that case it is already "
|
|
138
138
|
"saved; do NOT create a duplicate, just finish.\n"
|
|
139
|
+
"If the rule can be enforced deterministically — a settings.json hook, "
|
|
140
|
+
"a permission rule, a skill/script edit, or a config change — prefer "
|
|
141
|
+
"that (instead of, or in addition to, the directive) and say which you "
|
|
142
|
+
"did; reserve a directive for judgment rules code cannot enforce.\n"
|
|
139
143
|
"If, on reflection, it was only a one-off instruction for this task, do "
|
|
140
144
|
"NOT create a directive — just finish your reply normally.\n"
|
|
141
145
|
"This verification fires once per turn.\n"
|
|
@@ -1442,8 +1442,13 @@ _DIRECTIVE_CAPTURE_NUDGE = (
|
|
|
1442
1442
|
"(verbatim, in the user’s own words) BEFORE you answer, so the "
|
|
1443
1443
|
"correction survives future sessions. UNLESS an equivalent active "
|
|
1444
1444
|
"directive already exists (see any <active_directives> block above) — "
|
|
1445
|
-
"in that case it is already saved; do NOT create a duplicate. If
|
|
1446
|
-
"
|
|
1445
|
+
"in that case it is already saved; do NOT create a duplicate. If the "
|
|
1446
|
+
"rule can be enforced deterministically — a settings.json hook, a "
|
|
1447
|
+
"permission rule, a skill/script edit, or a config change — prefer "
|
|
1448
|
+
"that (instead of, or in addition to, the directive) and say which "
|
|
1449
|
+
"you did; reserve a directive for judgment rules code can’t enforce. "
|
|
1450
|
+
"If it’s only a one-off instruction, ignore this note and just "
|
|
1451
|
+
"answer.\n"
|
|
1447
1452
|
"</directive_capture_check>"
|
|
1448
1453
|
)
|
|
1449
1454
|
|