switchroom 0.14.31 → 0.14.32

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.
@@ -427,6 +427,7 @@ import {
427
427
  recordTurnEnd,
428
428
  findLatestTurnIfInterrupted,
429
429
  findRecentTurnsForChat,
430
+ getTurnByKey,
430
431
  } from '../registry/turns-schema.js'
431
432
  import {
432
433
  buildResumeInterruptedInbound,
@@ -1117,6 +1118,41 @@ try {
1117
1118
  turnsDb = null
1118
1119
  }
1119
1120
 
1121
+ /**
1122
+ * Resolve the chat/thread a background sub-agent was dispatched from, so
1123
+ * its live worker card + handback route back to the originating
1124
+ * conversation (group / forum topic) instead of the operator DM.
1125
+ *
1126
+ * Walks jsonl_agent_id → `subagents.parent_turn_key` →
1127
+ * `turns.chat_id`/`thread_id`. Returns null on any miss so the caller
1128
+ * keeps its existing `allowFrom[0]` DM fallback — best-effort, never
1129
+ * throws out of the worker-card hot path. This restores the chat context
1130
+ * the pinned-card fleet used to carry before it was removed in #1122
1131
+ * (progressDriver is permanently null, so the old fleet lookup always
1132
+ * yielded the DM for a Task dispatched from a group/topic).
1133
+ */
1134
+ function resolveSubagentOriginChat(
1135
+ agentId: string,
1136
+ ): { chatId: string; threadId?: number } | null {
1137
+ if (turnsDb == null) return null
1138
+ try {
1139
+ const sub = getSubagentByJsonlId(turnsDb, agentId)
1140
+ if (sub?.parent_turn_key == null) return null
1141
+ const turn = getTurnByKey(turnsDb, sub.parent_turn_key)
1142
+ if (turn == null || turn.chat_id.length === 0) return null
1143
+ const threadNum =
1144
+ turn.thread_id != null && turn.thread_id.length > 0
1145
+ ? Number(turn.thread_id)
1146
+ : NaN
1147
+ return {
1148
+ chatId: turn.chat_id,
1149
+ threadId: Number.isFinite(threadNum) ? threadNum : undefined,
1150
+ }
1151
+ } catch {
1152
+ return null
1153
+ }
1154
+ }
1155
+
1120
1156
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
1121
1157
  // The init-time prune in history.ts only touched the `messages` table.
1122
1158
  // `subagents` and `turns` in registry.db grew unbounded — every Agent()
@@ -18371,11 +18407,15 @@ void (async () => {
18371
18407
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
18372
18408
  outcome,
18373
18409
  isBackground,
18374
- fleetChatId,
18375
- // Owner-chat fallback: if the progress-driver fleet
18376
- // entry was already cleaned up, route to the owner
18377
- // chat. Every switchroom fleet agent is DM-shaped, so
18378
- // allowFrom[0] is the conversation that dispatched.
18410
+ // Route the handback (the worker's result → a synthesized
18411
+ // turn) back to the conversation the Task was dispatched
18412
+ // from, so the result lands where the user asked — not the
18413
+ // agent's DM. Falls back to fleetChatId/ownerChatId.
18414
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18415
+ // Owner-chat fallback: if the parent-turn chat can't be
18416
+ // resolved, route to the owner chat. Every switchroom fleet
18417
+ // agent is DM-shaped, so allowFrom[0] is the conversation
18418
+ // that dispatched.
18379
18419
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18380
18420
  taskDescription: description,
18381
18421
  resultText,
@@ -18505,12 +18545,16 @@ void (async () => {
18505
18545
  // message owns the progress beat. Push a running cue and
18506
18546
  // return BEFORE the legacy bucket relay so the same activity
18507
18547
  // isn't double-surfaced (in-message edit + injected
18508
- // "still working" inbound turn). Chat = owner DM, since the
18509
- // pinned-card fleet is gone and every agent is DM-shaped.
18548
+ // "still working" inbound turn). Route to the conversation
18549
+ // the Task was dispatched from (group / forum topic) via the
18550
+ // parent turn; fall back to the owner DM when that can't be
18551
+ // resolved (the pinned-card fleet that used to carry the chat
18552
+ // is gone — see resolveSubagentOriginChat).
18510
18553
  if (workerFeedEnabled) {
18554
+ const origin = resolveSubagentOriginChat(agentId)
18511
18555
  void workerActivityFeed.update(
18512
18556
  agentId,
18513
- fleetChatId || (loadAccess().allowFrom[0] ?? ''),
18557
+ origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
18514
18558
  {
18515
18559
  description: dispatch.feedDescription,
18516
18560
  lastTool,
@@ -18519,6 +18563,7 @@ void (async () => {
18519
18563
  elapsedMs,
18520
18564
  state: 'running',
18521
18565
  },
18566
+ origin?.threadId,
18522
18567
  )
18523
18568
  return
18524
18569
  }
@@ -18526,7 +18571,9 @@ void (async () => {
18526
18571
  const decision = decideSubagentProgress({
18527
18572
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
18528
18573
  isBackground,
18529
- fleetChatId,
18574
+ // Prefer the conversation the Task was dispatched from over
18575
+ // the owner DM (see resolveSubagentOriginChat).
18576
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18530
18577
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18531
18578
  subagentJsonlId: agentId,
18532
18579
  taskDescription: description,
@@ -20,6 +20,7 @@ import {
20
20
  recordTurnStart,
21
21
  recordTurnEnd,
22
22
  findRecentTurnsForChat,
23
+ getTurnByKey,
23
24
  } from './turns-schema.js'
24
25
 
25
26
  // ---------------------------------------------------------------------------
@@ -99,3 +100,36 @@ describe('findRecentTurnsForChat', () => {
99
100
  db.close()
100
101
  })
101
102
  })
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // getTurnByKey — recover the dispatch chat/thread for a sub-agent's parent
106
+ // turn (subagents.parent_turn_key -> turns.turn_key). Without this the
107
+ // worker card / handback fall back to the operator DM (#worker-card-routing).
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('getTurnByKey', () => {
111
+ it('returns null when the turn key does not exist', () => {
112
+ const db = openTurnsDbInMemory()
113
+ expect(getTurnByKey(db, 'nope')).toBeNull()
114
+ db.close()
115
+ })
116
+
117
+ it('recovers chat_id + thread_id for a group/topic turn', () => {
118
+ const db = openTurnsDbInMemory()
119
+ recordTurnStart(db, { turnKey: 'g:11', chatId: '-1001234567890', threadId: '42' })
120
+ const turn = getTurnByKey(db, 'g:11')
121
+ expect(turn?.turn_key).toBe('g:11')
122
+ expect(turn?.chat_id).toBe('-1001234567890')
123
+ expect(turn?.thread_id).toBe('42')
124
+ db.close()
125
+ })
126
+
127
+ it('recovers chat_id with null thread_id for a plain group/DM turn', () => {
128
+ const db = openTurnsDbInMemory()
129
+ recordTurnStart(db, { turnKey: 'dm:7', chatId: '12345' })
130
+ const turn = getTurnByKey(db, 'dm:7')
131
+ expect(turn?.chat_id).toBe('12345')
132
+ expect(turn?.thread_id).toBeNull()
133
+ db.close()
134
+ })
135
+ })
@@ -348,6 +348,24 @@ export function findOrphanedTurns(db: SqliteDatabase, chatId: string): Turn[] {
348
348
  return rows.map(mapRow)
349
349
  }
350
350
 
351
+ /**
352
+ * Fetch a single turn by its primary key, or null if absent.
353
+ *
354
+ * Used to recover the chat/thread a background sub-agent was dispatched
355
+ * from: `subagents.parent_turn_key` is an FK-by-convention to
356
+ * `turns.turn_key`, so this resolves the originating conversation
357
+ * (chat_id + thread_id) for a worker card / handback. Without it the
358
+ * worker feed falls back to the operator DM (the pinned-card fleet that
359
+ * used to carry the chat was removed in #1122), so a Task dispatched from
360
+ * a group/topic posted its progress to the agent's DM instead.
361
+ */
362
+ export function getTurnByKey(db: SqliteDatabase, turnKey: string): Turn | null {
363
+ const row = db
364
+ .prepare(`SELECT * FROM turns WHERE turn_key = ?`)
365
+ .get(turnKey) as RawTurnRow | undefined
366
+ return row ? mapRow(row) : null
367
+ }
368
+
351
369
  export interface OrphanClassifyOpts {
352
370
  /**
353
371
  * `turnKey` from the on-disk `turn-active.json` marker — the single