@swarmclawai/swarmclaw 0.9.4 → 0.9.5

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 CHANGED
@@ -729,15 +729,15 @@ On `v*` tags, GitHub Actions will:
729
729
  2. Create a GitHub Release
730
730
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
731
731
 
732
- #### v0.9.4 Release Readiness Notes
732
+ #### v0.9.5 Release Readiness Notes
733
733
 
734
- Before shipping `v0.9.4`, confirm the following user-facing changes are reflected in docs:
734
+ Before shipping `v0.9.5`, confirm the following user-facing changes are reflected in docs:
735
735
 
736
- 1. Skills docs explain that local skills are discoverable by default, while `skillIds` now mean pinned always-on skills for an agent.
737
- 2. Runtime-skill docs mention executable skill metadata, on-demand selection, and the `use_skill` / `manage_skills` flow instead of implying every discovered skill is inlined into the prompt.
738
- 3. Connector/heartbeat docs mention that routable connector state is kept on direct connector sessions only, sender quiet-boundary memories are enforced before reply generation, and tool-only heartbeats no longer pollute visible main-thread history.
739
- 4. Site and README install/version strings are updated to `v0.9.4`, including pinned install snippets, release notes index text, and sidebar/footer labels.
740
- 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.4`) versus the git tag (`v0.9.4`).
736
+ 1. Plugin/runtime docs mention the new typed lifecycle hooks: `beforePromptBuild`, `beforeToolCall`, `beforeModelResolve`, `llmInput`, `llmOutput`, `toolResultPersist`, `beforeMessageWrite`, plus session and subagent lifecycle hooks.
737
+ 2. Connector/memory docs explain that quiet-boundary memories are matched by identifiers and agent aliases, with explicit boundary metadata support, rather than relying on hardcoded person-name fallbacks.
738
+ 3. Skills docs still explain that local skills are discoverable by default, pinned skills stay always-on, and `use_skill` is the runtime path for selection/loading/dispatch.
739
+ 4. Site and README install/version strings are updated to `v0.9.5`, including release notes index text and any pinned install snippets.
740
+ 5. The release tag, npm package version, and generated GitHub release install snippet all agree on the non-prefixed npm version (`0.9.5`) versus the git tag (`v0.9.5`).
741
741
 
742
742
  ## CLI
743
743
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -15,6 +15,10 @@ let runtime: typeof import('@/lib/server/agents/subagent-runtime')
15
15
  let lineage: typeof import('@/lib/server/agents/subagent-lineage')
16
16
  let delegationJobs: typeof import('@/lib/server/agents/delegation-jobs')
17
17
  let storage: typeof import('@/lib/server/storage')
18
+ let pluginManager: {
19
+ registerBuiltin: (id: string, plugin: Record<string, unknown>) => void
20
+ }
21
+ let providers: Record<string, unknown>
18
22
 
19
23
  before(async () => {
20
24
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-subagent-runtime-'))
@@ -26,6 +30,9 @@ before(async () => {
26
30
  delegationJobs = await import('@/lib/server/agents/delegation-jobs')
27
31
  lineage = await import('@/lib/server/agents/subagent-lineage')
28
32
  runtime = await import('@/lib/server/agents/subagent-runtime')
33
+ pluginManager = (await import('@/lib/server/plugins')).getPluginManager()
34
+ const providersMod = await import('@/lib/providers/index')
35
+ providers = providersMod.PROVIDERS
29
36
  })
30
37
 
31
38
  after(() => {
@@ -57,9 +64,9 @@ describe('subagent-runtime', () => {
57
64
  })
58
65
 
59
66
  describe('spawnSubagent', () => {
60
- it('throws for unknown agent', () => {
67
+ it('throws for unknown agent', async () => {
61
68
  lineage._clearLineage()
62
- assert.throws(
69
+ await assert.rejects(
63
70
  () => runtime.spawnSubagent(
64
71
  { agentId: 'nonexistent', message: 'hello' },
65
72
  { cwd: tempDir },
@@ -68,7 +75,7 @@ describe('subagent-runtime', () => {
68
75
  )
69
76
  })
70
77
 
71
- it('throws when max depth is exceeded', () => {
78
+ it('throws when max depth is exceeded', async () => {
72
79
  lineage._clearLineage()
73
80
  seedAgent('depth-agent', 'Depth Agent')
74
81
 
@@ -80,7 +87,7 @@ describe('subagent-runtime', () => {
80
87
  sessions['depth-s3'] = { id: 'depth-s3', parentSessionId: 'depth-s2', cwd: tempDir }
81
88
  storage.saveSessions(sessions)
82
89
 
83
- assert.throws(
90
+ await assert.rejects(
84
91
  () => runtime.spawnSubagent(
85
92
  { agentId: 'depth-agent', message: 'too deep' },
86
93
  { sessionId: 'depth-s3', cwd: tempDir },
@@ -89,13 +96,13 @@ describe('subagent-runtime', () => {
89
96
  )
90
97
  })
91
98
 
92
- it('creates session, lineage node, and delegation job', () => {
99
+ it('creates session, lineage node, and delegation job', async () => {
93
100
  lineage._clearLineage()
94
101
  seedAgent('spawn-agent', 'Spawn Agent')
95
102
 
96
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
103
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
97
104
  try {
98
- handle = runtime.spawnSubagent(
105
+ handle = await runtime.spawnSubagent(
99
106
  { agentId: 'spawn-agent', message: 'test task', waitForCompletion: false },
100
107
  { sessionId: undefined, cwd: tempDir },
101
108
  )
@@ -132,7 +139,7 @@ describe('subagent-runtime', () => {
132
139
  }
133
140
  })
134
141
 
135
- it('tracks parent-child lineage correctly', () => {
142
+ it('tracks parent-child lineage correctly', async () => {
136
143
  lineage._clearLineage()
137
144
  seedAgent('parent-agent', 'Parent')
138
145
  seedAgent('child-agent', 'Child')
@@ -155,9 +162,9 @@ describe('subagent-runtime', () => {
155
162
  task: 'Parent task',
156
163
  })
157
164
 
158
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
165
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
159
166
  try {
160
- handle = runtime.spawnSubagent(
167
+ handle = await runtime.spawnSubagent(
161
168
  { agentId: 'child-agent', message: 'child task', waitForCompletion: false },
162
169
  { sessionId: 'parent-session', cwd: tempDir },
163
170
  )
@@ -184,6 +191,82 @@ describe('subagent-runtime', () => {
184
191
  assert.equal(ancestors[0].sessionId, 'parent-session')
185
192
  }
186
193
  })
194
+
195
+ it('fires subagent lifecycle hooks through the native runtime', async () => {
196
+ lineage._clearLineage()
197
+ seedAgent('parent-hook-agent', 'Parent Hook Agent', ['subagent_lifecycle_test'])
198
+ seedAgent('child-hook-agent', 'Child Hook Agent')
199
+
200
+ const marks: string[] = []
201
+ pluginManager.registerBuiltin('subagent_lifecycle_test', {
202
+ name: 'Subagent Lifecycle Test',
203
+ hooks: {
204
+ subagentSpawning: ({ agentId }) => {
205
+ marks.push(`spawning:${agentId}`)
206
+ return { status: 'ok' }
207
+ },
208
+ subagentSpawned: ({ childSessionId }) => {
209
+ marks.push(`spawned:${childSessionId}`)
210
+ },
211
+ subagentEnded: ({ status }) => {
212
+ marks.push(`ended:${status}`)
213
+ },
214
+ sessionEnd: ({ reason }) => {
215
+ marks.push(`session_end:${reason}`)
216
+ },
217
+ },
218
+ })
219
+
220
+ providers['subagent-test-provider'] = {
221
+ id: 'subagent-test-provider',
222
+ name: 'Subagent Test Provider',
223
+ models: ['unit'],
224
+ requiresApiKey: false,
225
+ requiresEndpoint: false,
226
+ handler: {
227
+ async streamChat() {
228
+ return 'subagent finished'
229
+ },
230
+ },
231
+ }
232
+
233
+ const agents = storage.loadAgents()
234
+ agents['child-hook-agent'] = {
235
+ id: 'child-hook-agent',
236
+ name: 'Child Hook Agent',
237
+ provider: 'subagent-test-provider',
238
+ model: 'unit',
239
+ systemPrompt: 'Child runtime test',
240
+ }
241
+ storage.saveAgents(agents)
242
+
243
+ const sessions = storage.loadSessions()
244
+ sessions['hook-parent-session'] = {
245
+ id: 'hook-parent-session',
246
+ cwd: tempDir,
247
+ parentSessionId: null,
248
+ agentId: 'parent-hook-agent',
249
+ provider: 'subagent-test-provider',
250
+ model: 'unit',
251
+ plugins: ['subagent_lifecycle_test'],
252
+ messages: [],
253
+ createdAt: Date.now(),
254
+ lastActiveAt: Date.now(),
255
+ }
256
+ storage.saveSessions(sessions)
257
+
258
+ const handle = await runtime.spawnSubagent(
259
+ { agentId: 'child-hook-agent', message: 'finish the task', waitForCompletion: false },
260
+ { sessionId: 'hook-parent-session', cwd: tempDir },
261
+ )
262
+ const result = await handle.promise
263
+
264
+ assert.equal(result.status, 'completed')
265
+ assert.equal(marks.includes('spawning:child-hook-agent'), true)
266
+ assert.equal(marks.some((mark) => mark.startsWith('spawned:')), true)
267
+ assert.equal(marks.includes('ended:completed'), true)
268
+ assert.equal(marks.includes('session_end:completed'), true)
269
+ })
187
270
  })
188
271
 
189
272
  describe('mergePlugins', () => {
@@ -230,7 +313,7 @@ describe('subagent-runtime', () => {
230
313
  })
231
314
 
232
315
  describe('plugin inheritance in spawnSubagent', () => {
233
- it('child session inherits parent plugins merged with agent plugins', () => {
316
+ it('child session inherits parent plugins merged with agent plugins', async () => {
234
317
  lineage._clearLineage()
235
318
  seedAgent('inherit-agent', 'Inherit Agent', ['shell', 'memory'])
236
319
 
@@ -244,9 +327,9 @@ describe('subagent-runtime', () => {
244
327
  }
245
328
  storage.saveSessions(sessions)
246
329
 
247
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
330
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
248
331
  try {
249
- handle = runtime.spawnSubagent(
332
+ handle = await runtime.spawnSubagent(
250
333
  { agentId: 'inherit-agent', message: 'test inheritance', waitForCompletion: false },
251
334
  { sessionId: 'inherit-parent', cwd: tempDir },
252
335
  )
@@ -265,7 +348,7 @@ describe('subagent-runtime', () => {
265
348
  }
266
349
  })
267
350
 
268
- it('child session does not inherit when inheritPlugins is false', () => {
351
+ it('child session does not inherit when inheritPlugins is false', async () => {
269
352
  lineage._clearLineage()
270
353
  seedAgent('no-inherit-agent', 'No Inherit Agent', ['shell'])
271
354
 
@@ -278,9 +361,9 @@ describe('subagent-runtime', () => {
278
361
  }
279
362
  storage.saveSessions(sessions)
280
363
 
281
- let handle: ReturnType<typeof runtime.spawnSubagent> | null = null
364
+ let handle: Awaited<ReturnType<typeof runtime.spawnSubagent>> | null = null
282
365
  try {
283
- handle = runtime.spawnSubagent(
366
+ handle = await runtime.spawnSubagent(
284
367
  { agentId: 'no-inherit-agent', message: 'no inherit', inheritPlugins: false, waitForCompletion: false },
285
368
  { sessionId: 'no-inherit-parent', cwd: tempDir },
286
369
  )
@@ -13,6 +13,7 @@ import { enqueueSessionRun, type EnqueueSessionRunResult } from '@/lib/server/ru
13
13
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
14
14
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
15
15
  import { resolveSubagentBrowserProfileId } from '@/lib/server/session-tools/subagent'
16
+ import { getPluginManager } from '@/lib/server/plugins'
16
17
  import {
17
18
  appendDelegationCheckpoint,
18
19
  completeDelegationJob,
@@ -179,7 +180,14 @@ export function getSessionDepth(
179
180
  export function spawnSubagent(
180
181
  input: SpawnSubagentInput,
181
182
  context: SubagentContext,
182
- ): SubagentHandle {
183
+ ): Promise<SubagentHandle> {
184
+ return spawnSubagentImpl(input, context)
185
+ }
186
+
187
+ async function spawnSubagentImpl(
188
+ input: SpawnSubagentInput,
189
+ context: SubagentContext,
190
+ ): Promise<SubagentHandle> {
183
191
  const runtime = loadRuntimeSettings()
184
192
  const maxDepth = runtime.delegationMaxDepth || DEFAULT_DELEGATION_MAX_DEPTH
185
193
  const agents = loadAgents()
@@ -195,6 +203,28 @@ export function spawnSubagent(
195
203
  if (depth >= maxDepth) {
196
204
  throw new Error(`Max subagent depth (${maxDepth}) reached.`)
197
205
  }
206
+ const parent = context.sessionId ? sessions[context.sessionId] : null
207
+ const parentPlugins = (
208
+ Array.isArray(parent?.plugins) ? parent.plugins
209
+ : Array.isArray(parent?.tools) ? parent.tools
210
+ : []
211
+ ) as string[]
212
+ const pluginManager = getPluginManager()
213
+ const spawningResult = await pluginManager.runSubagentSpawning(
214
+ {
215
+ parentSessionId: context.sessionId || null,
216
+ agentId: input.agentId,
217
+ agentName: agent.name,
218
+ message: input.message,
219
+ cwd: input.cwd || context.cwd,
220
+ mode: 'run',
221
+ threadRequested: false,
222
+ },
223
+ { enabledIds: parentPlugins },
224
+ )
225
+ if (spawningResult.status === 'error') {
226
+ throw new Error(spawningResult.error || 'Subagent spawn blocked by plugin hook')
227
+ }
198
228
 
199
229
  // 1. Create delegation job
200
230
  const job = createDelegationJob({
@@ -209,7 +239,6 @@ export function spawnSubagent(
209
239
  // 2. Create child session
210
240
  const sid = genId()
211
241
  const now = Date.now()
212
- const parent = context.sessionId ? sessions[context.sessionId] : null
213
242
  const browserProfileId = resolveSubagentBrowserProfileId(
214
243
  parent,
215
244
  sid,
@@ -276,6 +305,19 @@ export function spawnSubagent(
276
305
  mode: 'followup',
277
306
  executionGroupKey: input.executionGroupKey,
278
307
  })
308
+ await pluginManager.runHook(
309
+ 'subagentSpawned',
310
+ {
311
+ parentSessionId: context.sessionId || null,
312
+ childSessionId: sid,
313
+ agentId: agent.id,
314
+ agentName: agent.name,
315
+ runId: run.runId,
316
+ mode: 'run',
317
+ threadRequested: false,
318
+ },
319
+ { enabledIds: parentPlugins },
320
+ )
279
321
 
280
322
  // 8. Register runtime handle for cancellation
281
323
  registerDelegationRuntime(job.id, {
@@ -290,33 +332,87 @@ export function spawnSubagent(
290
332
 
291
333
  // 9. Build result promise
292
334
  const resultPromise = run.promise
293
- .then((result): SubagentResult => {
335
+ .then(async (result): Promise<SubagentResult> => {
294
336
  const latest = getDelegationJob(job.id)
295
337
  const node = getLineageNode(lineageNode.id)
338
+ let subagentResult: SubagentResult
296
339
  if (latest?.status === 'cancelled' || node?.status === 'cancelled') {
297
- return buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
340
+ subagentResult = buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
341
+ } else {
342
+ const responseText = (result.text || '').slice(0, 32_000)
343
+ completeLineageNode(lineageNode.id, responseText.slice(0, 1000))
344
+ appendDelegationCheckpoint(job.id, 'Child session completed', 'completed')
345
+ completeDelegationJob(job.id, responseText, { childSessionId: sid })
346
+
347
+ subagentResult = buildResult(job.id, sid, lineageNode, agent, 'completed', responseText, null)
298
348
  }
299
-
300
- const responseText = (result.text || '').slice(0, 32_000)
301
- completeLineageNode(lineageNode.id, responseText.slice(0, 1000))
302
- appendDelegationCheckpoint(job.id, 'Child session completed', 'completed')
303
- completeDelegationJob(job.id, responseText, { childSessionId: sid })
304
-
305
- return buildResult(job.id, sid, lineageNode, agent, 'completed', responseText, null)
349
+ await pluginManager.runHook(
350
+ 'subagentEnded',
351
+ {
352
+ parentSessionId: context.sessionId || null,
353
+ childSessionId: sid,
354
+ agentId: agent.id,
355
+ agentName: agent.name,
356
+ status: subagentResult.status,
357
+ response: subagentResult.response,
358
+ error: subagentResult.error,
359
+ durationMs: subagentResult.durationMs,
360
+ },
361
+ { enabledIds: parentPlugins },
362
+ )
363
+ await pluginManager.runHook(
364
+ 'sessionEnd',
365
+ {
366
+ sessionId: sid,
367
+ session: loadSessions()[sid] || null,
368
+ messageCount: Array.isArray(loadSessions()[sid]?.messages) ? loadSessions()[sid].messages.length : 0,
369
+ durationMs: subagentResult.durationMs,
370
+ reason: subagentResult.status,
371
+ },
372
+ { enabledIds: parentPlugins },
373
+ )
374
+ return subagentResult
306
375
  })
307
- .catch((err: unknown): SubagentResult => {
376
+ .catch(async (err: unknown): Promise<SubagentResult> => {
308
377
  const message = errorMessage(err)
309
378
  const latest = getDelegationJob(job.id)
310
379
  const node = getLineageNode(lineageNode.id)
380
+ let subagentResult: SubagentResult
311
381
  if (latest?.status === 'cancelled' || node?.status === 'cancelled') {
312
- return buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
313
- }
382
+ subagentResult = buildResult(job.id, sid, lineageNode, agent, 'cancelled', null, null)
383
+ } else {
384
+ failLineageNode(lineageNode.id, message)
385
+ appendDelegationCheckpoint(job.id, `Child session failed: ${message}`, 'failed')
386
+ failDelegationJob(job.id, message, { childSessionId: sid })
314
387
 
315
- failLineageNode(lineageNode.id, message)
316
- appendDelegationCheckpoint(job.id, `Child session failed: ${message}`, 'failed')
317
- failDelegationJob(job.id, message, { childSessionId: sid })
318
-
319
- return buildResult(job.id, sid, lineageNode, agent, 'failed', null, message)
388
+ subagentResult = buildResult(job.id, sid, lineageNode, agent, 'failed', null, message)
389
+ }
390
+ await pluginManager.runHook(
391
+ 'subagentEnded',
392
+ {
393
+ parentSessionId: context.sessionId || null,
394
+ childSessionId: sid,
395
+ agentId: agent.id,
396
+ agentName: agent.name,
397
+ status: subagentResult.status,
398
+ response: subagentResult.response,
399
+ error: subagentResult.error,
400
+ durationMs: subagentResult.durationMs,
401
+ },
402
+ { enabledIds: parentPlugins },
403
+ )
404
+ await pluginManager.runHook(
405
+ 'sessionEnd',
406
+ {
407
+ sessionId: sid,
408
+ session: loadSessions()[sid] || null,
409
+ messageCount: Array.isArray(loadSessions()[sid]?.messages) ? loadSessions()[sid].messages.length : 0,
410
+ durationMs: subagentResult.durationMs,
411
+ reason: subagentResult.status,
412
+ },
413
+ { enabledIds: parentPlugins },
414
+ )
415
+ return subagentResult
320
416
  })
321
417
 
322
418
  const handle: SubagentHandle = {
@@ -169,10 +169,10 @@ function notifySwarmChanged() {
169
169
  *
170
170
  * const aggregate = await swarm.allSettled
171
171
  */
172
- export function spawnSwarm(
172
+ export async function spawnSwarm(
173
173
  input: BatchSpawnInput,
174
174
  context: SubagentContext,
175
- ): SwarmHandle {
175
+ ): Promise<SwarmHandle> {
176
176
  const swarmId = genId(10)
177
177
  const createdAt = Date.now()
178
178
  const members: SwarmMember[] = []
@@ -190,7 +190,7 @@ export function spawnSwarm(
190
190
  for (let i = 0; i < input.tasks.length; i++) {
191
191
  const task = input.tasks[i]
192
192
  try {
193
- const handle = spawnSubagent(
193
+ const handle = await spawnSubagent(
194
194
  {
195
195
  agentId: task.agentId,
196
196
  message: task.message,
@@ -230,3 +230,115 @@ test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thr
230
230
  assert.equal(output.lastMessageText, 'seed user message')
231
231
  assert.equal(output.heartbeatKinds, 0)
232
232
  })
233
+
234
+ test('executeSessionChatTurn applies lifecycle hooks for model resolution and message persistence', () => {
235
+ const output = runWithTempDataDir(`
236
+ const storageMod = await import('@/lib/server/storage')
237
+ const providersMod = await import('@/lib/providers/index')
238
+ const pluginsMod = await import('@/lib/server/plugins')
239
+ const execMod = await import('@/lib/server/chat-execution/chat-execution')
240
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
241
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
242
+ || execMod.default?.executeSessionChatTurn
243
+ || execMod['module.exports']?.executeSessionChatTurn
244
+ const providers = providersMod.PROVIDERS
245
+ || providersMod.default?.PROVIDERS
246
+ || providersMod['module.exports']?.PROVIDERS
247
+ const pluginManager = pluginsMod.getPluginManager
248
+ ? pluginsMod.getPluginManager()
249
+ : pluginsMod.default?.getPluginManager?.()
250
+
251
+ const lifecycleMarks = []
252
+ pluginManager.registerBuiltin('lifecycle_hooks_test', {
253
+ name: 'Lifecycle Hooks Test',
254
+ hooks: {
255
+ beforeModelResolve: () => ({
256
+ providerOverride: 'claude-cli',
257
+ modelOverride: 'resolved-model',
258
+ }),
259
+ beforeMessageWrite: ({ message, phase }) => {
260
+ lifecycleMarks.push(phase || 'unknown')
261
+ return {
262
+ message: {
263
+ ...message,
264
+ text: message.role === 'assistant' ? message.text + ' [stored]' : message.text,
265
+ },
266
+ }
267
+ },
268
+ sessionStart: () => {
269
+ lifecycleMarks.push('session_start')
270
+ },
271
+ },
272
+ })
273
+
274
+ providers['claude-cli'] = {
275
+ id: 'claude-cli',
276
+ name: 'Resolved Provider',
277
+ models: ['resolved-model'],
278
+ requiresApiKey: false,
279
+ requiresEndpoint: false,
280
+ handler: {
281
+ async streamChat(opts) {
282
+ lifecycleMarks.push('provider:' + opts.session.provider + ':' + opts.session.model)
283
+ return 'resolved response'
284
+ },
285
+ },
286
+ }
287
+
288
+ const now = Date.now()
289
+ storage.saveAgents({
290
+ lifecycle: {
291
+ id: 'lifecycle',
292
+ name: 'Lifecycle Agent',
293
+ description: 'Lifecycle hook integration test',
294
+ provider: 'openai',
295
+ model: 'seed-model',
296
+ credentialId: null,
297
+ apiEndpoint: null,
298
+ fallbackCredentialIds: [],
299
+ disabled: false,
300
+ heartbeatEnabled: false,
301
+ heartbeatIntervalSec: null,
302
+ plugins: ['lifecycle_hooks_test'],
303
+ createdAt: now,
304
+ updatedAt: now,
305
+ },
306
+ })
307
+ storage.saveSessions({
308
+ lifecycle_session: {
309
+ id: 'lifecycle_session',
310
+ name: 'Lifecycle Session',
311
+ cwd: process.env.WORKSPACE_DIR,
312
+ user: 'default',
313
+ provider: 'openai',
314
+ model: 'seed-model',
315
+ claudeSessionId: null,
316
+ messages: [],
317
+ createdAt: now,
318
+ lastActiveAt: now,
319
+ sessionType: 'human',
320
+ agentId: 'lifecycle',
321
+ plugins: ['lifecycle_hooks_test'],
322
+ },
323
+ })
324
+
325
+ await executeSessionChatTurn({
326
+ sessionId: 'lifecycle_session',
327
+ message: 'hello lifecycle',
328
+ runId: 'run-lifecycle-hooks',
329
+ })
330
+
331
+ const persisted = storage.loadSession('lifecycle_session')
332
+ console.log(JSON.stringify({
333
+ lastMessageText: persisted?.messages?.at(-1)?.text || null,
334
+ marks: lifecycleMarks,
335
+ }))
336
+ `)
337
+
338
+ assert.equal(output.lastMessageText.startsWith('resolved response'), true)
339
+ assert.equal(output.lastMessageText.endsWith('[stored]'), true)
340
+ assert.equal(output.marks.includes('session_start'), true)
341
+ assert.equal(output.marks.includes('provider:claude-cli:resolved-model'), true)
342
+ assert.equal(output.marks.includes('user'), true)
343
+ assert.equal(output.marks.includes('assistant_final'), true)
344
+ })