@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.
@@ -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
- return projectScopedRuns
206
- .filter((task) => {
207
- if (runStatusFilter !== 'all' && task.status !== runStatusFilter) return false
208
- if (agentFilter !== 'all' && task.agentId !== agentFilter) return false
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 = typeof task.sourceScheduleId === 'string' ? schedules[task.sourceScheduleId] : null
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[task.agentId]?.name || ''
215
- const sourceSchedule = typeof task.sourceScheduleName === 'string' ? task.sourceScheduleName : ''
328
+ const agentName = agents[entry.agentId]?.name || ''
216
329
  const haystack = [
217
- task.title,
218
- task.description,
219
- sourceSchedule,
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) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt))
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((task) => {
473
- const agent = agents[task.agentId]
474
- const sourceSchedule = typeof task.sourceScheduleId === 'string' ? schedules[task.sourceScheduleId] : null
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={task.id} className="px-5 py-4 hover:bg-white/[0.02] transition-colors">
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(task.status)}`}>{task.status}</span>
481
- {sourceSchedule && (
482
- <span className="text-[11px] text-text-3/60 uppercase tracking-[0.08em]">{sourceSchedule.name}</span>
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">{task.title}</div>
486
- <div className="text-[13px] text-text-3 mt-1 line-clamp-2">{runPreview(task)}</div>
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(task.updatedAt, now)}</span>
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(task.id)}>Open Task</ActionButton>
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
+ })