@roj-ai/sdk 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/bootstrap.d.ts +2 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugins/resources/index.d.ts +1 -1
  8. package/dist/plugins/resources/index.d.ts.map +1 -1
  9. package/dist/plugins/resources/plugin.d.ts +7 -1
  10. package/dist/plugins/resources/plugin.d.ts.map +1 -1
  11. package/dist/plugins/resources/plugin.js +25 -5
  12. package/dist/plugins/resources/plugin.js.map +1 -1
  13. package/dist/plugins/resources/resources.integration.test.d.ts +2 -0
  14. package/dist/plugins/resources/resources.integration.test.d.ts.map +1 -0
  15. package/dist/plugins/resources/resources.integration.test.js +62 -0
  16. package/dist/plugins/resources/resources.integration.test.js.map +1 -0
  17. package/dist/plugins/resources/state.d.ts +2 -0
  18. package/dist/plugins/resources/state.d.ts.map +1 -1
  19. package/dist/plugins/resources/state.js +1 -0
  20. package/dist/plugins/resources/state.js.map +1 -1
  21. package/dist/plugins/services/plugin.d.ts +4 -0
  22. package/dist/plugins/services/plugin.d.ts.map +1 -1
  23. package/dist/plugins/services/plugin.js +34 -9
  24. package/dist/plugins/services/plugin.js.map +1 -1
  25. package/dist/plugins/services/prompt.d.ts.map +1 -1
  26. package/dist/plugins/services/prompt.js +3 -0
  27. package/dist/plugins/services/prompt.js.map +1 -1
  28. package/dist/plugins/services/schema.d.ts +38 -4
  29. package/dist/plugins/services/schema.d.ts.map +1 -1
  30. package/dist/plugins/services/service.d.ts +11 -1
  31. package/dist/plugins/services/service.d.ts.map +1 -1
  32. package/dist/plugins/services/service.js +182 -46
  33. package/dist/plugins/services/service.js.map +1 -1
  34. package/dist/plugins/services/services.integration.test.js +128 -3
  35. package/dist/plugins/services/services.integration.test.js.map +1 -1
  36. package/dist/testing/preset-helpers.d.ts +1 -0
  37. package/dist/testing/preset-helpers.d.ts.map +1 -1
  38. package/dist/testing/preset-helpers.js +1 -0
  39. package/dist/testing/preset-helpers.js.map +1 -1
  40. package/dist/testing/test-harness.d.ts +9 -2
  41. package/dist/testing/test-harness.d.ts.map +1 -1
  42. package/dist/testing/test-harness.js +31 -3
  43. package/dist/testing/test-harness.js.map +1 -1
  44. package/dist/testing/test-harness.test.js +41 -0
  45. package/dist/testing/test-harness.test.js.map +1 -1
  46. package/package.json +2 -2
  47. package/src/index.ts +10 -2
  48. package/src/plugins/resources/index.ts +1 -1
  49. package/src/plugins/resources/plugin.ts +35 -6
  50. package/src/plugins/resources/resources.integration.test.ts +68 -0
  51. package/src/plugins/resources/state.ts +2 -0
  52. package/src/plugins/services/plugin.ts +32 -9
  53. package/src/plugins/services/prompt.ts +3 -0
  54. package/src/plugins/services/schema.ts +40 -4
  55. package/src/plugins/services/service.ts +202 -52
  56. package/src/plugins/services/services.integration.test.ts +143 -4
  57. package/src/testing/preset-helpers.ts +2 -0
  58. package/src/testing/test-harness.test.ts +51 -0
  59. package/src/testing/test-harness.ts +37 -4
@@ -13,7 +13,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
13
13
  import type { FileSystem } from '~/platform/fs.js'
14
14
  import type { ChildProcess, ProcessRunner } from '~/platform/process.js'
15
15
  import type { PortPool } from '~/plugins/services/port-pool.js'
16
- import type { ServiceConfig, ServiceStatus } from '~/plugins/services/schema.js'
16
+ import type { ServiceConfig, ServiceStartArgs, ServiceStatus } from '~/plugins/services/schema.js'
17
17
  import type { ToolError } from '../../core/tools/executor.js'
18
18
  import type { Logger } from '../../lib/logger/logger.js'
19
19
  import { RingBuffer } from '../../lib/logger/ring-buffer.js'
@@ -57,9 +57,20 @@ interface RunningService {
57
57
  pid: number
58
58
  status: ServiceStatus
59
59
  port: number
60
+ cwd?: string
61
+ command: string
60
62
  logs: RingBuffer
61
63
  }
62
64
 
65
+ export interface ServiceStatusChangeDetails {
66
+ port?: number
67
+ error?: string
68
+ pid?: number
69
+ pidStartTime?: number
70
+ cwd?: string
71
+ command?: string
72
+ }
73
+
63
74
  /** Matches a port bind-conflict across the Node and Bun runtimes. */
64
75
  const PORT_CONFLICT_PATTERN = /EADDRINUSE|address already in use/i
65
76
 
@@ -98,10 +109,7 @@ export class ServiceExecutor {
98
109
  sessionId: string,
99
110
  serviceType: string,
100
111
  status: ServiceStatus,
101
- port?: number,
102
- error?: string,
103
- pid?: number,
104
- pidStartTime?: number,
112
+ details: ServiceStatusChangeDetails,
105
113
  ) => void
106
114
 
107
115
  constructor(logger: Logger, portPool: PortPool, deps: ServiceExecutorDeps) {
@@ -115,14 +123,46 @@ export class ServiceExecutor {
115
123
  sessionId: string,
116
124
  serviceType: string,
117
125
  status: ServiceStatus,
118
- port?: number,
119
- error?: string,
120
- pid?: number,
121
- pidStartTime?: number,
126
+ details: ServiceStatusChangeDetails = {},
122
127
  ): void {
123
- this.onStatusChanged?.(sessionId, serviceType, status, port, error, pid, pidStartTime)
128
+ this.onStatusChanged?.(sessionId, serviceType, status, details)
124
129
  if (status === 'ready' || status === 'failed' || status === 'stopped') {
125
- this.resolveWaiters(serviceType, status, error)
130
+ this.resolveWaiters(serviceType, status, details.error)
131
+ }
132
+ }
133
+
134
+ private async isAvailable(config: ServiceConfig, sessionId: SessionId, workspaceDir?: string): Promise<Result<boolean, ToolError>> {
135
+ if (!config.availableWhen) return Ok(true)
136
+
137
+ try {
138
+ return Ok(await config.availableWhen({ sessionId: String(sessionId), workspaceDir }))
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error)
141
+ return Err({ message: `Service '${config.type}' availability check failed: ${message}`, recoverable: true })
142
+ }
143
+ }
144
+
145
+ private async resolveCwd(config: ServiceConfig, sessionId: SessionId, workspaceDir?: string): Promise<Result<string | undefined, ToolError>> {
146
+ try {
147
+ if (typeof config.cwd === 'function') {
148
+ if (!workspaceDir) {
149
+ return Err({
150
+ message: `Service '${config.type}' cwd resolver requires a workspaceDir`,
151
+ recoverable: true,
152
+ })
153
+ }
154
+ const raw = await config.cwd({ sessionId: String(sessionId), workspaceDir })
155
+ return Ok(resolve(workspaceDir, raw))
156
+ }
157
+
158
+ if (config.cwd !== undefined) {
159
+ return Ok(workspaceDir ? resolve(workspaceDir, config.cwd) : resolve(config.cwd))
160
+ }
161
+
162
+ return Ok(workspaceDir)
163
+ } catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error)
165
+ return Err({ message: `Service '${config.type}' cwd resolver failed: ${message}`, recoverable: true })
126
166
  }
127
167
  }
128
168
 
@@ -214,44 +254,74 @@ export class ServiceExecutor {
214
254
  return Ok(undefined)
215
255
  }
216
256
 
257
+ const availability = await this.isAvailable(config, sessionId, workspaceDir)
258
+ if (!availability.ok) return availability
259
+ if (!availability.value) {
260
+ return Err({ message: `Service '${config.type}' is not available in this workspace`, recoverable: true })
261
+ }
262
+
217
263
  // Allocate port: reuse session-level allocation, then try preferred, then random
218
264
  let port = this.allocatedPorts.get(config.type)
265
+ let allocatedNow = false
219
266
  if (port === undefined) {
220
267
  port = this.portPool.allocatePreferred(preferredPort) ?? undefined
221
268
  if (port === undefined) {
222
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, 'No ports available in pool')
269
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { error: 'No ports available in pool' })
223
270
  return Err({ message: 'No ports available in pool', recoverable: true })
224
271
  }
225
272
  this.allocatedPorts.set(config.type, port)
273
+ allocatedNow = true
226
274
  }
227
275
 
228
- // Resolve cwd: a callback is invoked lazily at start (the workspace is
229
- // populated by now), a string is used directly. Either result is resolved
230
- // against the workspace dir so a relative path (e.g. 'packages/web' for a
231
- // monorepo) lands inside this session's worktree; absolute paths pass through.
232
- let cwd = workspaceDir
233
- if (config.cwd !== undefined) {
234
- const raw = typeof config.cwd === 'function'
235
- ? await config.cwd({ workspaceDir: workspaceDir ?? process.cwd() })
236
- : config.cwd
237
- cwd = workspaceDir ? resolve(workspaceDir, raw) : resolve(raw)
276
+ const cwdResult = await this.resolveCwd(config, sessionId, workspaceDir)
277
+ if (!cwdResult.ok) {
278
+ if (allocatedNow) {
279
+ this.allocatedPorts.delete(config.type)
280
+ this.portPool.release(port)
281
+ }
282
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { port, error: cwdResult.error.message })
283
+ return cwdResult
238
284
  }
285
+
286
+ const cwd = cwdResult.value
239
287
  const logBufferSize = config.logBufferSize ?? 200
240
288
  const startupTimeoutMs = config.startupTimeoutMs ?? 30_000
241
289
 
242
290
  const readyRegex = config.readyPattern ? new RegExp(config.readyPattern) : undefined
243
291
 
244
- // Resolve command: callback gets port, string used as-is
245
- const command = typeof config.command === 'function'
246
- ? config.command({ port })
247
- : config.command
292
+ const startArgs = {
293
+ port,
294
+ sessionId: String(sessionId),
295
+ workspaceDir,
296
+ cwd,
297
+ }
298
+
299
+ let command: string
300
+ let serviceEnv: Record<string, string> | undefined
301
+ try {
302
+ command = typeof config.command === 'function'
303
+ ? await config.command(startArgs)
304
+ : config.command
305
+ serviceEnv = typeof config.env === 'function'
306
+ ? await config.env(startArgs)
307
+ : config.env
308
+ } catch (error) {
309
+ const message = error instanceof Error ? error.message : String(error)
310
+ const errorMessage = `Service '${config.type}' start resolver failed: ${message}`
311
+ if (allocatedNow) {
312
+ this.allocatedPorts.delete(config.type)
313
+ this.portPool.release(port)
314
+ }
315
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { port, cwd, error: errorMessage })
316
+ return Err({ message: errorMessage, recoverable: true })
317
+ }
248
318
 
249
319
  const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'
250
320
  const shellFlag = process.platform === 'win32' ? '/c' : '-c'
251
321
 
252
322
  const child = this.processRunner.spawn(shell, [shellFlag, command], {
253
323
  cwd,
254
- env: { ...process.env, ...config.env, PORT: String(port) },
324
+ env: { ...process.env, ...serviceEnv, PORT: String(port) },
255
325
  detached: true,
256
326
  stdio: ['ignore', 'pipe', 'pipe'],
257
327
  })
@@ -263,12 +333,20 @@ export class ServiceExecutor {
263
333
  const current = this.services.get(config.type)
264
334
  if (current && current.process === child && current.status !== 'stopped' && current.status !== 'failed') {
265
335
  current.status = 'failed'
266
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, error.message)
336
+ this.notifyStatusChanged(sessionId, config.type, 'failed', {
337
+ error: error.message,
338
+ cwd,
339
+ command,
340
+ })
267
341
  }
268
342
  })
269
343
 
270
344
  if (!child.pid) {
271
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, 'Failed to spawn process')
345
+ if (allocatedNow) {
346
+ this.allocatedPorts.delete(config.type)
347
+ this.portPool.release(port)
348
+ }
349
+ this.notifyStatusChanged(sessionId, config.type, 'failed', { port, cwd, command, error: 'Failed to spawn process' })
272
350
  return Err({ message: 'Failed to spawn service process', recoverable: true })
273
351
  }
274
352
 
@@ -276,8 +354,8 @@ export class ServiceExecutor {
276
354
  // "our process" from "an unrelated process that grabbed this PID after ours died"
277
355
  const pidStartTime = await getProcessStartTime(this.fs, child.pid)
278
356
 
279
- // Emit starting event with PID, port (port is known at allocation time), and start time
280
- this.notifyStatusChanged(sessionId, config.type, 'starting', port, undefined, child.pid, pidStartTime)
357
+ // Emit starting event with PID, port, resolved cwd/command, and start time.
358
+ this.notifyStatusChanged(sessionId, config.type, 'starting', { port, pid: child.pid, pidStartTime, cwd, command })
281
359
 
282
360
  const logs = new RingBuffer(logBufferSize)
283
361
  const startTime = Date.now()
@@ -291,10 +369,70 @@ export class ServiceExecutor {
291
369
  pid: child.pid,
292
370
  status: 'starting',
293
371
  port,
372
+ cwd,
373
+ command,
294
374
  logs,
295
375
  }
296
376
  this.services.set(config.type, entry)
297
377
 
378
+ let startupTimer: ReturnType<typeof setTimeout> | undefined
379
+ let readyCheckTimer: ReturnType<typeof setInterval> | undefined
380
+ let readyCheckInFlight = false
381
+
382
+ const clearReadinessTimers = () => {
383
+ if (startupTimer) {
384
+ clearTimeout(startupTimer)
385
+ startupTimer = undefined
386
+ }
387
+ if (readyCheckTimer) {
388
+ clearInterval(readyCheckTimer)
389
+ readyCheckTimer = undefined
390
+ }
391
+ }
392
+
393
+ const markReady = (matchedLine?: string) => {
394
+ const current = this.services.get(config.type)
395
+ if (!current || current.process !== child || current.status !== 'starting') return
396
+
397
+ current.status = 'ready'
398
+ clearReadinessTimers()
399
+ this.portConflictRetries.delete(config.type)
400
+ const startupDurationMs = Date.now() - startTime
401
+ this.notifyStatusChanged(sessionId, config.type, 'ready', {
402
+ port: current.port,
403
+ cwd: current.cwd,
404
+ command: current.command,
405
+ })
406
+ this.logger.info('Service ready', {
407
+ serviceType: config.type,
408
+ port: current.port,
409
+ startupDurationMs,
410
+ matchedLine,
411
+ })
412
+ }
413
+
414
+ const readyArgs = (): ServiceStartArgs => ({
415
+ ...startArgs,
416
+ logs: logs.toArray(),
417
+ })
418
+
419
+ const checkReadyWhen = async () => {
420
+ if (!config.readyWhen || readyCheckInFlight) return
421
+ const current = this.services.get(config.type)
422
+ if (!current || current.process !== child || current.status !== 'starting') return
423
+
424
+ readyCheckInFlight = true
425
+ try {
426
+ if (await config.readyWhen(readyArgs())) {
427
+ markReady()
428
+ }
429
+ } catch {
430
+ // The service may not be reachable yet; keep polling until timeout.
431
+ } finally {
432
+ readyCheckInFlight = false
433
+ }
434
+ }
435
+
298
436
  const processLine = (line: string) => {
299
437
  logs.push(line)
300
438
  if (PORT_CONFLICT_PATTERN.test(line)) {
@@ -307,27 +445,17 @@ export class ServiceExecutor {
307
445
  this.logger.debug('Service output', { serviceType: config.type, line })
308
446
  }
309
447
 
310
- // Check for ready pattern
311
448
  if (readyRegex && current.status === 'starting') {
312
449
  if (readyRegex.test(line)) {
313
- current.status = 'ready'
314
- if (startupTimer) clearTimeout(startupTimer)
315
- this.portConflictRetries.delete(config.type)
316
- const startupDurationMs = Date.now() - startTime
317
- this.notifyStatusChanged(sessionId, config.type, 'ready', current.port)
318
- this.logger.info('Service ready', {
319
- serviceType: config.type,
320
- port: current.port,
321
- startupDurationMs,
322
- matchedLine: line,
323
- })
450
+ markReady(line)
324
451
  }
325
452
  }
453
+
454
+ void checkReadyWhen()
326
455
  }
327
456
 
328
457
  // Startup timeout — mark as failed if not ready in time
329
- let startupTimer: ReturnType<typeof setTimeout> | undefined
330
- if (readyRegex) {
458
+ if (readyRegex || config.readyWhen) {
331
459
  startupTimer = setTimeout(() => {
332
460
  const current = this.services.get(config.type)
333
461
  if (!current || current.process !== child || current.status !== 'starting') return
@@ -335,7 +463,13 @@ export class ServiceExecutor {
335
463
  current.status = 'failed'
336
464
  const errorMsg = `Service startup timed out after ${startupTimeoutMs}ms`
337
465
  this.logger.error(errorMsg, undefined, { serviceType: config.type })
338
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, errorMsg)
466
+ clearReadinessTimers()
467
+ this.notifyStatusChanged(sessionId, config.type, 'failed', {
468
+ port: current.port,
469
+ cwd: current.cwd,
470
+ command: current.command,
471
+ error: errorMsg,
472
+ })
339
473
 
340
474
  // Kill the timed-out process
341
475
  try {
@@ -346,6 +480,14 @@ export class ServiceExecutor {
346
480
  }, startupTimeoutMs)
347
481
  }
348
482
 
483
+ if (config.readyWhen) {
484
+ const intervalMs = config.readyCheckIntervalMs ?? 250
485
+ readyCheckTimer = setInterval(() => {
486
+ void checkReadyWhen()
487
+ }, intervalMs)
488
+ void checkReadyWhen()
489
+ }
490
+
349
491
  // Pipe stdout/stderr line by line
350
492
  let stdoutPartial = ''
351
493
  child.stdout?.on('data', (data: Buffer) => {
@@ -369,7 +511,7 @@ export class ServiceExecutor {
369
511
 
370
512
  // Handle unexpected exit
371
513
  child.on('close', (code) => {
372
- if (startupTimer) clearTimeout(startupTimer)
514
+ clearReadinessTimers()
373
515
  // Flush remaining partial lines
374
516
  if (stdoutPartial) {
375
517
  processLine(stdoutPartial)
@@ -413,7 +555,12 @@ export class ServiceExecutor {
413
555
  // Unexpected exit
414
556
  current.status = 'failed'
415
557
  const errorMsg = `Process exited unexpectedly with code ${code}`
416
- this.notifyStatusChanged(sessionId, config.type, 'failed', undefined, errorMsg)
558
+ this.notifyStatusChanged(sessionId, config.type, 'failed', {
559
+ port: current.port,
560
+ cwd: current.cwd,
561
+ command: current.command,
562
+ error: errorMsg,
563
+ })
417
564
  this.logger.warn('Service process exited unexpectedly', {
418
565
  serviceType: config.type,
419
566
  code,
@@ -433,9 +580,8 @@ export class ServiceExecutor {
433
580
  })
434
581
 
435
582
  // If no ready pattern, immediately mark as ready
436
- if (!readyRegex) {
437
- entry.status = 'ready'
438
- this.notifyStatusChanged(sessionId, config.type, 'ready', port)
583
+ if (!readyRegex && !config.readyWhen) {
584
+ markReady()
439
585
  }
440
586
 
441
587
  return Ok(undefined)
@@ -564,7 +710,11 @@ export class ServiceExecutor {
564
710
  }
565
711
 
566
712
  entry.status = 'ready'
567
- this.notifyStatusChanged(sessionId, config.type, 'ready', entry.port)
713
+ this.notifyStatusChanged(sessionId, config.type, 'ready', {
714
+ port: entry.port,
715
+ cwd: entry.cwd,
716
+ command: entry.command,
717
+ })
568
718
 
569
719
  this.logger.info('Service resumed', { serviceType: config.type })
570
720
  return Ok(undefined)
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from 'bun:test'
2
- import { readFile, rm } from 'node:fs/promises'
2
+ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { MemoryEventStore } from '~/core/events/memory.js'
@@ -13,7 +13,7 @@ import { createTestPreset, TestHarness } from '~/testing/index.js'
13
13
  import { serviceEvents, servicePlugin } from './plugin.js'
14
14
  import type { ServiceAgentConfig, ServicePluginConfig } from './plugin.js'
15
15
  import { PortPool } from './port-pool.js'
16
- import type { ServiceConfig, ServiceEntry, ServiceStatus } from './schema.js'
16
+ import type { ServiceCommandArgs, ServiceConfig, ServiceCwdArgs, ServiceEntry, ServiceStatus } from './schema.js'
17
17
  import { ServiceExecutor } from './service.js'
18
18
 
19
19
  // ============================================================================
@@ -278,6 +278,84 @@ describe('services plugin', () => {
278
278
  const serviceState = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')?.get('callback-service')
279
279
  expect(serviceState!.port).toBeGreaterThanOrEqual(10000)
280
280
  })
281
+
282
+ it('resolves cwd, command, and env callbacks with runtime context', async () => {
283
+ const workspaceDir = await mkdtemp(join(tmpdir(), 'roj-svc-runtime-'))
284
+ const webDir = join(workspaceDir, 'packages', 'web')
285
+ await mkdir(webDir, { recursive: true })
286
+
287
+ let cwdArgs: ServiceCwdArgs | undefined
288
+ let commandArgs: ServiceCommandArgs | undefined
289
+ let envArgs: ServiceCommandArgs | undefined
290
+
291
+ const runtimeService: ServiceConfig = {
292
+ type: 'runtime',
293
+ description: 'Service with runtime resolvers',
294
+ cwd: (args) => {
295
+ cwdArgs = args
296
+ return 'packages/web'
297
+ },
298
+ command: async (args) => {
299
+ commandArgs = args
300
+ return `printf "%s" "$RUNTIME_MARKER" > marker.txt && echo "READY ${args.port}" && sleep 60`
301
+ },
302
+ env: (args) => {
303
+ envArgs = args
304
+ return {
305
+ RUNTIME_MARKER: `${args.sessionId}|${args.workspaceDir}|${args.cwd}|${args.port}`,
306
+ }
307
+ },
308
+ readyPattern: 'READY',
309
+ }
310
+
311
+ try {
312
+ const portPool = new PortPool()
313
+ const harness = createServicesHarness({
314
+ presets: [createServicesPreset([runtimeService], ['runtime'], portPool, { workspaceDir })],
315
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
316
+ })
317
+
318
+ const session = await harness.createSession('test')
319
+ const entryAgentId = session.getEntryAgentId()!
320
+ const startResult = await session.callPluginMethod('services.start', {
321
+ sessionId: String(session.sessionId),
322
+ agentId: String(entryAgentId),
323
+ serviceType: 'runtime',
324
+ })
325
+ expect(startResult.ok).toBe(true)
326
+ await waitForServiceStatus(session, 'runtime', 'ready')
327
+
328
+ const serviceState = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')?.get('runtime')
329
+ expect(cwdArgs?.workspaceDir).toBe(workspaceDir)
330
+ expect(cwdArgs?.sessionId).toBe(String(session.sessionId))
331
+ expect(commandArgs?.cwd).toBe(webDir)
332
+ expect(commandArgs?.workspaceDir).toBe(workspaceDir)
333
+ expect(envArgs?.cwd).toBe(webDir)
334
+ expect(serviceState?.cwd).toBe(webDir)
335
+ expect(serviceState?.command).toContain('RUNTIME_MARKER')
336
+
337
+ const marker = await readFile(join(webDir, 'marker.txt'), 'utf-8')
338
+ expect(marker).toContain(String(session.sessionId))
339
+ expect(marker).toContain(workspaceDir)
340
+ expect(marker).toContain(webDir)
341
+
342
+ const statusResult = await session.callPluginMethod('services.status', {
343
+ sessionId: String(session.sessionId),
344
+ agentId: String(entryAgentId),
345
+ serviceType: 'runtime',
346
+ })
347
+ expect(statusResult).toMatchObject({
348
+ ok: true,
349
+ value: {
350
+ status: 'ready',
351
+ cwd: webDir,
352
+ command: serviceState?.command,
353
+ },
354
+ })
355
+ } finally {
356
+ await rm(workspaceDir, { recursive: true, force: true })
357
+ }
358
+ })
281
359
  })
282
360
 
283
361
  // =========================================================================
@@ -379,6 +457,35 @@ describe('services plugin', () => {
379
457
  const autoEvents = events.filter((e) => e.serviceType === 'auto-start')
380
458
  expect(autoEvents.length).toBeGreaterThanOrEqual(1)
381
459
  })
460
+
461
+ it('availableWhen false skips auto-start and explicit start returns an error', async () => {
462
+ const unavailableService: ServiceConfig = {
463
+ type: 'unavailable',
464
+ description: 'Unavailable service',
465
+ command: 'sleep 60',
466
+ autoStart: true,
467
+ availableWhen: () => false,
468
+ }
469
+ const portPool = new PortPool()
470
+ const harness = createServicesHarness({
471
+ presets: [createServicesPreset([unavailableService], ['unavailable'], portPool)],
472
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
473
+ })
474
+
475
+ const session = await harness.createSession('test')
476
+ await new Promise((resolve) => setTimeout(resolve, 100))
477
+
478
+ const events = await session.getEventsByType(serviceEvents, 'service_status_changed')
479
+ expect(events.some((event) => event.serviceType === 'unavailable')).toBe(false)
480
+
481
+ const entryAgentId = session.getEntryAgentId()!
482
+ const result = await session.callPluginMethod('services.start', {
483
+ sessionId: String(session.sessionId),
484
+ agentId: String(entryAgentId),
485
+ serviceType: 'unavailable',
486
+ })
487
+ expect(result.ok).toBe(false)
488
+ })
382
489
  })
383
490
 
384
491
  // =========================================================================
@@ -439,6 +546,38 @@ describe('services plugin', () => {
439
546
  const serviceState = selectPluginState<Map<string, ServiceEntry>>(session.state, 'services')?.get('ready-service')
440
547
  expect(serviceState!.status).toBe('ready')
441
548
  })
549
+
550
+ it('readyWhen can mark a service ready without output pattern', async () => {
551
+ let checks = 0
552
+ const readyWhenService: ServiceConfig = {
553
+ type: 'ready-when',
554
+ description: 'Service with callback readiness',
555
+ command: 'sleep 60',
556
+ readyWhen: () => {
557
+ checks += 1
558
+ return checks > 1
559
+ },
560
+ readyCheckIntervalMs: 20,
561
+ startupTimeoutMs: 1000,
562
+ }
563
+ const portPool = new PortPool()
564
+ const harness = createServicesHarness({
565
+ presets: [createServicesPreset([readyWhenService], ['ready-when'], portPool)],
566
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }),
567
+ })
568
+
569
+ const session = await harness.createSession('test')
570
+ const entryAgentId = session.getEntryAgentId()!
571
+ const result = await session.callPluginMethod('services.start', {
572
+ sessionId: String(session.sessionId),
573
+ agentId: String(entryAgentId),
574
+ serviceType: 'ready-when',
575
+ })
576
+ expect(result.ok).toBe(true)
577
+
578
+ await waitForServiceStatus(session, 'ready-when', 'ready')
579
+ expect(checks).toBeGreaterThan(1)
580
+ })
442
581
  })
443
582
 
444
583
  // =========================================================================
@@ -658,8 +797,8 @@ describe('services plugin', () => {
658
797
  }
659
798
 
660
799
  const observed: Array<{ status: ServiceStatus; port?: number }> = []
661
- executor.onStatusChanged = (_sessionId, _serviceType, status, port) => {
662
- observed.push({ status, port })
800
+ executor.onStatusChanged = (_sessionId, _serviceType, status, details) => {
801
+ observed.push({ status, port: details.port })
663
802
  }
664
803
 
665
804
  try {
@@ -12,6 +12,7 @@ export type TestAgentDefinition<TInput = unknown> = Omit<AgentDefinition<TInput>
12
12
  */
13
13
  export function createTestPreset(overrides?: {
14
14
  id?: string
15
+ workspaceDir?: string
15
16
  orchestratorSystem?: string
16
17
  agents?: TestAgentDefinition[]
17
18
  plugins?: SessionPluginConfig[]
@@ -22,6 +23,7 @@ export function createTestPreset(overrides?: {
22
23
  return {
23
24
  id: overrides?.id ?? 'test',
24
25
  name: 'Test Preset',
26
+ workspaceDir: overrides?.workspaceDir,
25
27
  orchestrator: {
26
28
  system: overrides?.orchestratorSystem ?? 'You are a test agent.',
27
29
  model: ModelId('mock'),
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
2
  import { MockLLMProvider } from '~/core/llm/mock.js'
3
3
  import { ToolCallId } from '~/core/tools/schema.js'
4
+ import { definePlugin, Ok, selectSessionStats, sessionStatsPlugin, type SessionStatsState, z } from '~/index.js'
4
5
  import { createTestPreset, TestHarness } from './index.js'
5
6
 
6
7
  describe('TestHarness', () => {
@@ -60,4 +61,54 @@ describe('TestHarness', () => {
60
61
 
61
62
  await harness.shutdown()
62
63
  })
64
+
65
+ it('includes session stats by default and exposes plugin state', async () => {
66
+ expect(sessionStatsPlugin.name).toBe('session-stats')
67
+
68
+ const harness = new TestHarness({
69
+ presets: [createTestPreset()],
70
+ llmProvider: MockLLMProvider.withFixedResponse({
71
+ content: 'Done',
72
+ toolCalls: [],
73
+ metrics: MockLLMProvider.defaultMetricsWithCost(1.25),
74
+ }),
75
+ // Passing a default plugin explicitly should not register it twice.
76
+ systemPlugins: [sessionStatsPlugin],
77
+ })
78
+
79
+ const session = await harness.createSession('test')
80
+ await session.sendAndWaitForIdle('Test')
81
+
82
+ const stats = session.getPluginState<SessionStatsState>('sessionStats')
83
+ expect(stats?.llmCalls).toBe(1)
84
+ expect(stats?.totalCost).toBe(1.25)
85
+ expect(selectSessionStats(session.state).totalCost).toBe(1.25)
86
+
87
+ await harness.shutdown()
88
+ })
89
+
90
+ it('callPluginMethod can pass caller context', async () => {
91
+ const callerPlugin = definePlugin('caller')
92
+ .method('whoami', {
93
+ input: z.object({}),
94
+ output: z.object({ source: z.string() }),
95
+ handler: async ctx => Ok({ source: ctx.caller.source }),
96
+ })
97
+ .build()
98
+
99
+ const harness = new TestHarness({
100
+ presets: [createTestPreset()],
101
+ systemPlugins: [callerPlugin],
102
+ })
103
+
104
+ const session = await harness.createSession('test')
105
+ const result = await session.callPluginMethod('caller.whoami', {}, { caller: { source: 'system', meta: {} } })
106
+
107
+ expect(result.ok).toBe(true)
108
+ if (result.ok) {
109
+ expect(result.value).toEqual({ source: 'system' })
110
+ }
111
+
112
+ await harness.shutdown()
113
+ })
63
114
  })