/ src / lib / server / runtime / queue-followups.test.ts
queue-followups.test.ts
  1  import { describe, it } from 'node:test'
  2  import assert from 'node:assert/strict'
  3  import type { BoardTask, Session } from '@/types'
  4  import {
  5    applyTaskResumeStateToSession,
  6    collectTaskConnectorFollowupTargets,
  7    dequeueNextRunnableTask,
  8    resolveTaskOriginConnectorFollowupTarget,
  9    resolveTaskResumeContext,
 10    resolveReusableTaskSessionId,
 11  } from '@/lib/server/runtime/queue'
 12  
 13  function makeTask(partial?: Partial<BoardTask> & { createdInSessionId?: string | null }): BoardTask {
 14    const now = Date.now()
 15    return {
 16      id: 'task-1',
 17      title: 'Test task',
 18      description: 'desc',
 19      status: 'queued',
 20      agentId: 'agent-a',
 21      createdAt: now,
 22      updatedAt: now,
 23      ...(partial || {}),
 24    } as BoardTask
 25  }
 26  
 27  type SessionFixtureMap = Record<string, {
 28    connectorContext?: {
 29      connectorId?: string
 30      channelId?: string
 31      threadId?: string
 32    }
 33    messages: Array<{
 34      role: string
 35      text?: string
 36      historyExcluded?: boolean
 37      source?: {
 38        connectorId?: string
 39        channelId?: string
 40        threadId?: string
 41      }
 42    }>
 43  }>
 44  
 45  describe('resolveTaskOriginConnectorFollowupTarget', () => {
 46    it('uses connector source channel from origin session and normalizes WhatsApp numbers', () => {
 47      const task = makeTask({ createdInSessionId: 'session-1' })
 48      const sessions = {
 49        'session-1': {
 50          messages: [
 51            { role: 'assistant', text: 'ok' },
 52            {
 53              role: 'user',
 54              text: 'please update me',
 55              source: {
 56                connectorId: 'conn-wa',
 57                channelId: '+44 7700 900123',
 58              },
 59            },
 60          ],
 61        },
 62      }
 63      const connectors = {
 64        'conn-wa': {
 65          id: 'conn-wa',
 66          platform: 'whatsapp',
 67          agentId: 'agent-a',
 68          config: {},
 69        },
 70      }
 71      const running = [
 72        {
 73          id: 'conn-wa',
 74          platform: 'whatsapp',
 75          agentId: 'agent-a',
 76          supportsSend: true,
 77          configuredTargets: [],
 78          recentChannelId: '185200000000000@lid',
 79        },
 80      ]
 81  
 82      const target = resolveTaskOriginConnectorFollowupTarget({
 83        task,
 84        sessions: sessions as SessionFixtureMap,
 85        connectors: connectors as any,
 86        running,
 87      })
 88  
 89      assert.deepEqual(target, {
 90        connectorId: 'conn-wa',
 91        channelId: '447700900123@s.whatsapp.net',
 92      })
 93    })
 94  
 95    it('falls back to runtime recent channel when source channel is unavailable', () => {
 96      const task = makeTask({ createdInSessionId: 'session-1' })
 97      const sessions = {
 98        'session-1': {
 99          messages: [
100            {
101              role: 'user',
102              text: 'run this later',
103              source: {
104                connectorId: 'conn-telegram',
105              },
106            },
107          ],
108        },
109      }
110      const connectors = {
111        'conn-telegram': {
112          id: 'conn-telegram',
113          platform: 'telegram',
114          agentId: 'agent-a',
115          config: {},
116        },
117      }
118      const running = [
119        {
120          id: 'conn-telegram',
121          platform: 'telegram',
122          agentId: 'agent-a',
123          supportsSend: true,
124          configuredTargets: [],
125          recentChannelId: 'tg-chat-42',
126        },
127      ]
128  
129      const target = resolveTaskOriginConnectorFollowupTarget({
130        task,
131        sessions: sessions as SessionFixtureMap,
132        connectors: connectors as any,
133        running,
134      })
135  
136      assert.deepEqual(target, {
137        connectorId: 'conn-telegram',
138        channelId: 'tg-chat-42',
139      })
140    })
141  
142    it('returns null when the source connector belongs to a different agent', () => {
143      const task = makeTask({ createdInSessionId: 'session-1' })
144      const sessions = {
145        'session-1': {
146          messages: [
147            {
148              role: 'user',
149              text: 'do it',
150              source: {
151                connectorId: 'conn-wa',
152                channelId: '+15551230000',
153              },
154            },
155          ],
156        },
157      }
158      const connectors = {
159        'conn-wa': {
160          id: 'conn-wa',
161          platform: 'whatsapp',
162          agentId: 'different-agent',
163          config: {},
164        },
165      }
166      const running = [
167        {
168          id: 'conn-wa',
169          platform: 'whatsapp',
170          agentId: 'different-agent',
171          supportsSend: true,
172          configuredTargets: [],
173          recentChannelId: null,
174        },
175      ]
176  
177      const target = resolveTaskOriginConnectorFollowupTarget({
178        task,
179        sessions: sessions as SessionFixtureMap,
180        connectors: connectors as any,
181        running,
182      })
183  
184      assert.equal(target, null)
185    })
186  
187    it('allows delegated tasks to follow up via the delegating agent connector', () => {
188      const task = makeTask({
189        agentId: 'worker-agent',
190        delegatedByAgentId: 'delegator-agent',
191        createdInSessionId: 'session-1',
192      })
193      const sessions = {
194        'session-1': {
195          messages: [
196            {
197              role: 'user',
198              text: 'run and update me here',
199              source: {
200                connectorId: 'conn-wa',
201                channelId: '+44 7700 900123',
202              },
203            },
204          ],
205        },
206      }
207      const connectors = {
208        'conn-wa': {
209          id: 'conn-wa',
210          platform: 'whatsapp',
211          agentId: 'delegator-agent',
212          config: {},
213        },
214      }
215      const running = [
216        {
217          id: 'conn-wa',
218          platform: 'whatsapp',
219          agentId: 'delegator-agent',
220          supportsSend: true,
221          configuredTargets: [],
222          recentChannelId: null,
223        },
224      ]
225  
226      const target = resolveTaskOriginConnectorFollowupTarget({
227        task,
228        sessions: sessions as SessionFixtureMap,
229        connectors: connectors as any,
230        running,
231      })
232  
233      assert.deepEqual(target, {
234        connectorId: 'conn-wa',
235        channelId: '447700900123@s.whatsapp.net',
236      })
237    })
238  
239    it('prefers explicit task followup metadata over later thread traffic', () => {
240      const task = makeTask({
241        createdInSessionId: 'session-1',
242        followupConnectorId: 'conn-wa',
243        followupChannelId: '447700900111@s.whatsapp.net',
244        followupThreadId: 'thread-me',
245      })
246      const sessions = {
247        'session-1': {
248          messages: [
249            {
250              role: 'user',
251              text: 'wife said hello',
252              source: {
253                connectorId: 'conn-wa',
254                channelId: '447700900222@s.whatsapp.net',
255                threadId: 'thread-wife',
256              },
257            },
258          ],
259        },
260      }
261      const connectors = {
262        'conn-wa': {
263          id: 'conn-wa',
264          platform: 'whatsapp',
265          agentId: 'agent-a',
266          config: {},
267        },
268      }
269      const running = [
270        {
271          id: 'conn-wa',
272          platform: 'whatsapp',
273          agentId: 'agent-a',
274          supportsSend: true,
275          configuredTargets: [],
276          recentChannelId: '447700900222@s.whatsapp.net',
277        },
278      ]
279  
280      const target = resolveTaskOriginConnectorFollowupTarget({
281        task,
282        sessions: sessions as SessionFixtureMap,
283        connectors: connectors as any,
284        running,
285      })
286  
287      assert.deepEqual(target, {
288        connectorId: 'conn-wa',
289        channelId: '447700900111@s.whatsapp.net',
290        threadId: 'thread-me',
291      })
292    })
293  
294    it('ignores mirrored connector transcript copies when resolving delayed followups', () => {
295      const task = makeTask({ createdInSessionId: 'session-main' })
296      const sessions = {
297        'session-main': {
298          messages: [
299            {
300              role: 'user',
301              text: 'from me over whatsapp',
302              historyExcluded: true,
303              source: {
304                connectorId: 'conn-wa',
305                channelId: '447700900111@s.whatsapp.net',
306              },
307            },
308            {
309              role: 'user',
310              text: 'from wife over whatsapp later',
311              historyExcluded: true,
312              source: {
313                connectorId: 'conn-wa',
314                channelId: '447700900222@s.whatsapp.net',
315              },
316            },
317          ],
318        },
319      }
320      const connectors = {
321        'conn-wa': {
322          id: 'conn-wa',
323          platform: 'whatsapp',
324          agentId: 'agent-a',
325          config: {
326            taskFollowups: 'true',
327          },
328        },
329      }
330      const running = [
331        {
332          id: 'conn-wa',
333          platform: 'whatsapp',
334          agentId: 'agent-a',
335          supportsSend: true,
336          configuredTargets: [],
337          recentChannelId: '447700900222@s.whatsapp.net',
338        },
339      ]
340  
341      const target = resolveTaskOriginConnectorFollowupTarget({
342        task,
343        sessions: sessions as SessionFixtureMap,
344        connectors: connectors as any,
345        running,
346      })
347  
348      assert.equal(target, null)
349    })
350  })
351  
352  describe('collectTaskConnectorFollowupTargets', () => {
353    it('does not fall back to a connector recent channel when there is no explicit origin target', () => {
354      const task = makeTask({ createdInSessionId: 'session-main' })
355      const sessions = {
356        'session-main': {
357          messages: [
358            {
359              role: 'user',
360              text: 'mirrored from me',
361              historyExcluded: true,
362              source: {
363                connectorId: 'conn-wa',
364                channelId: '447700900111@s.whatsapp.net',
365              },
366            },
367          ],
368        },
369      }
370      const connectors = {
371        'conn-wa': {
372          id: 'conn-wa',
373          platform: 'whatsapp',
374          agentId: 'agent-a',
375          config: {
376            taskFollowups: 'true',
377          },
378        },
379      }
380      const running = [
381        {
382          id: 'conn-wa',
383          platform: 'whatsapp',
384          agentId: 'agent-a',
385          supportsSend: true,
386          configuredTargets: [],
387          recentChannelId: '447700900222@s.whatsapp.net',
388        },
389      ]
390  
391      const targets = collectTaskConnectorFollowupTargets({
392        task,
393        sessions: sessions as SessionFixtureMap,
394        connectors: connectors as any,
395        running,
396      })
397  
398      assert.deepEqual(targets, [])
399    })
400  
401    it('uses only the origin target when both origin and a different recent channel exist', () => {
402      const task = makeTask({
403        createdInSessionId: 'session-origin',
404        followupConnectorId: 'conn-wa',
405        followupChannelId: '447700900111@s.whatsapp.net',
406      })
407      const sessions = {
408        'session-origin': {
409          messages: [],
410        },
411      }
412      const connectors = {
413        'conn-wa': {
414          id: 'conn-wa',
415          platform: 'whatsapp',
416          agentId: 'agent-a',
417          config: {
418            taskFollowups: 'true',
419            outboundJid: '447700900333@s.whatsapp.net',
420          },
421        },
422      }
423      const running = [
424        {
425          id: 'conn-wa',
426          platform: 'whatsapp',
427          agentId: 'agent-a',
428          supportsSend: true,
429          configuredTargets: [],
430          recentChannelId: '447700900222@s.whatsapp.net',
431        },
432      ]
433  
434      const targets = collectTaskConnectorFollowupTargets({
435        task,
436        sessions: sessions as SessionFixtureMap,
437        connectors: connectors as any,
438        running,
439      })
440  
441      assert.deepEqual(targets, [
442        {
443          connectorId: 'conn-wa',
444          channelId: '447700900111@s.whatsapp.net',
445        },
446      ])
447    })
448  
449    it('uses configured outbound targets for generic task followups', () => {
450      const task = makeTask({ createdInSessionId: 'session-main' })
451      const sessions = {
452        'session-main': {
453          messages: [],
454        },
455      }
456      const connectors = {
457        'conn-wa': {
458          id: 'conn-wa',
459          platform: 'whatsapp',
460          agentId: 'agent-a',
461          config: {
462            taskFollowups: 'true',
463            outboundJid: '+44 7700 900333',
464          },
465        },
466      }
467      const running = [
468        {
469          id: 'conn-wa',
470          platform: 'whatsapp',
471          agentId: 'agent-a',
472          supportsSend: true,
473          configuredTargets: [],
474          recentChannelId: '447700900222@s.whatsapp.net',
475        },
476      ]
477  
478      const targets = collectTaskConnectorFollowupTargets({
479        task,
480        sessions: sessions as SessionFixtureMap,
481        connectors: connectors as any,
482        running,
483      })
484  
485      assert.deepEqual(targets, [
486        {
487          connectorId: 'conn-wa',
488          channelId: '447700900333@s.whatsapp.net',
489        },
490      ])
491    })
492  })
493  
494  describe('task resume context', () => {
495    it('falls back to delegated parent task resume handles for follow-up work', () => {
496      const parent = makeTask({
497        id: 'task-parent',
498        title: 'Parent task',
499        codexResumeId: 'codex-thread-123',
500        geminiResumeId: 'gemini-session-123',
501        sessionId: 'session-parent',
502      })
503      const child = makeTask({
504        id: 'task-child',
505        title: 'Child task',
506        delegatedFromTaskId: 'task-parent',
507      })
508  
509      const context = resolveTaskResumeContext(child, {
510        [parent.id]: parent,
511        [child.id]: child,
512      })
513  
514      assert.ok(context)
515      assert.equal(context?.source, 'delegated_from_task')
516      assert.equal(context?.sourceTaskId, 'task-parent')
517      assert.equal(context?.sourceSessionId, 'session-parent')
518      assert.equal(context?.resume.codexThreadId, 'codex-thread-123')
519      assert.equal(context?.resume.delegateResumeIds.gemini, 'gemini-session-123')
520    })
521  
522    it('hydrates task execution sessions with stored resume state', () => {
523      const session = {
524        id: 'session-task',
525        name: 'Task session',
526        cwd: process.cwd(),
527        user: 'system',
528        provider: 'codex-cli',
529        model: 'gpt-5-codex',
530        claudeSessionId: null,
531        codexThreadId: null,
532        opencodeSessionId: null,
533        delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
534        messages: [],
535        createdAt: Date.now(),
536        lastActiveAt: Date.now(),
537        sessionType: 'human',
538        agentId: 'agent-a',
539        parentSessionId: null,
540        extensions: ['delegate'],
541      } as Session
542  
543      const changed = applyTaskResumeStateToSession(session, {
544        claudeSessionId: 'claude-resume-1',
545        codexThreadId: 'codex-resume-1',
546        opencodeSessionId: 'opencode-resume-1',
547        delegateResumeIds: {
548          claudeCode: 'claude-resume-1',
549          codex: 'codex-resume-1',
550          opencode: 'opencode-resume-1',
551          gemini: 'gemini-resume-1',
552        },
553      })
554  
555      assert.equal(changed, true)
556      assert.equal(session.claudeSessionId, 'claude-resume-1')
557      assert.equal(session.codexThreadId, 'codex-resume-1')
558      assert.equal(session.opencodeSessionId, 'opencode-resume-1')
559      assert.equal(session.delegateResumeIds?.gemini, 'gemini-resume-1')
560    })
561  })
562  
563  describe('dequeueNextRunnableTask', () => {
564    it('leaves blocked queued tasks in place until their dependencies are completed', () => {
565      const source = makeTask({
566        id: 'task-source',
567        title: 'Source task',
568        status: 'running',
569      })
570      const followup = makeTask({
571        id: 'task-followup',
572        title: 'Follow-up task',
573        status: 'queued',
574        blockedBy: ['task-source'],
575      })
576      const queue = ['task-followup']
577  
578      const selectedWhileBlocked = dequeueNextRunnableTask(queue, {
579        [source.id]: source,
580        [followup.id]: followup,
581      })
582  
583      assert.equal(selectedWhileBlocked, null)
584      assert.deepEqual(queue, ['task-followup'])
585  
586      source.status = 'completed'
587      const selectedAfterUnblock = dequeueNextRunnableTask(queue, {
588        [source.id]: source,
589        [followup.id]: followup,
590      })
591  
592      assert.equal(selectedAfterUnblock, 'task-followup')
593      assert.deepEqual(queue, [])
594    })
595  })
596  
597  describe('resolveReusableTaskSessionId', () => {
598    it('reuses the completed dependency session for continuation tasks once it exists', () => {
599      const source = makeTask({
600        id: 'task-source',
601        title: 'Source task',
602        status: 'completed',
603        sessionId: 'session-source',
604        checkpoint: {
605          lastSessionId: 'session-source',
606          updatedAt: Date.now(),
607        },
608      })
609      const followup = makeTask({
610        id: 'task-followup',
611        title: 'Follow-up task',
612        status: 'queued',
613        blockedBy: ['task-source'],
614      })
615  
616      const sessionId = resolveReusableTaskSessionId(
617        followup,
618        {
619          [source.id]: source,
620          [followup.id]: followup,
621        },
622        {
623          'session-source': {
624            messages: [],
625          },
626        } as SessionFixtureMap,
627      )
628  
629      assert.equal(sessionId, 'session-source')
630    })
631  })