Files
BrowserOS/packages/browseros-agent/apps/server/tests/lib/agents/runtime/container-agent-runtime.test.ts
Dani Akash e89fccd997 feat(runtime): introduce AgentRuntime abstraction (types, interface, registry, abstract bases) (#964)
* feat(runtime): introduce AgentRuntime types + interface + registry

Foundation for the unified agent-runtime abstraction. No adapter
migrates yet; the existing acpx-runtime, per-adapter prepare
modules, OpenClawService, HermesContainerService, and
adapter-health.ts all keep working unchanged.

This commit adds the data layer of the abstraction:

- `RuntimeDescriptor` discriminates the two kinds we ship today
  (`'container'` | `'host-process'`). UI components route on this.
- `RuntimeState` is the union of both kinds' states — container
  flow `not_installed → installing → installed → starting →
  running → stopped`, host flow `cli_missing | cli_present |
  cli_unhealthy`, plus the shared `errored` and
  `unsupported_platform` terminals.
- `RuntimeStatusSnapshot` carries a single `isReady: boolean` so
  the harness has one bit to read before spawning turns.
- `RuntimeAction` is a typed discriminated union — required args
  (e.g. `agentId` for `'reset-wipe-agent'`) are compile-time
  enforced, removing the previous footgun of optional args on a
  string-keyed dispatch.
- `RuntimeCapability` lists every action a runtime can advertise;
  `getCapabilities()` is the single switchboard the UI uses to
  decide which buttons to render.

`AgentRuntime` interface declares the contract every runtime
implements: status snapshot + subscriber, capability list,
`executeAction(action)`, `buildExecArgv(spec)`, and per-agent home
dir. `prepareTurnContext` is intentionally absent until the first
adapter migrates so callers can't depend on a method that has no
implementation.

`AgentRuntimeRegistry` is a small class + module-level singleton —
adapters register themselves at boot, the harness/UI look up by
`adapterId`. `resetAgentRuntimeRegistry()` is for tests only.

Two error classes round it out: `ActionNotSupportedError`
(capability gate, mapped to HTTP 405 in a later phase) and
`RuntimeNotReadyError` (state gate at the runtime layer, distinct
from the container-layer's `ContainerNotReadyError`).

* feat(runtime): add ContainerAgentRuntime + HostProcessAgentRuntime abstract bases

* test(runtime): cover state translation, action dispatch, registry

* fix(runtime): gate host-process executeAction on capabilities; only stamp probe cache after probe resolves
2026-05-08 09:47:38 +05:30

189 lines
5.9 KiB
TypeScript

/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import {
ActionNotSupportedError,
ContainerAgentRuntime,
type RuntimeCapability,
type RuntimeStatusSnapshot,
} from '../../../../src/lib/agents/runtime'
import type {
ManagedContainerDeps,
MountRoot,
} from '../../../../src/lib/container/managed'
import type { ContainerSpec } from '../../../../src/lib/container/types'
interface Call {
kind: 'install' | 'start' | 'stop' | 'restart' | 'reset'
args?: unknown
}
class TestRuntime extends ContainerAgentRuntime {
readonly descriptor = {
adapterId: 'test',
displayName: 'Test',
kind: 'container' as const,
defaultImage: 'docker.io/test:latest',
containerName: 'test-container',
platforms: ['darwin' as NodeJS.Platform],
}
calls: Call[] = []
capabilities: ReadonlyArray<RuntimeCapability> | null = null
getPerAgentHomeDir(agentId: string): string {
return `/tmp/test/${agentId}`
}
override getCapabilities(): ReadonlyArray<RuntimeCapability> {
return this.capabilities ?? super.getCapabilities()
}
protected mountRoots(): readonly MountRoot[] {
return []
}
protected async buildContainerSpec(): Promise<ContainerSpec> {
return {
name: this.descriptor.containerName,
image: this.descriptor.defaultImage,
}
}
protected async readinessProbe(): Promise<boolean> {
return true
}
override async install(_opts: { onLog?: (m: string) => void } = {}) {
this.calls.push({ kind: 'install' })
}
override async start(_opts: { onLog?: (m: string) => void } = {}) {
this.calls.push({ kind: 'start' })
}
override async stop() {
this.calls.push({ kind: 'stop' })
}
override async restart(_opts: { onLog?: (m: string) => void } = {}) {
this.calls.push({ kind: 'restart' })
}
override async reset(level: 'soft' | 'wipe-agent' | 'hard', opts: unknown) {
this.calls.push({ kind: 'reset', args: { level, opts } })
}
}
function makeDeps(): ManagedContainerDeps {
return {
cli: {} as ManagedContainerDeps['cli'],
loader: {} as ManagedContainerDeps['loader'],
vm: {} as ManagedContainerDeps['vm'],
limactlPath: '/opt/homebrew/bin/limactl',
limaHome: '/tmp/lima',
vmName: 'browseros-vm',
lockDir: '/tmp/locks',
}
}
describe('ContainerAgentRuntime', () => {
it('default capabilities cover lifecycle + reset levels + logs', () => {
const r = new TestRuntime(makeDeps())
expect(r.getCapabilities()).toEqual([
'install',
'start',
'stop',
'restart',
'reset-soft',
'reset-wipe-agent',
'reset-hard',
'logs',
])
})
it('getStatusSnapshot maps state, isReady, containerName', () => {
const r = new TestRuntime(makeDeps())
const snap = r.getStatusSnapshot()
expect(snap.adapterId).toBe('test')
expect(snap.containerName).toBe('test-container')
expect(snap.state).toBe('not_installed')
expect(snap.isReady).toBe(false)
expect(snap.lastError).toBeNull()
})
it('isReady is true only when state is running', () => {
const r = new TestRuntime(makeDeps())
// biome-ignore lint/complexity/useLiteralKeys: protected access
r['state'] = 'running'
expect(r.getStatusSnapshot().isReady).toBe(true)
// biome-ignore lint/complexity/useLiteralKeys: protected access
r['state'] = 'errored'
expect(r.getStatusSnapshot().isReady).toBe(false)
})
it('subscribe wires through subscribeState and emits a snapshot', () => {
const r = new TestRuntime(makeDeps())
const seen: RuntimeStatusSnapshot[] = []
const off = r.subscribe((s) => seen.push(s))
// biome-ignore lint/complexity/useLiteralKeys: protected access
r['setState']('starting')
// biome-ignore lint/complexity/useLiteralKeys: protected access
r['setState']('running')
off()
// biome-ignore lint/complexity/useLiteralKeys: protected access
r['setState']('stopped')
expect(seen.map((s) => s.state)).toEqual(['starting', 'running'])
expect(seen[1].isReady).toBe(true)
})
describe('executeAction dispatch', () => {
it('routes install/start/stop/restart to inherited methods', async () => {
const r = new TestRuntime(makeDeps())
await r.executeAction({ type: 'install' })
await r.executeAction({ type: 'start' })
await r.executeAction({ type: 'stop' })
await r.executeAction({ type: 'restart' })
expect(r.calls.map((c) => c.kind)).toEqual([
'install',
'start',
'stop',
'restart',
])
})
it('routes reset variants with correct level + agentId', async () => {
const r = new TestRuntime(makeDeps())
await r.executeAction({ type: 'reset-soft' })
await r.executeAction({ type: 'reset-wipe-agent', agentId: 'agent-7' })
await r.executeAction({ type: 'reset-hard' })
const resets = r.calls.filter((c) => c.kind === 'reset')
expect(resets).toHaveLength(3)
expect((resets[0].args as { level: string }).level).toBe('soft')
expect(
(resets[1].args as { level: string; opts: { agentId: string } }).opts
.agentId,
).toBe('agent-7')
expect((resets[2].args as { level: string }).level).toBe('hard')
})
it('throws ActionNotSupportedError when capability is filtered out', async () => {
const r = new TestRuntime(makeDeps())
r.capabilities = ['start', 'stop']
await expect(r.executeAction({ type: 'install' })).rejects.toBeInstanceOf(
ActionNotSupportedError,
)
expect(r.calls).toEqual([])
})
it('throws ActionNotSupportedError for host-only actions', async () => {
const r = new TestRuntime(makeDeps())
await expect(
r.executeAction({ type: 'reinstall-cli' }),
).rejects.toBeInstanceOf(ActionNotSupportedError)
await expect(
r.executeAction({ type: 'check-auth' }),
).rejects.toBeInstanceOf(ActionNotSupportedError)
})
})
})