@swarmclawai/swarmclaw 0.7.7 → 0.7.8

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 (63) hide show
  1. package/README.md +10 -9
  2. package/package.json +1 -1
  3. package/src/app/api/chats/route.ts +1 -0
  4. package/src/app/api/connectors/[id]/route.ts +20 -2
  5. package/src/app/api/connectors/route.ts +12 -8
  6. package/src/app/api/projects/[id]/route.ts +6 -2
  7. package/src/app/api/projects/route.ts +4 -3
  8. package/src/app/api/secrets/[id]/route.ts +1 -0
  9. package/src/app/api/secrets/route.ts +2 -1
  10. package/src/app/api/settings/route.ts +2 -0
  11. package/src/components/agents/agent-sheet.tsx +184 -14
  12. package/src/components/chat/chat-area.tsx +36 -19
  13. package/src/components/chat/chat-header.tsx +4 -0
  14. package/src/components/chat/delegation-banner.test.ts +14 -1
  15. package/src/components/chat/delegation-banner.tsx +1 -1
  16. package/src/components/layout/app-layout.tsx +40 -23
  17. package/src/components/projects/project-detail.tsx +217 -0
  18. package/src/components/projects/project-sheet.tsx +176 -4
  19. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  20. package/src/components/shared/settings/section-voice.tsx +11 -3
  21. package/src/components/tasks/approvals-panel.tsx +177 -18
  22. package/src/components/tasks/task-board.tsx +137 -23
  23. package/src/components/tasks/task-card.tsx +29 -0
  24. package/src/components/tasks/task-sheet.tsx +16 -4
  25. package/src/lib/server/capability-router.test.ts +22 -0
  26. package/src/lib/server/capability-router.ts +54 -18
  27. package/src/lib/server/chat-execution.ts +25 -1
  28. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  29. package/src/lib/server/connectors/manager.ts +99 -74
  30. package/src/lib/server/daemon-state.ts +83 -46
  31. package/src/lib/server/elevenlabs.test.ts +59 -1
  32. package/src/lib/server/heartbeat-service.ts +5 -1
  33. package/src/lib/server/main-agent-loop.test.ts +260 -0
  34. package/src/lib/server/main-agent-loop.ts +559 -14
  35. package/src/lib/server/orchestrator-lg.ts +1 -0
  36. package/src/lib/server/orchestrator.ts +2 -0
  37. package/src/lib/server/plugins.ts +6 -1
  38. package/src/lib/server/project-context.ts +162 -0
  39. package/src/lib/server/project-utils.ts +150 -0
  40. package/src/lib/server/queue-followups.test.ts +147 -2
  41. package/src/lib/server/queue.ts +234 -7
  42. package/src/lib/server/session-run-manager.ts +31 -0
  43. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  44. package/src/lib/server/session-tools/connector.ts +26 -1
  45. package/src/lib/server/session-tools/context.ts +5 -0
  46. package/src/lib/server/session-tools/crud.ts +265 -76
  47. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  48. package/src/lib/server/session-tools/delegate.ts +38 -2
  49. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  50. package/src/lib/server/session-tools/memory.ts +14 -2
  51. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  52. package/src/lib/server/session-tools/platform.ts +60 -19
  53. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  54. package/src/lib/server/session-tools/web.ts +153 -6
  55. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  56. package/src/lib/server/stream-agent-chat.ts +104 -30
  57. package/src/lib/server/tool-aliases.ts +2 -0
  58. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  59. package/src/lib/server/tool-capability-policy.ts +29 -1
  60. package/src/lib/server/tool-planning.test.ts +44 -0
  61. package/src/lib/server/tool-planning.ts +269 -0
  62. package/src/lib/tool-definitions.ts +2 -1
  63. package/src/types/index.ts +39 -0
@@ -0,0 +1,162 @@
1
+ import path from 'path'
2
+ import type { Project } from '@/types'
3
+ import { WORKSPACE_DIR } from './data-dir'
4
+ import { loadAgents, loadProjects } from './storage'
5
+ import { buildProjectSnapshot, type ProjectResourceSummary } from './project-utils'
6
+
7
+ export interface ActiveProjectContext {
8
+ projectId: string | null
9
+ project: (Project & { workspaceRoot: string; resourceSummary: ProjectResourceSummary }) | null
10
+ projectRoot: string | null
11
+ objective: string | null
12
+ audience: string | null
13
+ priorities: string[]
14
+ openObjectives: string[]
15
+ capabilityHints: string[]
16
+ credentialRequirements: string[]
17
+ successMetrics: string[]
18
+ heartbeatPrompt: string | null
19
+ heartbeatIntervalSec: number | null
20
+ resourceSummary: ProjectResourceSummary | null
21
+ }
22
+
23
+ function normalizeProjectId(value: unknown): string | null {
24
+ return typeof value === 'string' && value.trim() ? value.trim() : null
25
+ }
26
+
27
+ function inferProjectIdFromCwd(cwd: unknown): string | null {
28
+ if (typeof cwd !== 'string' || !cwd.trim()) return null
29
+ const projectsRoot = path.resolve(path.join(WORKSPACE_DIR, 'projects'))
30
+ const resolvedCwd = path.resolve(cwd)
31
+ const relative = path.relative(projectsRoot, resolvedCwd)
32
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null
33
+ const [projectId] = relative.split(path.sep).filter(Boolean)
34
+ return normalizeProjectId(projectId)
35
+ }
36
+
37
+ function extractProjectHints(project: Project | null): {
38
+ objective: string | null
39
+ audience: string | null
40
+ priorities: string[]
41
+ openObjectives: string[]
42
+ capabilityHints: string[]
43
+ credentialRequirements: string[]
44
+ successMetrics: string[]
45
+ heartbeatPrompt: string | null
46
+ heartbeatIntervalSec: number | null
47
+ } {
48
+ if (!project) {
49
+ return {
50
+ objective: null,
51
+ audience: null,
52
+ priorities: [],
53
+ openObjectives: [],
54
+ capabilityHints: [],
55
+ credentialRequirements: [],
56
+ successMetrics: [],
57
+ heartbeatPrompt: null,
58
+ heartbeatIntervalSec: null,
59
+ }
60
+ }
61
+ if (
62
+ project.objective
63
+ || project.audience
64
+ || project.priorities?.length
65
+ || project.openObjectives?.length
66
+ || project.capabilityHints?.length
67
+ || project.credentialRequirements?.length
68
+ || project.successMetrics?.length
69
+ || project.heartbeatPrompt
70
+ || typeof project.heartbeatIntervalSec === 'number'
71
+ ) {
72
+ return {
73
+ objective: project.objective || null,
74
+ audience: project.audience || null,
75
+ priorities: Array.isArray(project.priorities) ? project.priorities : [],
76
+ openObjectives: Array.isArray(project.openObjectives) ? project.openObjectives : [],
77
+ capabilityHints: Array.isArray(project.capabilityHints) ? project.capabilityHints : [],
78
+ credentialRequirements: Array.isArray(project.credentialRequirements) ? project.credentialRequirements : [],
79
+ successMetrics: Array.isArray(project.successMetrics) ? project.successMetrics : [],
80
+ heartbeatPrompt: project.heartbeatPrompt || null,
81
+ heartbeatIntervalSec: typeof project.heartbeatIntervalSec === 'number' ? project.heartbeatIntervalSec : null,
82
+ }
83
+ }
84
+
85
+ const description = project.description || ''
86
+ if (!description) {
87
+ return {
88
+ objective: null,
89
+ audience: null,
90
+ priorities: [],
91
+ openObjectives: [],
92
+ capabilityHints: [],
93
+ credentialRequirements: [],
94
+ successMetrics: [],
95
+ heartbeatPrompt: null,
96
+ heartbeatIntervalSec: null,
97
+ }
98
+ }
99
+ const audienceMatch = description.match(/\bfor\s+([^.!?]+?)(?:\.|,|;|$)/i)
100
+ const objectiveMatch = description.match(/^([^.!?]+?)(?:\.|!|\?)/)
101
+ const focusMatch = description.match(/\b(?:focused on|focuses on|pilot priorities(?: are| include)?|priority is)\s+([^.!?]+)/i)
102
+ const audience = normalizeProjectId(audienceMatch?.[1]?.replace(/^the\s+/i, '').trim()) || null
103
+ const priorities = (focusMatch?.[1] || '')
104
+ .split(/\s+(?:and|&)\s+|,\s+/)
105
+ .map((value) => value.trim())
106
+ .filter((value) => value.length > 0)
107
+ .slice(0, 4)
108
+ return {
109
+ objective: normalizeProjectId(objectiveMatch?.[1]?.trim()) || null,
110
+ audience,
111
+ priorities,
112
+ openObjectives: [],
113
+ capabilityHints: [],
114
+ credentialRequirements: [],
115
+ successMetrics: [],
116
+ heartbeatPrompt: null,
117
+ heartbeatIntervalSec: null,
118
+ }
119
+ }
120
+
121
+ export function resolveActiveProjectContext(sessionLike: { agentId?: string | null; cwd?: string | null; projectId?: string | null }): ActiveProjectContext {
122
+ const agents = loadAgents()
123
+ const projects = loadProjects() as Record<string, Project>
124
+ const explicitProjectId = normalizeProjectId(sessionLike.projectId)
125
+ const agentProjectId = normalizeProjectId(sessionLike.agentId ? agents[sessionLike.agentId]?.projectId : null)
126
+ const cwdProjectId = inferProjectIdFromCwd(sessionLike.cwd)
127
+ const projectId = explicitProjectId || agentProjectId || cwdProjectId
128
+ if (!projectId) {
129
+ return {
130
+ projectId: null,
131
+ project: null,
132
+ projectRoot: null,
133
+ objective: null,
134
+ audience: null,
135
+ priorities: [],
136
+ openObjectives: [],
137
+ capabilityHints: [],
138
+ credentialRequirements: [],
139
+ successMetrics: [],
140
+ heartbeatPrompt: null,
141
+ heartbeatIntervalSec: null,
142
+ resourceSummary: null,
143
+ }
144
+ }
145
+ const project = projects[projectId] ? buildProjectSnapshot(projects[projectId]) : null
146
+ const hints = extractProjectHints(project)
147
+ return {
148
+ projectId,
149
+ project,
150
+ projectRoot: project?.workspaceRoot || path.join(WORKSPACE_DIR, 'projects', projectId),
151
+ objective: hints.objective,
152
+ audience: hints.audience,
153
+ priorities: hints.priorities,
154
+ openObjectives: hints.openObjectives,
155
+ capabilityHints: hints.capabilityHints,
156
+ credentialRequirements: hints.credentialRequirements,
157
+ successMetrics: hints.successMetrics,
158
+ heartbeatPrompt: hints.heartbeatPrompt,
159
+ heartbeatIntervalSec: hints.heartbeatIntervalSec,
160
+ resourceSummary: project?.resourceSummary || null,
161
+ }
162
+ }
@@ -0,0 +1,150 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import type { OrchestratorSecret, Project, Schedule, Skill, BoardTask } from '@/types'
4
+ import { WORKSPACE_DIR } from './data-dir'
5
+ import { loadSchedules, loadSecrets, loadSkills, loadTasks } from './storage'
6
+
7
+ function normalizeText(value: unknown, maxLen = 400): string | undefined {
8
+ if (typeof value !== 'string') return undefined
9
+ const trimmed = value.replace(/\s+/g, ' ').trim()
10
+ if (!trimmed) return undefined
11
+ return trimmed.slice(0, maxLen)
12
+ }
13
+
14
+ function normalizeColor(value: unknown): string | undefined {
15
+ if (typeof value !== 'string') return undefined
16
+ const trimmed = value.trim()
17
+ if (!trimmed) return undefined
18
+ return trimmed.slice(0, 32)
19
+ }
20
+
21
+ function normalizeStringArray(value: unknown, maxItems = 8, maxLen = 160): string[] | undefined {
22
+ const rawItems = Array.isArray(value)
23
+ ? value
24
+ : typeof value === 'string'
25
+ ? value.split(/\r?\n|[,;]+/)
26
+ : []
27
+ const items = rawItems
28
+ .map((entry) => typeof entry === 'string' ? entry.replace(/\s+/g, ' ').trim() : '')
29
+ .filter(Boolean)
30
+ .slice(0, maxItems)
31
+ .map((entry) => entry.slice(0, maxLen))
32
+ return items.length > 0 ? items : undefined
33
+ }
34
+
35
+ function normalizeInteger(value: unknown, min: number, max: number): number | undefined {
36
+ const parsed = Number(value)
37
+ if (!Number.isFinite(parsed)) return undefined
38
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
39
+ }
40
+
41
+ export function normalizeProjectCreateInput(input: Record<string, unknown>): Omit<Project, 'id' | 'createdAt' | 'updatedAt'> {
42
+ return {
43
+ name: normalizeText(input.name, 140) || 'Unnamed Project',
44
+ description: normalizeText(input.description, 4000) || '',
45
+ color: normalizeColor(input.color),
46
+ objective: normalizeText(input.objective, 240),
47
+ audience: normalizeText(input.audience, 240),
48
+ priorities: normalizeStringArray(input.priorities, 10),
49
+ openObjectives: normalizeStringArray(input.openObjectives, 12),
50
+ capabilityHints: normalizeStringArray(input.capabilityHints, 12),
51
+ credentialRequirements: normalizeStringArray(input.credentialRequirements, 12),
52
+ successMetrics: normalizeStringArray(input.successMetrics, 10),
53
+ heartbeatPrompt: normalizeText(input.heartbeatPrompt, 300),
54
+ heartbeatIntervalSec: normalizeInteger(input.heartbeatIntervalSec, 0, 86_400),
55
+ }
56
+ }
57
+
58
+ export function normalizeProjectPatchInput(input: Record<string, unknown>): Partial<Project> {
59
+ const patch: Partial<Project> = {}
60
+
61
+ if ('name' in input) patch.name = normalizeText(input.name, 140) || 'Unnamed Project'
62
+ if ('description' in input) patch.description = normalizeText(input.description, 4000) || ''
63
+ if ('color' in input) patch.color = normalizeColor(input.color)
64
+ if ('objective' in input) patch.objective = normalizeText(input.objective, 240)
65
+ if ('audience' in input) patch.audience = normalizeText(input.audience, 240)
66
+ if ('priorities' in input) patch.priorities = normalizeStringArray(input.priorities, 10) || []
67
+ if ('openObjectives' in input) patch.openObjectives = normalizeStringArray(input.openObjectives, 12) || []
68
+ if ('capabilityHints' in input) patch.capabilityHints = normalizeStringArray(input.capabilityHints, 12) || []
69
+ if ('credentialRequirements' in input) patch.credentialRequirements = normalizeStringArray(input.credentialRequirements, 12) || []
70
+ if ('successMetrics' in input) patch.successMetrics = normalizeStringArray(input.successMetrics, 10) || []
71
+ if ('heartbeatPrompt' in input) patch.heartbeatPrompt = normalizeText(input.heartbeatPrompt, 300)
72
+ if ('heartbeatIntervalSec' in input) patch.heartbeatIntervalSec = normalizeInteger(input.heartbeatIntervalSec, 0, 86_400)
73
+
74
+ return patch
75
+ }
76
+
77
+ export function projectWorkspaceRoot(projectId: string): string {
78
+ return path.join(WORKSPACE_DIR, 'projects', projectId)
79
+ }
80
+
81
+ export function ensureProjectWorkspace(projectId: string, projectName?: string): string {
82
+ const root = projectWorkspaceRoot(projectId)
83
+ fs.mkdirSync(root, { recursive: true })
84
+ const readmePath = path.join(root, 'README.md')
85
+ if (!fs.existsSync(readmePath)) {
86
+ const title = (projectName || 'Project Workspace').trim() || 'Project Workspace'
87
+ fs.writeFileSync(readmePath, `# ${title}\n\nThis workspace belongs to project ${projectId}.\n`, 'utf8')
88
+ }
89
+ return root
90
+ }
91
+
92
+ export interface ProjectResourceSummary {
93
+ openTaskCount: number
94
+ queuedTaskCount: number
95
+ runningTaskCount: number
96
+ activeScheduleCount: number
97
+ secretCount: number
98
+ skillCount: number
99
+ topTaskTitles: string[]
100
+ scheduleNames: string[]
101
+ secretNames: string[]
102
+ }
103
+
104
+ function byUpdatedDesc<T extends { updatedAt?: number; createdAt?: number }>(a: T, b: T): number {
105
+ return (Number(b.updatedAt || b.createdAt || 0) - Number(a.updatedAt || a.createdAt || 0))
106
+ }
107
+
108
+ export function summarizeProjectResources(projectId: string): ProjectResourceSummary {
109
+ const tasks = Object.values(loadTasks() as Record<string, BoardTask>)
110
+ .filter((task) => task?.projectId === projectId)
111
+ const schedules = Object.values(loadSchedules() as Record<string, Schedule>)
112
+ .filter((schedule) => schedule?.projectId === projectId)
113
+ const secrets = Object.values(loadSecrets() as Record<string, OrchestratorSecret & { projectId?: string }>)
114
+ .filter((secret) => secret?.projectId === projectId)
115
+ const skills = Object.values(loadSkills() as Record<string, Skill>)
116
+ .filter((skill) => skill?.projectId === projectId)
117
+
118
+ const openTasks = tasks
119
+ .filter((task) => ['backlog', 'queued', 'running'].includes(String(task.status || '').toLowerCase()))
120
+ .sort(byUpdatedDesc)
121
+ const activeSchedules = schedules
122
+ .filter((schedule) => String(schedule.status || '').toLowerCase() === 'active')
123
+ .sort(byUpdatedDesc)
124
+ const recentSecrets = secrets
125
+ .slice()
126
+ .sort(byUpdatedDesc)
127
+
128
+ return {
129
+ openTaskCount: openTasks.length,
130
+ queuedTaskCount: openTasks.filter((task) => task.status === 'queued').length,
131
+ runningTaskCount: openTasks.filter((task) => task.status === 'running').length,
132
+ activeScheduleCount: activeSchedules.length,
133
+ secretCount: secrets.length,
134
+ skillCount: skills.length,
135
+ topTaskTitles: openTasks.slice(0, 3).map((task) => String(task.title || '').trim()).filter(Boolean),
136
+ scheduleNames: activeSchedules.slice(0, 3).map((schedule) => String(schedule.name || '').trim()).filter(Boolean),
137
+ secretNames: recentSecrets.slice(0, 3).map((secret) => String(secret.name || '').trim()).filter(Boolean),
138
+ }
139
+ }
140
+
141
+ export function buildProjectSnapshot(project: Project): Project & {
142
+ workspaceRoot: string
143
+ resourceSummary: ProjectResourceSummary
144
+ } {
145
+ return {
146
+ ...project,
147
+ workspaceRoot: ensureProjectWorkspace(project.id, project.name),
148
+ resourceSummary: summarizeProjectResources(project.id),
149
+ }
150
+ }
@@ -1,7 +1,13 @@
1
1
  import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import type { BoardTask } from '@/types'
4
- import { resolveTaskOriginConnectorFollowupTarget } from './queue'
3
+ import type { BoardTask, Session } from '@/types'
4
+ import {
5
+ applyTaskResumeStateToSession,
6
+ dequeueNextRunnableTask,
7
+ resolveTaskOriginConnectorFollowupTarget,
8
+ resolveTaskResumeContext,
9
+ resolveReusableTaskSessionId,
10
+ } from './queue'
5
11
 
6
12
  function makeTask(partial?: Partial<BoardTask> & { createdInSessionId?: string | null }): BoardTask {
7
13
  const now = Date.now()
@@ -222,3 +228,142 @@ describe('resolveTaskOriginConnectorFollowupTarget', () => {
222
228
  })
223
229
  })
224
230
  })
231
+
232
+ describe('task resume context', () => {
233
+ it('falls back to delegated parent task resume handles for follow-up work', () => {
234
+ const parent = makeTask({
235
+ id: 'task-parent',
236
+ title: 'Parent task',
237
+ codexResumeId: 'codex-thread-123',
238
+ geminiResumeId: 'gemini-session-123',
239
+ sessionId: 'session-parent',
240
+ })
241
+ const child = makeTask({
242
+ id: 'task-child',
243
+ title: 'Child task',
244
+ delegatedFromTaskId: 'task-parent',
245
+ })
246
+
247
+ const context = resolveTaskResumeContext(child, {
248
+ [parent.id]: parent,
249
+ [child.id]: child,
250
+ })
251
+
252
+ assert.ok(context)
253
+ assert.equal(context?.source, 'delegated_from_task')
254
+ assert.equal(context?.sourceTaskId, 'task-parent')
255
+ assert.equal(context?.sourceSessionId, 'session-parent')
256
+ assert.equal(context?.resume.codexThreadId, 'codex-thread-123')
257
+ assert.equal(context?.resume.delegateResumeIds.gemini, 'gemini-session-123')
258
+ })
259
+
260
+ it('hydrates task execution sessions with stored resume state', () => {
261
+ const session = {
262
+ id: 'session-task',
263
+ name: 'Task session',
264
+ cwd: process.cwd(),
265
+ user: 'system',
266
+ provider: 'codex-cli',
267
+ model: 'gpt-5-codex',
268
+ claudeSessionId: null,
269
+ codexThreadId: null,
270
+ opencodeSessionId: null,
271
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
272
+ messages: [],
273
+ createdAt: Date.now(),
274
+ lastActiveAt: Date.now(),
275
+ sessionType: 'human',
276
+ agentId: 'agent-a',
277
+ parentSessionId: null,
278
+ plugins: ['delegate'],
279
+ } satisfies Session
280
+
281
+ const changed = applyTaskResumeStateToSession(session, {
282
+ claudeSessionId: 'claude-resume-1',
283
+ codexThreadId: 'codex-resume-1',
284
+ opencodeSessionId: 'opencode-resume-1',
285
+ delegateResumeIds: {
286
+ claudeCode: 'claude-resume-1',
287
+ codex: 'codex-resume-1',
288
+ opencode: 'opencode-resume-1',
289
+ gemini: 'gemini-resume-1',
290
+ },
291
+ })
292
+
293
+ assert.equal(changed, true)
294
+ assert.equal(session.claudeSessionId, 'claude-resume-1')
295
+ assert.equal(session.codexThreadId, 'codex-resume-1')
296
+ assert.equal(session.opencodeSessionId, 'opencode-resume-1')
297
+ assert.equal(session.delegateResumeIds?.gemini, 'gemini-resume-1')
298
+ })
299
+ })
300
+
301
+ describe('dequeueNextRunnableTask', () => {
302
+ it('leaves blocked queued tasks in place until their dependencies are completed', () => {
303
+ const source = makeTask({
304
+ id: 'task-source',
305
+ title: 'Source task',
306
+ status: 'running',
307
+ })
308
+ const followup = makeTask({
309
+ id: 'task-followup',
310
+ title: 'Follow-up task',
311
+ status: 'queued',
312
+ blockedBy: ['task-source'],
313
+ })
314
+ const queue = ['task-followup']
315
+
316
+ const selectedWhileBlocked = dequeueNextRunnableTask(queue, {
317
+ [source.id]: source,
318
+ [followup.id]: followup,
319
+ })
320
+
321
+ assert.equal(selectedWhileBlocked, null)
322
+ assert.deepEqual(queue, ['task-followup'])
323
+
324
+ source.status = 'completed'
325
+ const selectedAfterUnblock = dequeueNextRunnableTask(queue, {
326
+ [source.id]: source,
327
+ [followup.id]: followup,
328
+ })
329
+
330
+ assert.equal(selectedAfterUnblock, 'task-followup')
331
+ assert.deepEqual(queue, [])
332
+ })
333
+ })
334
+
335
+ describe('resolveReusableTaskSessionId', () => {
336
+ it('reuses the completed dependency session for continuation tasks once it exists', () => {
337
+ const source = makeTask({
338
+ id: 'task-source',
339
+ title: 'Source task',
340
+ status: 'completed',
341
+ sessionId: 'session-source',
342
+ checkpoint: {
343
+ lastSessionId: 'session-source',
344
+ updatedAt: Date.now(),
345
+ },
346
+ })
347
+ const followup = makeTask({
348
+ id: 'task-followup',
349
+ title: 'Follow-up task',
350
+ status: 'queued',
351
+ blockedBy: ['task-source'],
352
+ })
353
+
354
+ const sessionId = resolveReusableTaskSessionId(
355
+ followup,
356
+ {
357
+ [source.id]: source,
358
+ [followup.id]: followup,
359
+ },
360
+ {
361
+ 'session-source': {
362
+ messages: [],
363
+ },
364
+ } as SessionFixtureMap,
365
+ )
366
+
367
+ assert.equal(sessionId, 'session-source')
368
+ })
369
+ })