@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.
- package/README.md +10 -9
- package/package.json +1 -1
- package/src/app/api/chats/route.ts +1 -0
- package/src/app/api/connectors/[id]/route.ts +20 -2
- package/src/app/api/connectors/route.ts +12 -8
- package/src/app/api/projects/[id]/route.ts +6 -2
- package/src/app/api/projects/route.ts +4 -3
- package/src/app/api/secrets/[id]/route.ts +1 -0
- package/src/app/api/secrets/route.ts +2 -1
- package/src/app/api/settings/route.ts +2 -0
- package/src/components/agents/agent-sheet.tsx +184 -14
- package/src/components/chat/chat-area.tsx +36 -19
- package/src/components/chat/chat-header.tsx +4 -0
- package/src/components/chat/delegation-banner.test.ts +14 -1
- package/src/components/chat/delegation-banner.tsx +1 -1
- package/src/components/layout/app-layout.tsx +40 -23
- package/src/components/projects/project-detail.tsx +217 -0
- package/src/components/projects/project-sheet.tsx +176 -4
- package/src/components/shared/settings/section-capability-policy.tsx +38 -0
- package/src/components/shared/settings/section-voice.tsx +11 -3
- package/src/components/tasks/approvals-panel.tsx +177 -18
- package/src/components/tasks/task-board.tsx +137 -23
- package/src/components/tasks/task-card.tsx +29 -0
- package/src/components/tasks/task-sheet.tsx +16 -4
- package/src/lib/server/capability-router.test.ts +22 -0
- package/src/lib/server/capability-router.ts +54 -18
- package/src/lib/server/chat-execution.ts +25 -1
- package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
- package/src/lib/server/connectors/manager.ts +99 -74
- package/src/lib/server/daemon-state.ts +83 -46
- package/src/lib/server/elevenlabs.test.ts +59 -1
- package/src/lib/server/heartbeat-service.ts +5 -1
- package/src/lib/server/main-agent-loop.test.ts +260 -0
- package/src/lib/server/main-agent-loop.ts +559 -14
- package/src/lib/server/orchestrator-lg.ts +1 -0
- package/src/lib/server/orchestrator.ts +2 -0
- package/src/lib/server/plugins.ts +6 -1
- package/src/lib/server/project-context.ts +162 -0
- package/src/lib/server/project-utils.ts +150 -0
- package/src/lib/server/queue-followups.test.ts +147 -2
- package/src/lib/server/queue.ts +234 -7
- package/src/lib/server/session-run-manager.ts +31 -0
- package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
- package/src/lib/server/session-tools/connector.ts +26 -1
- package/src/lib/server/session-tools/context.ts +5 -0
- package/src/lib/server/session-tools/crud.ts +265 -76
- package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
- package/src/lib/server/session-tools/delegate.ts +38 -2
- package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
- package/src/lib/server/session-tools/memory.ts +14 -2
- package/src/lib/server/session-tools/platform-access.test.ts +58 -0
- package/src/lib/server/session-tools/platform.ts +60 -19
- package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
- package/src/lib/server/session-tools/web.ts +153 -6
- package/src/lib/server/stream-agent-chat.test.ts +27 -2
- package/src/lib/server/stream-agent-chat.ts +104 -30
- package/src/lib/server/tool-aliases.ts +2 -0
- package/src/lib/server/tool-capability-policy.test.ts +24 -0
- package/src/lib/server/tool-capability-policy.ts +29 -1
- package/src/lib/server/tool-planning.test.ts +44 -0
- package/src/lib/server/tool-planning.ts +269 -0
- package/src/lib/tool-definitions.ts +2 -1
- 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 {
|
|
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
|
+
})
|