@swarmclawai/swarmclaw 1.5.67 → 1.5.69
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 +18 -0
- package/package.json +14 -7
- package/src/app/api/runs/[id]/events/route.ts +10 -4
- package/src/app/api/runs/[id]/route.ts +6 -2
- package/src/app/api/runs/route.test.ts +84 -0
- package/src/app/api/runs/route.ts +41 -2
- package/src/components/agents/inspector-panel.tsx +2 -1
- package/src/components/chat/chat-area.tsx +57 -12
- package/src/components/chat/chat-header.tsx +20 -1
- package/src/components/schedules/schedule-console.tsx +148 -30
- package/src/lib/chat/new-session.test.ts +114 -0
- package/src/lib/chat/new-session.ts +146 -0
- package/src/lib/server/daemon/controller.test.ts +78 -0
- package/src/lib/server/daemon/controller.ts +50 -7
- package/src/lib/server/missions/mission-templates.test.ts +9 -0
- package/src/lib/server/missions/mission-templates.ts +23 -0
- package/src/lib/server/protocols/protocol-agent-turn.test.ts +164 -0
- package/src/lib/server/protocols/protocol-agent-turn.ts +119 -16
- package/src/lib/server/provider-endpoint.ts +0 -3
- package/src/lib/server/runs/unified-run-records.ts +91 -0
- package/src/lib/server/schedules/schedule-normalization.ts +4 -1
- package/src/lib/server/schedules/schedule-service.test.ts +73 -0
- package/src/lib/server/schedules/schedule-service.ts +10 -3
- package/src/lib/server/test-utils/run-with-temp-data-dir.ts +3 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useState, type ButtonHTMLAttributes } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
4
5
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
5
6
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
6
7
|
import { SearchInput } from '@/components/ui/search-input'
|
|
@@ -9,10 +10,11 @@ import { SectionHeader } from '@/components/ui/section-header'
|
|
|
9
10
|
import { useNow } from '@/hooks/use-now'
|
|
10
11
|
import { useWs } from '@/hooks/use-ws'
|
|
11
12
|
import { useAppStore } from '@/stores/use-app-store'
|
|
13
|
+
import { api } from '@/lib/app/api-client'
|
|
12
14
|
import { archiveSchedule, purgeSchedule, restoreSchedule, runSchedule, updateSchedule } from '@/lib/schedules/schedules'
|
|
13
15
|
import { cronToHuman } from '@/lib/schedules/cron-human'
|
|
14
16
|
import { timeAgo, timeUntil } from '@/lib/time-format'
|
|
15
|
-
import type { BoardTask, Schedule, ScheduleStatus } from '@/types'
|
|
17
|
+
import type { BoardTask, ProtocolRun, Schedule, ScheduleStatus } from '@/types'
|
|
16
18
|
import { toast } from 'sonner'
|
|
17
19
|
|
|
18
20
|
type ScheduleScope = 'live' | 'archived' | 'runs'
|
|
@@ -21,6 +23,18 @@ type ScheduleRunStatusFilter = 'all' | Extract<BoardTask['status'], 'queued' | '
|
|
|
21
23
|
type ScheduleCadenceFilter = 'all' | Schedule['scheduleType']
|
|
22
24
|
type ScheduleDeliveryFilter = 'all' | 'ok' | 'error' | 'unknown'
|
|
23
25
|
type ScheduleSortBy = 'nextRunAt' | 'lastRunAt' | 'updatedAt' | 'name'
|
|
26
|
+
type ScheduleConsoleRunRow = {
|
|
27
|
+
id: string
|
|
28
|
+
kind: 'task' | 'protocol'
|
|
29
|
+
status: Extract<BoardTask['status'], 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'>
|
|
30
|
+
title: string
|
|
31
|
+
preview: string
|
|
32
|
+
updatedAt: number
|
|
33
|
+
createdAt: number
|
|
34
|
+
agentId: string
|
|
35
|
+
scheduleId: string | null
|
|
36
|
+
scheduleName: string
|
|
37
|
+
}
|
|
24
38
|
|
|
25
39
|
const STATUS_STYLES: Record<string, string> = {
|
|
26
40
|
active: 'bg-emerald-500/12 text-emerald-400 border-emerald-500/20',
|
|
@@ -76,6 +90,55 @@ function runPreview(task: BoardTask): string {
|
|
|
76
90
|
return (result || error || task.description || '').slice(0, 180) || 'No run summary yet.'
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
function mapProtocolStatus(status: ProtocolRun['status']): ScheduleConsoleRunRow['status'] {
|
|
94
|
+
switch (status) {
|
|
95
|
+
case 'draft':
|
|
96
|
+
return 'queued'
|
|
97
|
+
case 'running':
|
|
98
|
+
case 'waiting':
|
|
99
|
+
case 'paused':
|
|
100
|
+
return 'running'
|
|
101
|
+
case 'completed':
|
|
102
|
+
return 'completed'
|
|
103
|
+
case 'failed':
|
|
104
|
+
return 'failed'
|
|
105
|
+
case 'cancelled':
|
|
106
|
+
case 'archived':
|
|
107
|
+
return 'cancelled'
|
|
108
|
+
default:
|
|
109
|
+
return 'queued'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function mapTaskStatus(status: BoardTask['status']): ScheduleConsoleRunRow['status'] {
|
|
114
|
+
switch (status) {
|
|
115
|
+
case 'running':
|
|
116
|
+
case 'completed':
|
|
117
|
+
case 'failed':
|
|
118
|
+
case 'cancelled':
|
|
119
|
+
case 'queued':
|
|
120
|
+
return status
|
|
121
|
+
case 'archived':
|
|
122
|
+
return 'cancelled'
|
|
123
|
+
default:
|
|
124
|
+
return 'queued'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function protocolRunPreview(run: ProtocolRun): string {
|
|
129
|
+
const summary = typeof run.summary === 'string' ? run.summary.trim() : ''
|
|
130
|
+
if (summary) return summary.slice(0, 180)
|
|
131
|
+
const latestArtifact = Array.isArray(run.artifacts)
|
|
132
|
+
? [...run.artifacts].sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))[0]
|
|
133
|
+
: null
|
|
134
|
+
const artifactContent = typeof latestArtifact?.content === 'string' ? latestArtifact.content.trim() : ''
|
|
135
|
+
if (artifactContent) return artifactContent.slice(0, 180)
|
|
136
|
+
const error = typeof run.lastError === 'string' ? run.lastError.trim() : ''
|
|
137
|
+
if (error) return error.slice(0, 180)
|
|
138
|
+
const goal = typeof run.config?.goal === 'string' ? run.config.goal.trim() : ''
|
|
139
|
+
return (goal || run.title || 'Structured session run').slice(0, 180)
|
|
140
|
+
}
|
|
141
|
+
|
|
79
142
|
function ActionButton(
|
|
80
143
|
props: ButtonHTMLAttributes<HTMLButtonElement> & { tone?: 'default' | 'danger' },
|
|
81
144
|
) {
|
|
@@ -96,6 +159,7 @@ function ActionButton(
|
|
|
96
159
|
}
|
|
97
160
|
|
|
98
161
|
export function ScheduleConsole() {
|
|
162
|
+
const router = useRouter()
|
|
99
163
|
const now = useNow()
|
|
100
164
|
const schedules = useAppStore((s) => s.schedules)
|
|
101
165
|
const tasks = useAppStore((s) => s.tasks)
|
|
@@ -119,12 +183,23 @@ export function ScheduleConsole() {
|
|
|
119
183
|
const [sortBy, setSortBy] = useState<ScheduleSortBy>('nextRunAt')
|
|
120
184
|
const [busyId, setBusyId] = useState('')
|
|
121
185
|
const [loaded, setLoaded] = useState(false)
|
|
186
|
+
const [protocolRuns, setProtocolRuns] = useState<ProtocolRun[]>([])
|
|
187
|
+
|
|
188
|
+
const fetchProtocolRuns = async () => {
|
|
189
|
+
try {
|
|
190
|
+
const runs = await api<ProtocolRun[]>('GET', '/protocols/runs?includeSystemOwned=true&sourceKind=schedule&limit=200')
|
|
191
|
+
setProtocolRuns(Array.isArray(runs) ? runs : [])
|
|
192
|
+
} catch {
|
|
193
|
+
setProtocolRuns([])
|
|
194
|
+
}
|
|
195
|
+
}
|
|
122
196
|
|
|
123
197
|
useEffect(() => {
|
|
124
|
-
void Promise.all([loadSchedules(), loadTasks(), loadAgents()]).then(() => setLoaded(true))
|
|
198
|
+
void Promise.all([loadSchedules(), loadTasks(), loadAgents(), fetchProtocolRuns()]).then(() => setLoaded(true))
|
|
125
199
|
}, [loadAgents, loadSchedules, loadTasks])
|
|
126
200
|
useWs('schedules', loadSchedules, 5_000)
|
|
127
201
|
useWs('tasks', loadTasks, 5_000)
|
|
202
|
+
useWs('protocol_runs', fetchProtocolRuns, 5_000)
|
|
128
203
|
|
|
129
204
|
useEffect(() => {
|
|
130
205
|
if (scope === 'runs') {
|
|
@@ -142,6 +217,10 @@ export function ScheduleConsole() {
|
|
|
142
217
|
|
|
143
218
|
const scheduleRows = useMemo(() => Object.values(schedules), [schedules])
|
|
144
219
|
const runRows = useMemo(() => Object.values(tasks).filter((task) => task.sourceType === 'schedule'), [tasks])
|
|
220
|
+
const protocolRunRows = useMemo(
|
|
221
|
+
() => protocolRuns.filter((run) => run.sourceRef.kind === 'schedule' && run.status !== 'archived'),
|
|
222
|
+
[protocolRuns],
|
|
223
|
+
)
|
|
145
224
|
const projectScopedSchedules = useMemo(
|
|
146
225
|
() => scheduleRows.filter((schedule) => !activeProjectFilter || schedule.projectId === activeProjectFilter),
|
|
147
226
|
[activeProjectFilter, scheduleRows],
|
|
@@ -150,6 +229,14 @@ export function ScheduleConsole() {
|
|
|
150
229
|
() => runRows.filter((task) => !activeProjectFilter || task.projectId === activeProjectFilter),
|
|
151
230
|
[activeProjectFilter, runRows],
|
|
152
231
|
)
|
|
232
|
+
const projectScopedProtocolRuns = useMemo(
|
|
233
|
+
() => protocolRunRows.filter((run) => {
|
|
234
|
+
if (!activeProjectFilter) return true
|
|
235
|
+
const sourceSchedule = typeof run.scheduleId === 'string' ? schedules[run.scheduleId] : null
|
|
236
|
+
return !!sourceSchedule && sourceSchedule.projectId === activeProjectFilter
|
|
237
|
+
}),
|
|
238
|
+
[activeProjectFilter, protocolRunRows, schedules],
|
|
239
|
+
)
|
|
153
240
|
|
|
154
241
|
const summary = useMemo(() => {
|
|
155
242
|
const live = projectScopedSchedules.filter((schedule) => schedule.status !== 'archived')
|
|
@@ -200,31 +287,55 @@ export function ScheduleConsole() {
|
|
|
200
287
|
})
|
|
201
288
|
}, [agentFilter, agents, cadenceFilter, deliveryFilter, projectScopedSchedules, scope, search, sortBy, statusFilter])
|
|
202
289
|
|
|
203
|
-
const filteredRuns = useMemo(() => {
|
|
290
|
+
const filteredRuns = useMemo<ScheduleConsoleRunRow[]>(() => {
|
|
204
291
|
const q = search.trim().toLowerCase()
|
|
205
|
-
|
|
206
|
-
.
|
|
207
|
-
|
|
208
|
-
|
|
292
|
+
const normalizedTaskRuns: ScheduleConsoleRunRow[] = projectScopedRuns.map((task) => ({
|
|
293
|
+
id: task.id,
|
|
294
|
+
kind: 'task',
|
|
295
|
+
status: mapTaskStatus(task.status),
|
|
296
|
+
title: task.title,
|
|
297
|
+
preview: runPreview(task),
|
|
298
|
+
updatedAt: task.updatedAt || task.createdAt,
|
|
299
|
+
createdAt: task.createdAt,
|
|
300
|
+
agentId: task.agentId,
|
|
301
|
+
scheduleId: typeof task.sourceScheduleId === 'string' ? task.sourceScheduleId : null,
|
|
302
|
+
scheduleName: typeof task.sourceScheduleName === 'string' ? task.sourceScheduleName : 'Scheduled run',
|
|
303
|
+
}))
|
|
304
|
+
const normalizedProtocolRuns: ScheduleConsoleRunRow[] = projectScopedProtocolRuns.map((run) => {
|
|
305
|
+
const sourceSchedule = typeof run.scheduleId === 'string' ? schedules[run.scheduleId] : null
|
|
306
|
+
return {
|
|
307
|
+
id: run.id,
|
|
308
|
+
kind: 'protocol',
|
|
309
|
+
status: mapProtocolStatus(run.status),
|
|
310
|
+
title: run.title,
|
|
311
|
+
preview: protocolRunPreview(run),
|
|
312
|
+
updatedAt: run.updatedAt || run.createdAt,
|
|
313
|
+
createdAt: run.createdAt,
|
|
314
|
+
agentId: run.facilitatorAgentId || run.participantAgentIds[0] || '',
|
|
315
|
+
scheduleId: typeof run.scheduleId === 'string' ? run.scheduleId : null,
|
|
316
|
+
scheduleName: sourceSchedule?.name || run.title || 'Structured session',
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
return [...normalizedTaskRuns, ...normalizedProtocolRuns]
|
|
320
|
+
.filter((entry) => {
|
|
321
|
+
if (runStatusFilter !== 'all' && entry.status !== runStatusFilter) return false
|
|
322
|
+
if (agentFilter !== 'all' && entry.agentId !== agentFilter) return false
|
|
209
323
|
if (cadenceFilter !== 'all') {
|
|
210
|
-
const sourceSchedule =
|
|
324
|
+
const sourceSchedule = entry.scheduleId ? schedules[entry.scheduleId] : null
|
|
211
325
|
if (!sourceSchedule || sourceSchedule.scheduleType !== cadenceFilter) return false
|
|
212
326
|
}
|
|
213
327
|
if (!q) return true
|
|
214
|
-
const agentName = agents[
|
|
215
|
-
const sourceSchedule = typeof task.sourceScheduleName === 'string' ? task.sourceScheduleName : ''
|
|
328
|
+
const agentName = agents[entry.agentId]?.name || ''
|
|
216
329
|
const haystack = [
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
task.result,
|
|
221
|
-
task.error,
|
|
330
|
+
entry.title,
|
|
331
|
+
entry.preview,
|
|
332
|
+
entry.scheduleName,
|
|
222
333
|
agentName,
|
|
223
334
|
].filter(Boolean).join(' ').toLowerCase()
|
|
224
335
|
return haystack.includes(q)
|
|
225
336
|
})
|
|
226
|
-
.sort((a, b) =>
|
|
227
|
-
}, [agentFilter, agents, cadenceFilter, projectScopedRuns, runStatusFilter, schedules, search])
|
|
337
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
338
|
+
}, [agentFilter, agents, cadenceFilter, projectScopedProtocolRuns, projectScopedRuns, runStatusFilter, schedules, search])
|
|
228
339
|
|
|
229
340
|
const handleArchive = async (scheduleId: string) => {
|
|
230
341
|
setBusyId(scheduleId)
|
|
@@ -275,7 +386,7 @@ export function ScheduleConsole() {
|
|
|
275
386
|
const result = await runSchedule(scheduleId)
|
|
276
387
|
if ('queued' in result && result.queued === false) toast.message('Schedule already has an in-flight run')
|
|
277
388
|
else toast.success('Schedule run queued')
|
|
278
|
-
await Promise.all([loadSchedules(), loadTasks()])
|
|
389
|
+
await Promise.all([loadSchedules(), loadTasks(), fetchProtocolRuns()])
|
|
279
390
|
} catch (err) {
|
|
280
391
|
toast.error(err instanceof Error ? err.message : 'Failed to run schedule')
|
|
281
392
|
} finally {
|
|
@@ -307,6 +418,10 @@ export function ScheduleConsole() {
|
|
|
307
418
|
setTaskSheetOpen(true)
|
|
308
419
|
}
|
|
309
420
|
|
|
421
|
+
const openProtocolRun = (runId: string) => {
|
|
422
|
+
router.push(`/protocols?runId=${encodeURIComponent(runId)}`)
|
|
423
|
+
}
|
|
424
|
+
|
|
310
425
|
const resetFilters = () => {
|
|
311
426
|
setSearch('')
|
|
312
427
|
setStatusFilter('all')
|
|
@@ -469,21 +584,22 @@ export function ScheduleConsole() {
|
|
|
469
584
|
<div className="divide-y divide-white/[0.05]">
|
|
470
585
|
{filteredRuns.length === 0 ? (
|
|
471
586
|
<div className="px-5 py-10 text-center text-text-3/60">No schedule runs match the current filters.</div>
|
|
472
|
-
) : filteredRuns.map((
|
|
473
|
-
const agent = agents[
|
|
474
|
-
const sourceSchedule =
|
|
587
|
+
) : filteredRuns.map((run) => {
|
|
588
|
+
const agent = run.agentId ? agents[run.agentId] : null
|
|
589
|
+
const sourceSchedule = run.scheduleId ? schedules[run.scheduleId] : null
|
|
475
590
|
return (
|
|
476
|
-
<div key={
|
|
591
|
+
<div key={`${run.kind}:${run.id}`} className="px-5 py-4 hover:bg-white/[0.02] transition-colors">
|
|
477
592
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
|
478
593
|
<div className="min-w-0">
|
|
479
594
|
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
|
480
|
-
<span className={`px-2 py-0.5 rounded-[8px] border text-[10px] font-700 uppercase tracking-[0.08em] ${badgeClass(
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
595
|
+
<span className={`px-2 py-0.5 rounded-[8px] border text-[10px] font-700 uppercase tracking-[0.08em] ${badgeClass(run.status)}`}>{run.status}</span>
|
|
596
|
+
<span className="text-[11px] text-text-3/60 uppercase tracking-[0.08em]">{run.scheduleName}</span>
|
|
597
|
+
<span className="text-[11px] text-text-3/40 uppercase tracking-[0.08em]">
|
|
598
|
+
{run.kind === 'protocol' ? 'Structured session' : 'Legacy task'}
|
|
599
|
+
</span>
|
|
484
600
|
</div>
|
|
485
|
-
<div className="text-[15px] font-600 text-text-2">{
|
|
486
|
-
<div className="text-[13px] text-text-3 mt-1 line-clamp-2">{
|
|
601
|
+
<div className="text-[15px] font-600 text-text-2">{run.title}</div>
|
|
602
|
+
<div className="text-[13px] text-text-3 mt-1 line-clamp-2">{run.preview}</div>
|
|
487
603
|
<div className="flex flex-wrap items-center gap-2 mt-3">
|
|
488
604
|
{agent && (
|
|
489
605
|
<div className="inline-flex items-center gap-2 rounded-[10px] bg-white/[0.03] px-2.5 py-1.5 text-[12px] text-text-2">
|
|
@@ -496,11 +612,13 @@ export function ScheduleConsole() {
|
|
|
496
612
|
<span>{agent.name}</span>
|
|
497
613
|
</div>
|
|
498
614
|
)}
|
|
499
|
-
<span className="text-[12px] text-text-3/60">Updated {timeAgo(
|
|
615
|
+
<span className="text-[12px] text-text-3/60">Updated {timeAgo(run.updatedAt, now)}</span>
|
|
500
616
|
</div>
|
|
501
617
|
</div>
|
|
502
618
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
|
503
|
-
<ActionButton onClick={() => openTask(
|
|
619
|
+
<ActionButton onClick={() => run.kind === 'protocol' ? openProtocolRun(run.id) : openTask(run.id)}>
|
|
620
|
+
{run.kind === 'protocol' ? 'Open Run' : 'Open Task'}
|
|
621
|
+
</ActionButton>
|
|
504
622
|
{sourceSchedule && sourceSchedule.status !== 'archived' && (
|
|
505
623
|
<ActionButton onClick={() => openSchedule(sourceSchedule.id)}>Open Schedule</ActionButton>
|
|
506
624
|
)}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildNewAgentSessionPayload,
|
|
6
|
+
getNewSessionButtonTitle,
|
|
7
|
+
hasResettableSessionRuntime,
|
|
8
|
+
sortSessionsNewestFirst,
|
|
9
|
+
summarizeFirstMessageAsTitle,
|
|
10
|
+
} from './new-session'
|
|
11
|
+
|
|
12
|
+
test('getNewSessionButtonTitle includes the Copilot CLI native reset hint', () => {
|
|
13
|
+
const title = getNewSessionButtonTitle({
|
|
14
|
+
provider: 'copilot-cli',
|
|
15
|
+
claudeSessionId: null,
|
|
16
|
+
codexThreadId: null,
|
|
17
|
+
opencodeSessionId: null,
|
|
18
|
+
opencodeWebSessionId: null,
|
|
19
|
+
geminiSessionId: null,
|
|
20
|
+
copilotSessionId: 'copilot-session-1',
|
|
21
|
+
droidSessionId: null,
|
|
22
|
+
cursorSessionId: null,
|
|
23
|
+
qwenSessionId: null,
|
|
24
|
+
acpSessionId: null,
|
|
25
|
+
delegateResumeIds: undefined,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
assert.match(title, /Copilot CLI/)
|
|
29
|
+
assert.match(title, /\/new/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('hasResettableSessionRuntime detects saved provider or delegate resume ids', () => {
|
|
33
|
+
assert.equal(hasResettableSessionRuntime({
|
|
34
|
+
provider: 'openai',
|
|
35
|
+
claudeSessionId: null,
|
|
36
|
+
codexThreadId: null,
|
|
37
|
+
opencodeSessionId: null,
|
|
38
|
+
opencodeWebSessionId: null,
|
|
39
|
+
geminiSessionId: null,
|
|
40
|
+
copilotSessionId: null,
|
|
41
|
+
droidSessionId: null,
|
|
42
|
+
cursorSessionId: null,
|
|
43
|
+
qwenSessionId: null,
|
|
44
|
+
acpSessionId: null,
|
|
45
|
+
delegateResumeIds: { codex: 'resume-1' },
|
|
46
|
+
}), true)
|
|
47
|
+
|
|
48
|
+
assert.equal(hasResettableSessionRuntime({
|
|
49
|
+
provider: 'openai',
|
|
50
|
+
claudeSessionId: null,
|
|
51
|
+
codexThreadId: null,
|
|
52
|
+
opencodeSessionId: null,
|
|
53
|
+
opencodeWebSessionId: null,
|
|
54
|
+
geminiSessionId: null,
|
|
55
|
+
copilotSessionId: null,
|
|
56
|
+
droidSessionId: null,
|
|
57
|
+
cursorSessionId: null,
|
|
58
|
+
qwenSessionId: null,
|
|
59
|
+
acpSessionId: null,
|
|
60
|
+
delegateResumeIds: undefined,
|
|
61
|
+
}), false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('buildNewAgentSessionPayload clones the current agent chat settings for a fresh session', () => {
|
|
65
|
+
const payload = buildNewAgentSessionPayload({
|
|
66
|
+
id: 'sess-current',
|
|
67
|
+
name: 'Slackado',
|
|
68
|
+
cwd: '/workspace',
|
|
69
|
+
user: 'bnikolov',
|
|
70
|
+
provider: 'copilot-cli',
|
|
71
|
+
model: 'gpt-5.4-mini',
|
|
72
|
+
ollamaMode: null,
|
|
73
|
+
credentialId: null,
|
|
74
|
+
fallbackCredentialIds: ['cred-a'],
|
|
75
|
+
apiEndpoint: null,
|
|
76
|
+
routePreferredGatewayTags: ['primary'],
|
|
77
|
+
routePreferredGatewayUseCase: 'chat',
|
|
78
|
+
sessionType: 'human',
|
|
79
|
+
agentId: 'agent-1',
|
|
80
|
+
tools: ['shell'],
|
|
81
|
+
extensions: ['ext-a'],
|
|
82
|
+
heartbeatEnabled: null,
|
|
83
|
+
heartbeatIntervalSec: null,
|
|
84
|
+
sessionResetMode: 'idle',
|
|
85
|
+
sessionIdleTimeoutSec: 900,
|
|
86
|
+
sessionMaxAgeSec: null,
|
|
87
|
+
sessionDailyResetAt: null,
|
|
88
|
+
sessionResetTimezone: null,
|
|
89
|
+
thinkingLevel: 'medium',
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
assert.equal(payload.parentSessionId, 'sess-current')
|
|
93
|
+
assert.equal(payload.agentId, 'agent-1')
|
|
94
|
+
assert.equal(payload.provider, 'copilot-cli')
|
|
95
|
+
assert.deepEqual(payload.tools, ['shell'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('sortSessionsNewestFirst puts the latest active session first', () => {
|
|
99
|
+
const ordered = sortSessionsNewestFirst([
|
|
100
|
+
{ id: 'older', createdAt: 100, lastActiveAt: 150 },
|
|
101
|
+
{ id: 'newest', createdAt: 200, lastActiveAt: 500 },
|
|
102
|
+
{ id: 'middle', createdAt: 300, lastActiveAt: 320 },
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(ordered.map((session) => session.id), ['newest', 'middle', 'older'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('summarizeFirstMessageAsTitle turns the opening prompt into a compact session title', () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
summarizeFirstMessageAsTitle('Review the latest CI failures for the dashboard and tell me what broke first.'),
|
|
111
|
+
'Review the latest CI failures for the dashboard',
|
|
112
|
+
)
|
|
113
|
+
assert.equal(summarizeFirstMessageAsTitle(' '), 'New Chat')
|
|
114
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ProviderId, Session } from '@/types'
|
|
2
|
+
|
|
3
|
+
type SessionResetSnapshot = Pick<
|
|
4
|
+
Session,
|
|
5
|
+
| 'provider'
|
|
6
|
+
| 'claudeSessionId'
|
|
7
|
+
| 'codexThreadId'
|
|
8
|
+
| 'opencodeSessionId'
|
|
9
|
+
| 'opencodeWebSessionId'
|
|
10
|
+
| 'geminiSessionId'
|
|
11
|
+
| 'copilotSessionId'
|
|
12
|
+
| 'droidSessionId'
|
|
13
|
+
| 'cursorSessionId'
|
|
14
|
+
| 'qwenSessionId'
|
|
15
|
+
| 'acpSessionId'
|
|
16
|
+
| 'delegateResumeIds'
|
|
17
|
+
>
|
|
18
|
+
|
|
19
|
+
type AgentSessionCloneSource = Pick<
|
|
20
|
+
Session,
|
|
21
|
+
| 'id'
|
|
22
|
+
| 'name'
|
|
23
|
+
| 'cwd'
|
|
24
|
+
| 'user'
|
|
25
|
+
| 'provider'
|
|
26
|
+
| 'model'
|
|
27
|
+
| 'ollamaMode'
|
|
28
|
+
| 'credentialId'
|
|
29
|
+
| 'fallbackCredentialIds'
|
|
30
|
+
| 'apiEndpoint'
|
|
31
|
+
| 'routePreferredGatewayTags'
|
|
32
|
+
| 'routePreferredGatewayUseCase'
|
|
33
|
+
| 'sessionType'
|
|
34
|
+
| 'agentId'
|
|
35
|
+
| 'tools'
|
|
36
|
+
| 'extensions'
|
|
37
|
+
| 'heartbeatEnabled'
|
|
38
|
+
| 'heartbeatIntervalSec'
|
|
39
|
+
| 'sessionResetMode'
|
|
40
|
+
| 'sessionIdleTimeoutSec'
|
|
41
|
+
| 'sessionMaxAgeSec'
|
|
42
|
+
| 'sessionDailyResetAt'
|
|
43
|
+
| 'sessionResetTimezone'
|
|
44
|
+
| 'thinkingLevel'
|
|
45
|
+
>
|
|
46
|
+
|
|
47
|
+
const PROVIDER_RESET_HINTS: Partial<Record<ProviderId, { label: string; equivalentCommand?: string }>> = {
|
|
48
|
+
'copilot-cli': { label: 'Copilot CLI', equivalentCommand: '/new' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CLI_PROVIDER_IDS = new Set<ProviderId>([
|
|
52
|
+
'claude-cli',
|
|
53
|
+
'codex-cli',
|
|
54
|
+
'opencode-cli',
|
|
55
|
+
'gemini-cli',
|
|
56
|
+
'copilot-cli',
|
|
57
|
+
'droid-cli',
|
|
58
|
+
'cursor-cli',
|
|
59
|
+
'qwen-code-cli',
|
|
60
|
+
'goose',
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
export function hasResettableSessionRuntime(session: SessionResetSnapshot): boolean {
|
|
64
|
+
return Boolean(
|
|
65
|
+
session.claudeSessionId
|
|
66
|
+
|| session.codexThreadId
|
|
67
|
+
|| session.opencodeSessionId
|
|
68
|
+
|| session.opencodeWebSessionId
|
|
69
|
+
|| session.geminiSessionId
|
|
70
|
+
|| session.copilotSessionId
|
|
71
|
+
|| session.droidSessionId
|
|
72
|
+
|| session.cursorSessionId
|
|
73
|
+
|| session.qwenSessionId
|
|
74
|
+
|| session.acpSessionId
|
|
75
|
+
|| session.delegateResumeIds?.claudeCode
|
|
76
|
+
|| session.delegateResumeIds?.codex
|
|
77
|
+
|| session.delegateResumeIds?.opencode
|
|
78
|
+
|| session.delegateResumeIds?.gemini
|
|
79
|
+
|| session.delegateResumeIds?.copilot
|
|
80
|
+
|| session.delegateResumeIds?.droid
|
|
81
|
+
|| session.delegateResumeIds?.cursor
|
|
82
|
+
|| session.delegateResumeIds?.qwen
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getNewSessionButtonTitle(session: SessionResetSnapshot): string {
|
|
87
|
+
const providerHint = PROVIDER_RESET_HINTS[session.provider]
|
|
88
|
+
if (providerHint?.equivalentCommand) {
|
|
89
|
+
return `Create a brand-new chat session. For ${providerHint.label}, this starts fresh instead of reusing the saved thread (equivalent to ${providerHint.equivalentCommand}).`
|
|
90
|
+
}
|
|
91
|
+
if (CLI_PROVIDER_IDS.has(session.provider)) {
|
|
92
|
+
return 'Create a brand-new chat session without reusing the saved CLI thread.'
|
|
93
|
+
}
|
|
94
|
+
return 'Create a brand-new chat session and keep the current conversation intact.'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildNewAgentSessionPayload(session: AgentSessionCloneSource): Record<string, unknown> {
|
|
98
|
+
return {
|
|
99
|
+
name: session.name,
|
|
100
|
+
cwd: session.cwd,
|
|
101
|
+
user: session.user,
|
|
102
|
+
provider: session.provider,
|
|
103
|
+
model: session.model,
|
|
104
|
+
ollamaMode: session.ollamaMode ?? null,
|
|
105
|
+
credentialId: session.credentialId ?? null,
|
|
106
|
+
fallbackCredentialIds: session.fallbackCredentialIds ?? [],
|
|
107
|
+
apiEndpoint: session.apiEndpoint ?? null,
|
|
108
|
+
routePreferredGatewayTags: session.routePreferredGatewayTags ?? [],
|
|
109
|
+
routePreferredGatewayUseCase: session.routePreferredGatewayUseCase ?? null,
|
|
110
|
+
sessionType: session.sessionType ?? 'human',
|
|
111
|
+
agentId: session.agentId ?? null,
|
|
112
|
+
parentSessionId: session.id,
|
|
113
|
+
tools: session.tools ?? [],
|
|
114
|
+
extensions: session.extensions ?? [],
|
|
115
|
+
heartbeatEnabled: session.heartbeatEnabled ?? null,
|
|
116
|
+
heartbeatIntervalSec: session.heartbeatIntervalSec ?? null,
|
|
117
|
+
sessionResetMode: session.sessionResetMode ?? null,
|
|
118
|
+
sessionIdleTimeoutSec: session.sessionIdleTimeoutSec ?? null,
|
|
119
|
+
sessionMaxAgeSec: session.sessionMaxAgeSec ?? null,
|
|
120
|
+
sessionDailyResetAt: session.sessionDailyResetAt ?? null,
|
|
121
|
+
sessionResetTimezone: session.sessionResetTimezone ?? null,
|
|
122
|
+
thinkingLevel: session.thinkingLevel ?? null,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function sortSessionsNewestFirst<T extends Pick<Session, 'createdAt' | 'lastActiveAt'>>(sessions: T[]): T[] {
|
|
127
|
+
return [...sessions].sort((left, right) => {
|
|
128
|
+
const leftTime = left.lastActiveAt || left.createdAt || 0
|
|
129
|
+
const rightTime = right.lastActiveAt || right.createdAt || 0
|
|
130
|
+
return rightTime - leftTime
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function summarizeFirstMessageAsTitle(text: string, fallback = 'New Chat'): string {
|
|
135
|
+
const cleaned = text
|
|
136
|
+
.replace(/\s+/g, ' ')
|
|
137
|
+
.replace(/[`*_#>\[\]]/g, '')
|
|
138
|
+
.trim()
|
|
139
|
+
if (!cleaned) return fallback
|
|
140
|
+
const sentenceMatch = cleaned.match(/^(.+?[.!?])(?:\s|$)/)
|
|
141
|
+
const sentence = (sentenceMatch?.[1] || cleaned).trim()
|
|
142
|
+
const words = sentence.split(' ').filter(Boolean).slice(0, 8)
|
|
143
|
+
const shortened = words.join(' ').trim()
|
|
144
|
+
if (!shortened) return fallback
|
|
145
|
+
return shortened.length > 60 ? `${shortened.slice(0, 57).trimEnd()}...` : shortened
|
|
146
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { after, before, describe, it } from 'node:test'
|
|
6
|
+
|
|
7
|
+
const originalEnv = {
|
|
8
|
+
DATA_DIR: process.env.DATA_DIR,
|
|
9
|
+
WORKSPACE_DIR: process.env.WORKSPACE_DIR,
|
|
10
|
+
SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
|
|
11
|
+
SWARMCLAW_DAEMON_AUTOSTART: process.env.SWARMCLAW_DAEMON_AUTOSTART,
|
|
12
|
+
SWARMCLAW_DAEMON_BACKGROUND_SERVICES: process.env.SWARMCLAW_DAEMON_BACKGROUND_SERVICES,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let tempDir = ''
|
|
16
|
+
let daemonState: typeof import('@/lib/server/runtime/daemon-state')
|
|
17
|
+
let controller: typeof import('@/lib/server/daemon/controller')
|
|
18
|
+
let adminMetadata: typeof import('@/lib/server/daemon/admin-metadata')
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-daemon-controller-'))
|
|
22
|
+
process.env.DATA_DIR = path.join(tempDir, 'data')
|
|
23
|
+
process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
|
|
24
|
+
process.env.SWARMCLAW_BUILD_MODE = '1'
|
|
25
|
+
process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
|
|
26
|
+
|
|
27
|
+
daemonState = await import('@/lib/server/runtime/daemon-state')
|
|
28
|
+
controller = await import('@/lib/server/daemon/controller')
|
|
29
|
+
adminMetadata = await import('@/lib/server/daemon/admin-metadata')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
after(async () => {
|
|
33
|
+
try { await daemonState.stopDaemon({ source: 'test-cleanup' }) } catch { /* ignore */ }
|
|
34
|
+
adminMetadata.clearDaemonAdminMetadata()
|
|
35
|
+
for (const [key, val] of Object.entries(originalEnv)) {
|
|
36
|
+
if (val === undefined) delete process.env[key]
|
|
37
|
+
else process.env[key] = val
|
|
38
|
+
}
|
|
39
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('daemon controller in-process mode', () => {
|
|
43
|
+
it('reports in-process daemon as running', async () => {
|
|
44
|
+
daemonState.startDaemon({ source: 'test', manualStart: true })
|
|
45
|
+
try {
|
|
46
|
+
const status = await controller.getDaemonStatusSnapshot()
|
|
47
|
+
const health = await controller.getDaemonHealthSummarySnapshot()
|
|
48
|
+
assert.equal(status.running, true)
|
|
49
|
+
assert.equal(status.schedulerActive, true)
|
|
50
|
+
assert.equal(health.components.daemon.status, 'healthy')
|
|
51
|
+
} finally {
|
|
52
|
+
await daemonState.stopDaemon({ source: 'test-cleanup' })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('starts daemon in-process when manually requested', async () => {
|
|
57
|
+
await daemonState.stopDaemon({ source: 'test-prep' })
|
|
58
|
+
adminMetadata.clearDaemonAdminMetadata()
|
|
59
|
+
|
|
60
|
+
const started = await controller.ensureDaemonProcessRunning('test-start', { manualStart: true })
|
|
61
|
+
try {
|
|
62
|
+
assert.equal(started, true)
|
|
63
|
+
assert.equal(daemonState.getDaemonStatus().running, true)
|
|
64
|
+
assert.equal(adminMetadata.readDaemonAdminMetadata(), null)
|
|
65
|
+
} finally {
|
|
66
|
+
await daemonState.stopDaemon({ source: 'test-cleanup' })
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('stops in-process daemon via controller without subprocess metadata', async () => {
|
|
71
|
+
daemonState.startDaemon({ source: 'test-stop', manualStart: true })
|
|
72
|
+
adminMetadata.clearDaemonAdminMetadata()
|
|
73
|
+
|
|
74
|
+
const stopped = await controller.stopDaemonProcess({ source: 'test-stop', manualStop: true })
|
|
75
|
+
assert.equal(stopped, true)
|
|
76
|
+
assert.equal(daemonState.getDaemonStatus().running, false)
|
|
77
|
+
})
|
|
78
|
+
})
|