switchroom 0.14.45 → 0.14.46

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.
@@ -2913,6 +2913,10 @@ interface PendingVaultRequestSave {
2913
2913
  chat_id: string
2914
2914
  /** Card message id (filled in after we send the card). */
2915
2915
  card_message_id?: number
2916
+ /** Supergroup forum topic the agent was working in when it requested the
2917
+ * save — carried into the save-outcome inbound so the resumed reply lands
2918
+ * back in that topic, not General. */
2919
+ threadId?: number
2916
2920
  /** Currently-suggested slug; may be renamed by the user. */
2917
2921
  key: string
2918
2922
  /** Storage shape — 'string' (default) or 'binary'. */
@@ -2954,6 +2958,10 @@ interface PendingVaultRequestAccess {
2954
2958
  chat_id: string
2955
2959
  /** Card message id (filled in after we send the card). */
2956
2960
  card_message_id?: number
2961
+ /** Supergroup forum topic the agent was working in when it requested (the
2962
+ * card's originating thread). Carried into the grant-outcome inbound so the
2963
+ * resumed reply lands back in that topic, not General. */
2964
+ threadId?: number
2957
2965
  /** Vault key the agent wants to read. */
2958
2966
  key: string
2959
2967
  /** 'read' (default) or 'write'. */
@@ -7080,6 +7088,8 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
7080
7088
  // crashing the tool call.
7081
7089
  const text = renderVaultRequestSaveCard(pending, agentSlug)
7082
7090
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7091
+ // Remember the agent's working topic so the save-outcome inbound resumes in it.
7092
+ if (threadId != null) pending.threadId = threadId
7083
7093
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7084
7094
  robustApiCall,
7085
7095
  (tid) =>
@@ -7120,12 +7130,16 @@ interface PendingSecretRequest {
7120
7130
  reason?: string
7121
7131
  staged_at: number
7122
7132
  card_message_id?: number
7133
+ /** Supergroup forum topic the agent was working in — carried into the
7134
+ * provide/decline/fail outcome inbounds so the resumed reply lands back
7135
+ * in that topic, not General. */
7136
+ threadId?: number
7123
7137
  }
7124
7138
  // stageId -> request (lives until tapped or TTL).
7125
7139
  const pendingSecretRequests = new Map<string, PendingSecretRequest>()
7126
7140
  // chat_id -> the armed capture: the operator's NEXT message in this chat is
7127
7141
  // the value for `key`. Set when [Provide securely] is tapped.
7128
- interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number }
7142
+ interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number; threadId?: number }
7129
7143
  const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
7130
7144
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
7131
7145
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
@@ -7194,6 +7208,8 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
7194
7208
 
7195
7209
  const text = renderSecretRequestCard(pending)
7196
7210
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7211
+ // Remember the agent's working topic so the provide/decline/fail inbound resumes in it.
7212
+ if (threadId != null) pending.threadId = threadId
7197
7213
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7198
7214
  robustApiCall,
7199
7215
  (tid) =>
@@ -7269,12 +7285,19 @@ async function captureProvidedSecret(
7269
7285
  const failMsg: InboundMessage = {
7270
7286
  type: 'inbound',
7271
7287
  chatId: chat_id,
7288
+ ...(armed.threadId != null ? { threadId: armed.threadId } : {}),
7272
7289
  messageId: fts,
7273
7290
  user: 'vault-broker',
7274
7291
  userId: 0,
7275
7292
  ts: fts,
7276
7293
  text: `⚠️ The secret you requested for \`vault:${armed.key}\` could NOT be saved (vault write failed). Do not assume it is available; tell the operator or try request_secret again.`,
7277
- meta: { source: 'secret_provide_failed', agent: armed.agent, key: armed.key, stage_id: armed.stageId },
7294
+ meta: {
7295
+ source: 'secret_provide_failed',
7296
+ agent: armed.agent,
7297
+ ...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
7298
+ key: armed.key,
7299
+ stage_id: armed.stageId,
7300
+ },
7278
7301
  }
7279
7302
  const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
7280
7303
  if (fdelivered) markClaudeBusyForInbound(failMsg)
@@ -7294,6 +7317,7 @@ async function captureProvidedSecret(
7294
7317
  const synthetic: InboundMessage = {
7295
7318
  type: 'inbound',
7296
7319
  chatId: chat_id,
7320
+ ...(armed.threadId != null ? { threadId: armed.threadId } : {}),
7297
7321
  messageId: ts,
7298
7322
  user: 'vault-broker',
7299
7323
  userId: 0,
@@ -7305,6 +7329,7 @@ async function captureProvidedSecret(
7305
7329
  meta: {
7306
7330
  source: 'secret_provided',
7307
7331
  agent: armed.agent,
7332
+ ...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
7308
7333
  key: armed.key,
7309
7334
  stage_id: armed.stageId,
7310
7335
  },
@@ -7345,6 +7370,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
7345
7370
  agent: pending.agent,
7346
7371
  stageId,
7347
7372
  armed_at: Date.now(),
7373
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
7348
7374
  })
7349
7375
  await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
7350
7376
  if (pending.card_message_id != null) {
@@ -7377,12 +7403,19 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
7377
7403
  const synthetic: InboundMessage = {
7378
7404
  type: 'inbound',
7379
7405
  chatId: pending.chat_id,
7406
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
7380
7407
  messageId: ts,
7381
7408
  user: 'vault-broker',
7382
7409
  userId: 0,
7383
7410
  ts,
7384
7411
  text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
7385
- meta: { source: 'secret_declined', agent: pending.agent, key: pending.key, stage_id: stageId },
7412
+ meta: {
7413
+ source: 'secret_declined',
7414
+ agent: pending.agent,
7415
+ ...(pending.threadId != null ? { message_thread_id: String(pending.threadId) } : {}),
7416
+ key: pending.key,
7417
+ stage_id: stageId,
7418
+ },
7386
7419
  }
7387
7420
  const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
7388
7421
  if (delivered) markClaudeBusyForInbound(synthetic)
@@ -7530,6 +7563,8 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
7530
7563
 
7531
7564
  const text = renderVaultRequestAccessCard(pending)
7532
7565
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7566
+ // Remember the agent's working topic so the grant-outcome inbound resumes in it.
7567
+ if (threadId != null) pending.threadId = threadId
7533
7568
  // #1075: deleted-topic safe — fall back to main chat.
7534
7569
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7535
7570
  robustApiCall,
@@ -14023,6 +14058,7 @@ async function performVaultAccessApproval(
14023
14058
  scope: pending.scope,
14024
14059
  chat_id: pending.chat_id,
14025
14060
  ttl_seconds: pending.ttl_seconds,
14061
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14026
14062
  },
14027
14063
  grantId: id,
14028
14064
  stageId,
@@ -14104,6 +14140,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
14104
14140
  scope: pending.scope,
14105
14141
  chat_id: pending.chat_id,
14106
14142
  ttl_seconds: pending.ttl_seconds,
14143
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14107
14144
  },
14108
14145
  stageId,
14109
14146
  operatorId: senderId,
@@ -14278,7 +14315,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14278
14315
  // tool returned "waiting for operator", the turn ended, and a
14279
14316
  // Discard left the agent silently idle forever.
14280
14317
  const discardInbound = buildVaultSaveDiscardedInbound({
14281
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14318
+ ctx: {
14319
+ agent: pending.agent,
14320
+ key: pending.key,
14321
+ chat_id: pending.chat_id,
14322
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14323
+ },
14282
14324
  stageId,
14283
14325
  operatorId: senderId,
14284
14326
  })
@@ -14401,7 +14443,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14401
14443
  const failReason =
14402
14444
  (write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
14403
14445
  const failInbound = buildVaultSaveFailedInbound({
14404
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14446
+ ctx: {
14447
+ agent: pending.agent,
14448
+ key: pending.key,
14449
+ chat_id: pending.chat_id,
14450
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14451
+ },
14405
14452
  stageId,
14406
14453
  operatorId: senderId,
14407
14454
  reason: failReason,
@@ -14432,7 +14479,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14432
14479
  // task that was blocked on this credential (symmetric with the
14433
14480
  // vra: approve path; buffered if the bridge is mid-reconnect).
14434
14481
  const okInbound = buildVaultSaveCompletedInbound({
14435
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14482
+ ctx: {
14483
+ agent: pending.agent,
14484
+ key: pending.key,
14485
+ chat_id: pending.chat_id,
14486
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14487
+ },
14436
14488
  stageId,
14437
14489
  operatorId: senderId,
14438
14490
  })
@@ -34,6 +34,10 @@ export interface VaultGrantInboundContext {
34
34
  chat_id: string
35
35
  /** Seconds. For approved grants; ignored for deny. */
36
36
  ttl_seconds: number
37
+ /** Supergroup forum topic (message_thread_id) the agent was working in
38
+ * when it requested the credential — so the resumed turn's reply lands
39
+ * back in that topic, not General. Undefined for DM / non-topic requests. */
40
+ threadId?: number
37
41
  }
38
42
 
39
43
  /**
@@ -62,6 +66,7 @@ export function buildVaultGrantApprovedInbound(opts: {
62
66
  return {
63
67
  type: 'inbound',
64
68
  chatId: opts.ctx.chat_id,
69
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
65
70
  messageId: ts, // synthetic — no Telegram message id exists
66
71
  user: 'vault-broker',
67
72
  userId: 0,
@@ -76,6 +81,7 @@ export function buildVaultGrantApprovedInbound(opts: {
76
81
  meta: {
77
82
  source: 'vault_grant_approved',
78
83
  agent: opts.ctx.agent,
84
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
79
85
  key: opts.ctx.key,
80
86
  scope: opts.ctx.scope,
81
87
  grant_id: opts.grantId,
@@ -103,6 +109,7 @@ export function buildVaultGrantDeniedInbound(opts: {
103
109
  return {
104
110
  type: 'inbound',
105
111
  chatId: opts.ctx.chat_id,
112
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
106
113
  messageId: ts,
107
114
  user: 'vault-broker',
108
115
  userId: 0,
@@ -116,6 +123,7 @@ export function buildVaultGrantDeniedInbound(opts: {
116
123
  meta: {
117
124
  source: 'vault_grant_denied',
118
125
  agent: opts.ctx.agent,
126
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
119
127
  key: opts.ctx.key,
120
128
  scope: opts.ctx.scope,
121
129
  stage_id: opts.stageId,
@@ -133,6 +141,9 @@ export interface VaultSaveInboundContext {
133
141
  /** Telegram chat the save card lived in — keeps the synthesized
134
142
  * resume-turn associated with the originating conversation. */
135
143
  chat_id: string
144
+ /** Supergroup forum topic the agent was working in when it requested the
145
+ * save — so the resumed reply lands in that topic, not General. */
146
+ threadId?: number
136
147
  }
137
148
 
138
149
  /**
@@ -154,6 +165,7 @@ export function buildVaultSaveCompletedInbound(opts: {
154
165
  return {
155
166
  type: 'inbound',
156
167
  chatId: opts.ctx.chat_id,
168
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
157
169
  messageId: ts,
158
170
  user: 'vault-broker',
159
171
  userId: 0,
@@ -165,6 +177,7 @@ export function buildVaultSaveCompletedInbound(opts: {
165
177
  meta: {
166
178
  source: 'vault_save_completed',
167
179
  agent: opts.ctx.agent,
180
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
168
181
  key: opts.ctx.key,
169
182
  stage_id: opts.stageId,
170
183
  operator_id: opts.operatorId,
@@ -187,6 +200,7 @@ export function buildVaultSaveFailedInbound(opts: {
187
200
  return {
188
201
  type: 'inbound',
189
202
  chatId: opts.ctx.chat_id,
203
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
190
204
  messageId: ts,
191
205
  user: 'vault-broker',
192
206
  userId: 0,
@@ -200,6 +214,7 @@ export function buildVaultSaveFailedInbound(opts: {
200
214
  meta: {
201
215
  source: 'vault_save_failed',
202
216
  agent: opts.ctx.agent,
217
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
203
218
  key: opts.ctx.key,
204
219
  stage_id: opts.stageId,
205
220
  operator_id: opts.operatorId,
@@ -221,6 +236,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
221
236
  return {
222
237
  type: 'inbound',
223
238
  chatId: opts.ctx.chat_id,
239
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
224
240
  messageId: ts,
225
241
  user: 'vault-broker',
226
242
  userId: 0,
@@ -234,6 +250,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
234
250
  meta: {
235
251
  source: 'vault_save_discarded',
236
252
  agent: opts.ctx.agent,
253
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
237
254
  key: opts.ctx.key,
238
255
  stage_id: opts.stageId,
239
256
  operator_id: opts.operatorId,
@@ -16,7 +16,11 @@ import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
18
  buildVaultGrantDeniedInbound,
19
+ buildVaultSaveCompletedInbound,
20
+ buildVaultSaveFailedInbound,
21
+ buildVaultSaveDiscardedInbound,
19
22
  type VaultGrantInboundContext,
23
+ type VaultSaveInboundContext,
20
24
  } from '../gateway/vault-grant-inbound-builders.js'
21
25
 
22
26
  const FIXED_NOW = 1_700_000_000_000
@@ -224,3 +228,83 @@ describe('approve vs deny shape invariants', () => {
224
228
  expect(String(deny.meta?.source)).toMatch(/^vault_grant_denied$/)
225
229
  })
226
230
  })
231
+
232
+ // ── Supergroup topic routing (gap #2) ──────────────────────────────────────
233
+ //
234
+ // When the agent requested the credential from inside a forum topic, the
235
+ // grant/save-outcome inbound must carry that topic so the resumed turn's
236
+ // reply lands back in it — not General. The carrier is two-fold and BOTH
237
+ // halves are load-bearing:
238
+ // - top-level `threadId` → the gateway's per-topic busy-key / deliver-
239
+ // until-acked keying (markClaudeBusyForInbound reads it).
240
+ // - `meta.message_thread_id` (stringified) → rendered into the
241
+ // `<channel message_thread_id="…">` XML, which session-tail's
242
+ // parseChannelMeta re-extracts to set currentTurn.sessionThreadId, which
243
+ // the reply tool defaults to. Drop either and the reply mis-routes.
244
+ //
245
+ // DM / non-topic requests must leave BOTH absent (an empty-string or 0
246
+ // thread is a Telegram 400 "message thread not found").
247
+ describe('grant/save outcome topic routing', () => {
248
+ const SAVE_CTX: VaultSaveInboundContext = {
249
+ agent: 'marko',
250
+ key: 'brevo/api-key',
251
+ chat_id: '-1001234567890',
252
+ }
253
+ const THREAD = 4242
254
+
255
+ const grantBuilders = [
256
+ {
257
+ name: 'approved',
258
+ build: (ctx: VaultGrantInboundContext) =>
259
+ buildVaultGrantApprovedInbound({ ctx, grantId: 'vg_x', stageId: 's', operatorId: '1' }),
260
+ },
261
+ {
262
+ name: 'denied',
263
+ build: (ctx: VaultGrantInboundContext) =>
264
+ buildVaultGrantDeniedInbound({ ctx, stageId: 's', operatorId: '1' }),
265
+ },
266
+ ]
267
+ const saveBuilders = [
268
+ {
269
+ name: 'save-completed',
270
+ build: (ctx: VaultSaveInboundContext) =>
271
+ buildVaultSaveCompletedInbound({ ctx, stageId: 's', operatorId: '1' }),
272
+ },
273
+ {
274
+ name: 'save-failed',
275
+ build: (ctx: VaultSaveInboundContext) =>
276
+ buildVaultSaveFailedInbound({ ctx, stageId: 's', operatorId: '1', reason: 'disk full' }),
277
+ },
278
+ {
279
+ name: 'save-discarded',
280
+ build: (ctx: VaultSaveInboundContext) =>
281
+ buildVaultSaveDiscardedInbound({ ctx, stageId: 's', operatorId: '1' }),
282
+ },
283
+ ]
284
+
285
+ for (const { name, build } of grantBuilders) {
286
+ it(`${name}: threadId set → top-level threadId + meta.message_thread_id (stringified)`, () => {
287
+ const msg = build({ ...CTX_READ, threadId: THREAD })
288
+ expect(msg.threadId).toBe(THREAD)
289
+ expect(msg.meta?.message_thread_id).toBe(String(THREAD))
290
+ })
291
+ it(`${name}: threadId absent → both omitted (DM stays thread-less)`, () => {
292
+ const msg = build(CTX_READ)
293
+ expect(msg.threadId).toBeUndefined()
294
+ expect(msg.meta?.message_thread_id).toBeUndefined()
295
+ })
296
+ }
297
+
298
+ for (const { name, build } of saveBuilders) {
299
+ it(`${name}: threadId set → top-level threadId + meta.message_thread_id (stringified)`, () => {
300
+ const msg = build({ ...SAVE_CTX, threadId: THREAD })
301
+ expect(msg.threadId).toBe(THREAD)
302
+ expect(msg.meta?.message_thread_id).toBe(String(THREAD))
303
+ })
304
+ it(`${name}: threadId absent → both omitted (DM stays thread-less)`, () => {
305
+ const msg = build(SAVE_CTX)
306
+ expect(msg.threadId).toBeUndefined()
307
+ expect(msg.meta?.message_thread_id).toBeUndefined()
308
+ })
309
+ }
310
+ })