switchroom 0.19.40 → 0.19.42

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.
Files changed (37) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +810 -241
  5. package/dist/host-control/main.js +94 -19
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +2 -2
  10. package/telegram-plugin/dist/bridge/bridge.js +2 -2
  11. package/telegram-plugin/dist/gateway/gateway.js +332 -124
  12. package/telegram-plugin/dist/server.js +2 -2
  13. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  14. package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
  15. package/telegram-plugin/gateway/gateway.ts +76 -76
  16. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  17. package/telegram-plugin/gateway/model-command.ts +21 -109
  18. package/telegram-plugin/gateway/stream-render.ts +57 -0
  19. package/telegram-plugin/gateway/turn-record-status.ts +80 -0
  20. package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
  21. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  22. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
  23. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  24. package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
  25. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  26. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  28. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  29. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  30. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  31. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -25007,7 +25007,7 @@ var init_bridge = __esm(async () => {
25007
25007
  },
25008
25008
  {
25009
25009
  name: "send_checklist",
25010
- description: 'Send a native Telegram checklist (interactive task list) to a chat. Users can tick tasks directly in the Telegram app. Returns the message_id of the created checklist. The bot is notified when tasks are ticked \u2014 these arrive as channel events with kind="checklist_task_changed". Limit: 30 tasks per checklist.',
25010
+ description: 'Send a checklist (task list) to a chat. Native interactive Telegram checklists require a Business-account connection, which most deployments do not have \u2014 in that normal case the checklist is sent as a formatted TEXT message (bold title + \u2705/\u2b1c task lines) and the result carries degraded:"text": tasks are NOT tappable, and you tick them yourself via update_checklist (task ids are 1..N in send order). With a Business connection configured, the checklist is sent natively and tick events arrive as channel events with kind="checklist_task_changed". Returns JSON with message_id and mode. Limit: 30 tasks.',
25011
25011
  inputSchema: {
25012
25012
  type: "object",
25013
25013
  properties: {
@@ -25092,7 +25092,7 @@ var init_bridge = __esm(async () => {
25092
25092
  },
25093
25093
  {
25094
25094
  name: "update_checklist",
25095
- description: "Patch an existing native Telegram checklist. Supports updating the title, adding new tasks, removing tasks, or marking tasks done/undone. Tasks with an id target existing items; tasks without an id are appended. Preserves existing task ids across edits.",
25095
+ description: `Patch a checklist previously sent with send_checklist: update the title, append tasks, or mark tasks done/undone. Tasks with an id target existing items (ids are 1..N in send order for text-mode checklists); tasks without an id are appended. Removal is not supported. The edit re-renders in the mode the checklist was sent in (text for most deployments). Returns JSON { ok, ... }; after a gateway restart the stored task state may be gone \u2014 then it returns ok:false with reason "unknown_checklist" unless you pass a full replacement (title + every task's text).`,
25096
25096
  inputSchema: {
25097
25097
  type: "object",
25098
25098
  properties: {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Regression test for #4069 — the "kernel record failed" false toast.
3
+ *
4
+ * The kernel's approval_consume_record reply carries `decision_id`, but
5
+ * the client decodes every reply through ResponseSchema (a plain
6
+ * z.union, first-match wins, unknown keys stripped). With
7
+ * OkApprovalConsumeResponseSchema ordered before
8
+ * OkApprovalConsumeRecordResponseSchema, the consume_record reply
9
+ * matched the prefix schema and `decision_id` was silently stripped —
10
+ * so handleApprovalCallback hit its defensive `!result.decision_id`
11
+ * branch, toasted "kernel record failed", and returned before editing
12
+ * the card to its granted state. Deterministic on EVERY `apv:` approval
13
+ * tap. The audit row was never lost — the kernel committed it — this
14
+ * was purely the client's mangled view of the reply.
15
+ *
16
+ * This test drives handleApprovalCallback end-to-end against a fake
17
+ * kernel serving a REAL wire frame through the REAL
18
+ * rpcRaw/decodeResponse path (the kernel suites parse the wire with raw
19
+ * JSON.parse and bypass decodeResponse — which is exactly why they
20
+ * missed this). It asserts the tap lands: no "kernel record failed"
21
+ * toast, and the card advances to its granted body carrying the
22
+ * decision_id revoke hint.
23
+ */
24
+
25
+ import { afterEach, describe, expect, it } from "vitest";
26
+ import * as net from "node:net";
27
+ import * as os from "node:os";
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+ import type { Context } from "grammy";
31
+ import { handleApprovalCallback } from "./approval-callback.js";
32
+
33
+ const REQUEST_ID = "0123456789abcdef0123456789abcdef";
34
+ const DECISION_ID = "dec-4069";
35
+
36
+ /** One-shot fake kernel: replies to the first request line with `reply`. */
37
+ function startFakeKernel(
38
+ socketPath: string,
39
+ reply: Record<string, unknown>,
40
+ seen: string[],
41
+ ): Promise<net.Server> {
42
+ return new Promise((resolve, reject) => {
43
+ const server = net.createServer((conn) => {
44
+ let buf = "";
45
+ conn.on("data", (chunk) => {
46
+ buf += chunk.toString("utf8");
47
+ const idx = buf.indexOf("\n");
48
+ if (idx === -1) return;
49
+ seen.push(buf.slice(0, idx));
50
+ conn.write(JSON.stringify(reply) + "\n");
51
+ });
52
+ });
53
+ server.on("error", reject);
54
+ server.listen(socketPath, () => resolve(server));
55
+ });
56
+ }
57
+
58
+ function makeFakeCtx(): {
59
+ ctx: Context;
60
+ toasts: string[];
61
+ edits: Array<{ markdown: string }>;
62
+ } {
63
+ const toasts: string[] = [];
64
+ const edits: Array<{ markdown: string }> = [];
65
+ const ctx = {
66
+ from: { id: 42 },
67
+ answerCallbackQuery: async (args?: { text?: string }) => {
68
+ toasts.push(args?.text ?? "");
69
+ return true;
70
+ },
71
+ editMessageText: async (body: { markdown: string }) => {
72
+ edits.push(body);
73
+ return true;
74
+ },
75
+ } as unknown as Context;
76
+ return { ctx, toasts, edits };
77
+ }
78
+
79
+ describe("handleApprovalCallback × real decodeResponse (#4069)", () => {
80
+ let server: net.Server | undefined;
81
+ let tmpDir: string | undefined;
82
+ const savedEnv = process.env.SWITCHROOM_KERNEL_SOCKET;
83
+
84
+ afterEach(async () => {
85
+ if (server) await new Promise((r) => server!.close(r));
86
+ server = undefined;
87
+ if (savedEnv === undefined) delete process.env.SWITCHROOM_KERNEL_SOCKET;
88
+ else process.env.SWITCHROOM_KERNEL_SOCKET = savedEnv;
89
+ if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
90
+ tmpDir = undefined;
91
+ });
92
+
93
+ it("approve tap records + advances the card — no false 'kernel record failed'", async () => {
94
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sr4069-"));
95
+ const socketPath = path.join(tmpDir, "kernel.sock");
96
+ const seen: string[] = [];
97
+ server = await startFakeKernel(
98
+ socketPath,
99
+ {
100
+ ok: true,
101
+ consumed: true,
102
+ decision_id: DECISION_ID,
103
+ agent_unit: "clerk",
104
+ scope: "doc:gdrive:D1",
105
+ action: "write",
106
+ why: null,
107
+ },
108
+ seen,
109
+ );
110
+ process.env.SWITCHROOM_KERNEL_SOCKET = socketPath;
111
+
112
+ const { ctx, toasts, edits } = makeFakeCtx();
113
+ await handleApprovalCallback(ctx, `apv:${REQUEST_ID}:once`);
114
+
115
+ // The fake kernel really received the atomic consume+record op.
116
+ expect(seen.length).toBe(1);
117
+ expect(JSON.parse(seen[0]!)).toMatchObject({
118
+ op: "approval_consume_record",
119
+ request_id: REQUEST_ID,
120
+ decision: "allow_once",
121
+ });
122
+
123
+ // Outcome: the tap must land as Approved, never the false-failure
124
+ // toast, and the card must advance to its granted body carrying the
125
+ // decision_id revoke hint.
126
+ expect(toasts).not.toContain("kernel record failed");
127
+ expect(toasts).toContain("Approved");
128
+ expect(edits.length).toBe(1);
129
+ expect(edits[0]!.markdown).toContain(DECISION_ID);
130
+ expect(edits[0]!.markdown).toContain("granted once");
131
+ });
132
+ });
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Checklist send/update with graceful degradation.
3
+ *
4
+ * Telegram's native `sendChecklist` / `editMessageChecklist` (Bot API 9.1)
5
+ * are **Business-account-only**: both REQUIRE a `business_connection_id`,
6
+ * and the payload nests `title`/`tasks` inside a single `checklist` object
7
+ * (`InputChecklist`) whose tasks each carry a REQUIRED integer `id` and NO
8
+ * completion flag (`InputChecklistTask = { id, text }` — a native checklist
9
+ * cannot be pre-marked done). The original wrapper sent a FLAT
10
+ * `{ chat_id, title, tasks: [{ text, is_completed? }] }` payload, which
11
+ * Telegram rejected with `400: parameter "checklist" is required` — and even
12
+ * a well-formed payload fails for ordinary bot DMs/groups (no business
13
+ * connection), which is every chat this fleet uses.
14
+ *
15
+ * So the tool degrades gracefully instead of surfacing a raw 400:
16
+ * 1. If a business connection id is configured AND the Bot API supports
17
+ * checklists → build the CORRECT native payload and send natively.
18
+ * 2. Otherwise (the normal case) → render the checklist as a formatted
19
+ * text message (bold title + ✅/⬜ task lines — text CAN show the
20
+ * `done` flag) and send via the normal message path. The tool result
21
+ * carries `degraded: "text"` so the caller knows it is not interactive.
22
+ * 3. A failed native attempt falls back to text rather than erroring.
23
+ *
24
+ * Updates work against a per-message in-memory state store (the gateway has
25
+ * no other record of a text-rendered checklist's tasks): a patch is applied
26
+ * to the stored state and the message is edited (natively or as text to
27
+ * match how it was sent). State is process-local — after a gateway restart
28
+ * an update to a pre-restart checklist returns a graceful
29
+ * `unknown_checklist` result unless the patch itself carries a full
30
+ * replacement (title + all task texts), never a raw Telegram error.
31
+ *
32
+ * Pure/deps-injected so the orchestration is unit-testable without a bot
33
+ * (same pattern as checklist-message-handler.ts).
34
+ */
35
+
36
+ export interface ChecklistTaskState {
37
+ /** 1-based sequential id assigned at send time (mirrors the native API's
38
+ * required per-task integer id; doubles as the patch handle in text mode). */
39
+ id: number
40
+ text: string
41
+ done: boolean
42
+ }
43
+
44
+ export interface ChecklistState {
45
+ title: string
46
+ tasks: ChecklistTaskState[]
47
+ /** How the message was actually delivered — updates must match it. */
48
+ mode: 'native' | 'text'
49
+ }
50
+
51
+ export interface ChecklistPatchTask {
52
+ id?: string | number
53
+ text?: string
54
+ done?: boolean
55
+ }
56
+
57
+ export interface ChecklistPatch {
58
+ title?: string
59
+ tasks?: ChecklistPatchTask[]
60
+ }
61
+
62
+ export const CHECKLIST_MAX_TASKS = 30
63
+
64
+ /** Assign sequential 1-based ids and normalize the `done` flag. */
65
+ export function buildChecklistTasks(
66
+ tasks: ReadonlyArray<{ text: string; done?: boolean }>,
67
+ ): ChecklistTaskState[] {
68
+ if (tasks.length > CHECKLIST_MAX_TASKS) {
69
+ throw new Error(`checklist exceeds ${CHECKLIST_MAX_TASKS}-task limit (got ${tasks.length})`)
70
+ }
71
+ return tasks.map((t, i) => ({ id: i + 1, text: t.text, done: t.done === true }))
72
+ }
73
+
74
+ /**
75
+ * Render a checklist as a text message: title header + one ✅/⬜ line per
76
+ * task. Rich (default) output is GFM markdown for `richMessage()`; pass
77
+ * `literal: true` when the access config pins `parseMode: 'text'` (plain
78
+ * sends, no markdown parsing) so the title doesn't ship raw asterisks.
79
+ */
80
+ export function renderChecklistText(
81
+ cl: { title: string; tasks: ReadonlyArray<{ text: string; done: boolean }> },
82
+ opts?: { literal?: boolean },
83
+ ): string {
84
+ const header = opts?.literal ? cl.title : `**${cl.title}**`
85
+ const lines = cl.tasks.map((t) => `${t.done ? '✅' : '⬜'} ${t.text}`)
86
+ return [header, ...lines].join('\n')
87
+ }
88
+
89
+ /**
90
+ * Apply an update_checklist patch: `title` replaces the title; a task with
91
+ * an `id` matching an existing task updates its text/done in place; a task
92
+ * without an `id` (or with an unknown id) is appended. Returns a NEW state
93
+ * (input untouched). Removal is not supported by the patch shape.
94
+ */
95
+ export function applyChecklistPatch(
96
+ cl: { title: string; tasks: ReadonlyArray<ChecklistTaskState> },
97
+ patch: ChecklistPatch,
98
+ ): { title: string; tasks: ChecklistTaskState[] } {
99
+ const tasks = cl.tasks.map((t) => ({ ...t }))
100
+ for (const p of patch.tasks ?? []) {
101
+ const pid = p.id != null ? Number(p.id) : undefined
102
+ const existing = pid != null ? tasks.find((t) => t.id === pid) : undefined
103
+ if (existing) {
104
+ if (p.text != null) existing.text = p.text
105
+ if (p.done != null) existing.done = p.done
106
+ } else {
107
+ const nextId = pid != null && Number.isFinite(pid)
108
+ ? pid
109
+ : tasks.reduce((m, t) => Math.max(m, t.id), 0) + 1
110
+ tasks.push({ id: nextId, text: p.text ?? '', done: p.done === true })
111
+ }
112
+ }
113
+ if (tasks.length > CHECKLIST_MAX_TASKS) {
114
+ throw new Error(`checklist exceeds ${CHECKLIST_MAX_TASKS}-task limit (got ${tasks.length})`)
115
+ }
116
+ return { title: patch.title ?? cl.title, tasks }
117
+ }
118
+
119
+ /**
120
+ * Build a CORRECT native `sendChecklist` payload per Bot API 9.1:
121
+ * `business_connection_id` is required, `title`/`tasks` nest inside a single
122
+ * `checklist` object (`InputChecklist`), each task carries its integer `id`,
123
+ * and there is NO completion field (`InputChecklistTask` has none).
124
+ */
125
+ export function buildNativeChecklistPayload(p: {
126
+ businessConnectionId: string
127
+ chatId: number
128
+ title: string
129
+ tasks: ReadonlyArray<ChecklistTaskState>
130
+ replyToMessageId?: number
131
+ protectContent?: boolean
132
+ }): Record<string, unknown> {
133
+ return {
134
+ business_connection_id: p.businessConnectionId,
135
+ chat_id: p.chatId,
136
+ checklist: {
137
+ title: p.title,
138
+ tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
139
+ },
140
+ ...(p.replyToMessageId != null ? { reply_parameters: { message_id: p.replyToMessageId } } : {}),
141
+ ...(p.protectContent === true ? { protect_content: true } : {}),
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Build a native `editMessageChecklist` payload. The native edit REPLACES
147
+ * the whole checklist, so the FULL post-patch state is sent; reusing the
148
+ * stored task ids preserves user tick state for unchanged tasks.
149
+ */
150
+ export function buildNativeEditChecklistPayload(p: {
151
+ businessConnectionId: string
152
+ chatId: number
153
+ messageId: number
154
+ title: string
155
+ tasks: ReadonlyArray<ChecklistTaskState>
156
+ }): Record<string, unknown> {
157
+ return {
158
+ business_connection_id: p.businessConnectionId,
159
+ chat_id: p.chatId,
160
+ message_id: p.messageId,
161
+ checklist: {
162
+ title: p.title,
163
+ tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
164
+ },
165
+ }
166
+ }
167
+
168
+ /** Bounded per-message checklist state store (insertion-order eviction). */
169
+ export interface ChecklistStore {
170
+ get(key: string): ChecklistState | undefined
171
+ set(key: string, state: ChecklistState): void
172
+ }
173
+
174
+ export function createChecklistStore(cap = 500): ChecklistStore {
175
+ const map = new Map<string, ChecklistState>()
176
+ return {
177
+ get: (key) => map.get(key),
178
+ set: (key, state) => {
179
+ if (map.has(key)) map.delete(key) // refresh insertion order
180
+ map.set(key, state)
181
+ while (map.size > cap) {
182
+ const oldest = map.keys().next().value as string | undefined
183
+ if (oldest == null) break
184
+ map.delete(oldest)
185
+ }
186
+ },
187
+ }
188
+ }
189
+
190
+ export function checklistStoreKey(chatId: string, messageId: string | number): string {
191
+ return `${chatId}:${messageId}`
192
+ }
193
+
194
+ // ─── Orchestration (deps-injected, unit-testable) ─────────────────────────
195
+
196
+ export interface SendChecklistDeps {
197
+ /** Business connection id, when the operator has one configured. Native
198
+ * checklists are impossible without it (required API parameter). */
199
+ businessConnectionId: string | undefined
200
+ /** Boot-probe result: the connected grammY/Bot API exposes sendChecklist. */
201
+ nativeAvailable: boolean
202
+ sendNative: (payload: Record<string, unknown>) => Promise<{ message_id: number }>
203
+ sendText: (text: string) => Promise<{ message_id: number }>
204
+ /** access.parseMode === 'text' — render without markdown. */
205
+ literalText: boolean
206
+ log: (line: string) => void
207
+ }
208
+
209
+ export interface SendChecklistResult {
210
+ message_id: number
211
+ mode: 'native' | 'text'
212
+ state: ChecklistState
213
+ }
214
+
215
+ /**
216
+ * Send a checklist: natively when possible, degrading to a formatted text
217
+ * message otherwise. NEVER lets a native-send failure escape — the fallback
218
+ * is the text render. (A text-send failure still throws: there is nothing
219
+ * further to degrade to, and the caller's normal error path applies.)
220
+ */
221
+ export async function performSendChecklist(
222
+ deps: SendChecklistDeps,
223
+ args: {
224
+ title: string
225
+ tasks: ReadonlyArray<{ text: string; done?: boolean }>
226
+ chatId: number
227
+ replyToMessageId?: number
228
+ protectContent?: boolean
229
+ },
230
+ ): Promise<SendChecklistResult> {
231
+ const tasks = buildChecklistTasks(args.tasks)
232
+ if (deps.nativeAvailable && deps.businessConnectionId) {
233
+ try {
234
+ const sent = await deps.sendNative(buildNativeChecklistPayload({
235
+ businessConnectionId: deps.businessConnectionId,
236
+ chatId: args.chatId,
237
+ title: args.title,
238
+ tasks,
239
+ replyToMessageId: args.replyToMessageId,
240
+ protectContent: args.protectContent,
241
+ }))
242
+ return {
243
+ message_id: sent.message_id,
244
+ mode: 'native',
245
+ state: { title: args.title, tasks, mode: 'native' },
246
+ }
247
+ } catch (err) {
248
+ deps.log(`native sendChecklist failed — falling back to text render: ${err}\n`)
249
+ }
250
+ }
251
+ const text = renderChecklistText({ title: args.title, tasks }, { literal: deps.literalText })
252
+ const sent = await deps.sendText(text)
253
+ return {
254
+ message_id: sent.message_id,
255
+ mode: 'text',
256
+ state: { title: args.title, tasks, mode: 'text' },
257
+ }
258
+ }
259
+
260
+ /** Tool-result text for send_checklist. The degraded shape carries
261
+ * `degraded: "text"` + a note so the calling agent knows the checklist is a
262
+ * formatted message, not a natively tickable one. */
263
+ export function sendChecklistToolText(r: SendChecklistResult): string {
264
+ return JSON.stringify(
265
+ r.mode === 'native'
266
+ ? { ok: true, message_id: r.message_id, mode: 'native' }
267
+ : {
268
+ ok: true,
269
+ message_id: r.message_id,
270
+ mode: 'text',
271
+ degraded: 'text',
272
+ note: 'rendered as a formatted text message (native Telegram checklists require a Business connection) — tasks are not tappable; use update_checklist with task ids 1..N to tick them',
273
+ },
274
+ )
275
+ }
276
+
277
+ export interface UpdateChecklistDeps {
278
+ /** Stored state for this chat:message, if the gateway still has it. */
279
+ state: ChecklistState | undefined
280
+ businessConnectionId: string | undefined
281
+ nativeAvailable: boolean
282
+ editNative: (payload: Record<string, unknown>) => Promise<void>
283
+ editText: (text: string) => Promise<void>
284
+ literalText: boolean
285
+ log: (line: string) => void
286
+ chatId: number
287
+ messageId: number
288
+ }
289
+
290
+ export type UpdateChecklistResult =
291
+ | { ok: true; mode: 'native' | 'text'; state: ChecklistState }
292
+ | { ok: false; reason: 'unknown_checklist' | 'edit_failed'; hint: string }
293
+
294
+ /**
295
+ * Update a checklist message. Applies the patch to the stored state and
296
+ * re-renders in the mode the message was originally sent in. Failures come
297
+ * back as structured `{ ok: false, reason, hint }` results — never a raw
298
+ * Telegram error thrown at the caller.
299
+ */
300
+ export async function performUpdateChecklist(
301
+ deps: UpdateChecklistDeps,
302
+ patch: ChecklistPatch,
303
+ ): Promise<UpdateChecklistResult> {
304
+ let base = deps.state
305
+ if (!base) {
306
+ // State lost (gateway restart / evicted). If the patch is a full
307
+ // replacement — a title plus tasks that all carry text — we can rebuild
308
+ // and edit as text (the fleet-normal mode). Otherwise we cannot know
309
+ // the existing tasks; degrade gracefully.
310
+ const fullReplacement =
311
+ patch.title != null &&
312
+ (patch.tasks?.length ?? 0) > 0 &&
313
+ (patch.tasks ?? []).every((t) => t.text != null)
314
+ if (!fullReplacement) {
315
+ return {
316
+ ok: false,
317
+ reason: 'unknown_checklist',
318
+ hint: 'no stored state for this checklist (gateway restarted?). Re-send it with send_checklist, or pass a full replacement (title + every task\'s text) to update_checklist.',
319
+ }
320
+ }
321
+ base = { title: patch.title!, tasks: [], mode: 'text' }
322
+ }
323
+ let next: { title: string; tasks: ChecklistTaskState[] }
324
+ try {
325
+ next = applyChecklistPatch(base, patch)
326
+ } catch (err) {
327
+ return { ok: false, reason: 'edit_failed', hint: String(err) }
328
+ }
329
+ const state: ChecklistState = { title: next.title, tasks: next.tasks, mode: base.mode }
330
+ if (base.mode === 'native') {
331
+ // A native checklist message cannot be edited into plain text — there is
332
+ // no text fallback here; a failure surfaces as a structured result.
333
+ if (!deps.nativeAvailable || !deps.businessConnectionId) {
334
+ return {
335
+ ok: false,
336
+ reason: 'edit_failed',
337
+ hint: 'this checklist was sent natively but the business connection / checklist API is no longer available.',
338
+ }
339
+ }
340
+ try {
341
+ await deps.editNative(buildNativeEditChecklistPayload({
342
+ businessConnectionId: deps.businessConnectionId,
343
+ chatId: deps.chatId,
344
+ messageId: deps.messageId,
345
+ title: state.title,
346
+ tasks: state.tasks,
347
+ }))
348
+ return { ok: true, mode: 'native', state }
349
+ } catch (err) {
350
+ deps.log(`native editMessageChecklist failed: ${err}\n`)
351
+ return { ok: false, reason: 'edit_failed', hint: String(err) }
352
+ }
353
+ }
354
+ try {
355
+ await deps.editText(renderChecklistText(state, { literal: deps.literalText }))
356
+ return { ok: true, mode: 'text', state }
357
+ } catch (err) {
358
+ deps.log(`checklist text edit failed: ${err}\n`)
359
+ return { ok: false, reason: 'edit_failed', hint: String(err) }
360
+ }
361
+ }
362
+
363
+ /** Tool-result text for update_checklist — structured for both outcomes. */
364
+ export function updateChecklistToolText(r: UpdateChecklistResult, messageId: number): string {
365
+ return JSON.stringify(
366
+ r.ok
367
+ ? { ok: true, message_id: messageId, mode: r.mode, ...(r.mode === 'text' ? { degraded: 'text' } : {}) }
368
+ : { ok: false, reason: r.reason, hint: r.hint },
369
+ )
370
+ }