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 })