switchroom 0.20.0 → 0.20.2
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 +2 -2
- package/dist/auth-broker/index.js +4 -3
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +2 -2
- package/dist/cli/switchroom.js +24704 -16399
- package/dist/host-control/main.js +44 -10
- package/dist/vault/approvals/kernel-server.js +4 -3
- package/dist/vault/broker/server.js +4 -3
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1400 -964
- 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 +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- 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
|
@@ -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
|
+
})
|