Skip to content
Merged

temp #16

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 97 additions & 8 deletions src/commands/run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
AgentRun,
AgentRunSource,
AgentRunStatus,
ReplayAgentRunRequest,
StartAgentRunRequest,
} from '../lib/types'

Expand Down Expand Up @@ -50,7 +51,11 @@ export function registerRun(program: Command): void {
)
.option(
'-o, --config-override <yaml>',
'partial agent config (YAML) merged onto the chosen config for this run, e.g. "limits:\\n run: 5"',
'partial agent config (YAML/JSON) merged onto the chosen config for this run, e.g. "limits:\\n run: 5"',
)
.option(
'--config-override-file <path>',
'read the partial config override from a file (.yaml/.yml or .json) instead of inline',
)
.option(
'-p, --prompt <text>',
Expand All @@ -62,7 +67,6 @@ export function registerRun(program: Command): void {
collectKeyValue,
{} as Record<string, string>,
)
.option('-s, --source <source>', 'run source', 'cli')
.option('-w, --watch', 'stream the run live until it reaches a terminal status')
.option('--json', 'output raw JSON')
.action(
Expand All @@ -71,9 +75,9 @@ export function registerRun(program: Command): void {
configFile?: string
template?: string
configOverride?: string
configOverrideFile?: string
prompt?: string
metadata: Record<string, string>
source: string
watch?: boolean
json?: boolean
}) => {
Expand All @@ -88,15 +92,14 @@ export function registerRun(program: Command): void {
throw new Error('provide only one of --config / --config-file / --template')
}
const req: StartAgentRunRequest = {
source: opts.source as AgentRunSource,
metadata: opts.metadata,
}
if (opts.config) req.config_id = opts.config
if (opts.configFile) req.config = readConfigFile(opts.configFile)
if (opts.template) req.template_id = opts.template
// Merged onto the chosen config and re-validated server-side; set
// limits.run here to override this run's budget.
if (opts.configOverride) req.config_override_yaml = opts.configOverride
applyConfigOverride(req, opts)
// Appended to the initial user query at build time; gives this run
// instructions on top of the config's shared system prompt.
if (opts.prompt) req.prompt = opts.prompt
Expand Down Expand Up @@ -201,6 +204,69 @@ export function registerRun(program: Command): void {
})
})

run
.command('replay <runId>')
.description("Re-run an existing run's trigger input (POST /v1/agents/runs/{id}/replay)")
.option(
'-c, --config-id <id>',
"run against a different saved config instead of the original run's snapshot",
)
.option(
'-o, --config-override <yaml>',
'partial agent config (YAML/JSON) merged onto the config for this replay, e.g. "claude:\\n model: claude-opus-4-8"',
)
.option(
'--config-override-file <path>',
'read the partial config override from a file (.yaml/.yml or .json) instead of inline',
)
.option(
'-p, --prompt <text>',
"per-run instructions; omit to inherit the original run's prompt, pass '' to clear it",
)
.option('-w, --watch', 'stream the run live until it reaches a terminal status')
.option('--json', 'output raw JSON')
.action(
async (
runId: string,
opts: {
configId?: string
configOverride?: string
configOverrideFile?: string
prompt?: string
watch?: boolean
json?: boolean
},
) => {
await runAction(async () => {
const req: ReplayAgentRunRequest = {}
if (opts.configId) req.config_id = opts.configId
applyConfigOverride(req, opts)
// Distinguish "flag omitted" (inherit the original prompt) from
// `--prompt ''` (clear it): only set the field when the flag was passed.
if (opts.prompt !== undefined) req.prompt = opts.prompt

const api = new ApiClient()
const run = await api.replayAgentRun(runId, req)

if (opts.watch) {
if (!opts.json) {
console.log(`✓ started replay ${run.id} (from ${runId})`)
await printRunUrl(api, run.id)
}
await watchRunStreaming(api, run.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json)
return
}
if (opts.json) {
printJson(run)
return
}
console.log(`✓ started replay ${run.id} (${run.status}, from ${runId})`)
await printRunUrl(api, run.id)
console.log(` follow with: agent run get ${run.id} --watch`)
})
},
)

run
.command('stop <runId>')
.description('Stop an in-flight run')
Expand Down Expand Up @@ -359,26 +425,49 @@ async function printRunUrl(api: ApiClient, runId: string): Promise<void> {
console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`)
}

// Apply the mutually-exclusive config-override flags onto a run request.
// `--config-override` is an inline YAML/JSON string passed straight through as
// config_override_yaml; `--config-override-file` is read and parsed to a mapping
// and sent as the structured config_override. Both merge identically server-side.
export function applyConfigOverride(
req: { config_override?: Record<string, unknown>; config_override_yaml?: string },
opts: { configOverride?: string; configOverrideFile?: string },
): void {
if (opts.configOverride && opts.configOverrideFile) {
throw new Error('provide only one of --config-override / --config-override-file')
}
if (opts.configOverride) req.config_override_yaml = opts.configOverride
if (opts.configOverrideFile) {
req.config_override = readMappingFile(opts.configOverrideFile, 'config override')
}
}

// Parse an inline agent config from disk, choosing the parser by file
// extension: .yaml/.yml as YAML, .json as JSON. (YAML is a JSON superset, so
// unknown extensions fall back to YAML, which still accepts JSON input.)
export function readConfigFile(path: string): Record<string, unknown> {
return readMappingFile(path, 'config')
}

// Read a YAML/JSON file from disk and parse it to a mapping, choosing the parser
// by extension. `label` (e.g. "config", "config override") tailors the error.
function readMappingFile(path: string, label: string): Record<string, unknown> {
let text: string
try {
text = readFileSync(path, 'utf8')
} catch (err) {
throw new Error(`could not read config file ${path}: ${(err as Error).message}`)
throw new Error(`could not read ${label} file ${path}: ${(err as Error).message}`)
}
const ext = extname(path).toLowerCase()
try {
const parsed = ext === '.json' ? JSON.parse(text) : parseYaml(text)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('config must be a mapping of fields')
throw new Error(`${label} must be a mapping of fields`)
}
return parsed as Record<string, unknown>
} catch (err) {
const kind = ext === '.json' ? 'JSON' : 'YAML'
throw new Error(`could not parse ${kind} config file ${path}: ${(err as Error).message}`)
throw new Error(`could not parse ${kind} ${label} file ${path}: ${(err as Error).message}`)
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveApiBase, resolveToken } from './config'
import { USER_AGENT } from './constants'
import type {
AgentRun,
AgentTemplate,
Expand All @@ -12,6 +13,7 @@ import type {
ListAgentRunsQuery,
ListAgentRunsResponse,
ListAgentTemplatesResponse,
ReplayAgentRunRequest,
SandboxVariableInput,
SandboxVariableSummary,
SavedAgentConfig,
Expand Down Expand Up @@ -59,6 +61,7 @@ export class ApiClient {
method,
headers: {
'content-type': 'application/json',
'user-agent': USER_AGENT,
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
Expand Down Expand Up @@ -107,6 +110,14 @@ export class ApiClient {
return this.request('GET', `/v1/agents/runs/${encodeURIComponent(runId)}`)
}

replayAgentRun(runId: string, req: ReplayAgentRunRequest): Promise<AgentRun> {
return this.request(
'POST',
`/v1/agents/runs/${encodeURIComponent(runId)}/replay`,
req,
)
}

// ----------------------------- agent configs ----------------------------

async listAgentConfigs(): Promise<SavedAgentConfig[]> {
Expand Down
12 changes: 11 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export const VERSION = '0.1.3'
import pkg from '../../package.json'

// package.json is the single source of truth for the version; tsup (esbuild),
// `bun build --compile`, and tsx all inline this JSON import, so the binary,
// `--version`, and the User-Agent below never drift from the published version.
export const VERSION: string = pkg.version

// Sent on every API/WebSocket request so the server can record which client
// started a run (stored on the run as client_version, shown for support). Not a
// security boundary — the server derives a run's `source` from the credential.
export const USER_AGENT = `ellipsis-cli/${VERSION}`

// The bare default; env (ELLIPSIS_API_BASE_URL / ELLIPSIS_API_BASE) and the
// config file take precedence and are layered in resolveApiBase() (config.ts).
Expand Down
23 changes: 19 additions & 4 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,33 @@ export interface StartAgentRunRequest {
config_id?: string
config?: AgentConfig
template_id?: string
source?: AgentRunSource
// No `source`: the server derives a run's provenance from the credential (a
// user token => `cli`), so it can't be spoofed by the request body.
metadata?: Record<string, string>
// A partial agent config (YAML) merged onto the chosen config and re-validated
// server-side, e.g. "limits:\n run: 5.0" to set just this run's budget. Only
// meaningful with config_id/template_id.
// A partial agent config merged onto the chosen config and re-validated
// server-side, e.g. raise just this run's budget. Supply it as a structured
// mapping (config_override) or a YAML/JSON string (config_override_yaml) — not
// both. Only meaningful with config_id/template_id.
config_override?: Record<string, unknown>
config_override_yaml?: string
// Per-run instructions appended to the initial user query at build time, after
// the config's shared `claude.system` system prompt. Distinct from the system
// prompt, which is identical for every run of a config.
prompt?: string
}

// Replay payload for POST /v1/agents/runs/{id}/replay. Re-runs an existing run's
// trigger input. Reuses the original run's frozen config snapshot unless
// config_id is given. The override fields behave exactly as on
// StartAgentRunRequest (mapping or string, not both). `prompt` is omitted to
// inherit the original run's prompt, set to "" to clear it.
export interface ReplayAgentRunRequest {
config_id?: string
config_override?: Record<string, unknown>
config_override_yaml?: string
prompt?: string
}

export interface ListAgentRunsResponse {
runs: AgentRun[]
}
Expand Down
6 changes: 4 additions & 2 deletions src/lib/ws.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import WebSocket from 'ws'
import { resolveApiBase } from './config'
import { DEFAULT_WS_BASE } from './constants'
import { DEFAULT_WS_BASE, USER_AGENT } from './constants'

// The frame protocol spoken over the run WebSocket (server -> client). One JSON
// object per message. Mirrors run_stream.py in the backend.
Expand Down Expand Up @@ -74,7 +74,9 @@ const HEARTBEAT_TIMEOUT_MS = 45_000
const DEFAULT_MAX_RECONNECTS = 5

const defaultFactory: SocketFactory = (url, token) => {
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${token}` } })
const ws = new WebSocket(url, {
headers: { authorization: `Bearer ${token}`, 'user-agent': USER_AGENT },
})
return {
onOpen: (cb) => ws.on('open', cb),
onMessage: (cb) => ws.on('message', (raw: WebSocket.RawData) => cb(raw.toString())),
Expand Down
22 changes: 22 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,28 @@ describe('ApiClient sandbox variables', () => {
})
})

describe('replayAgentRun', () => {
afterEach(() => vi.unstubAllGlobals())

it('POSTs to the run-scoped replay path (encoded) with the body', async () => {
const fetchMock = vi.fn(
async () => new Response(JSON.stringify({ id: 'run_2' }), { status: 200 }),
)
vi.stubGlobal('fetch', fetchMock)

const out = await new ApiClient('http://api.test', 't').replayAgentRun('run/1', {
config_override: { claude: { model: 'claude-opus-4-8' } },
})
expect(out.id).toBe('run_2')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://api.test/v1/agents/runs/run%2F1/replay')
expect((init as RequestInit).method).toBe('POST')
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
config_override: { claude: { model: 'claude-opus-4-8' } },
})
})
})

describe('agent templates', () => {
afterEach(() => vi.unstubAllGlobals())

Expand Down
50 changes: 49 additions & 1 deletion test/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readConfigFile, watchRun } from '../src/commands/run'
import { applyConfigOverride, readConfigFile, watchRun } from '../src/commands/run'
import type { ApiClient } from '../src/lib/api'
import type { AgentRun, AgentRunStatus } from '../src/lib/types'

Expand Down Expand Up @@ -111,3 +111,51 @@ describe('readConfigFile', () => {
expect(() => readConfigFile(join(dir, 'nope.yaml'))).toThrow(/could not read config file/)
})
})

describe('applyConfigOverride', () => {
const dir = mkdtempSync(join(tmpdir(), 'agent-override-'))
const write = (name: string, body: string): string => {
const path = join(dir, name)
writeFileSync(path, body)
return path
}

it('passes an inline override through as the YAML/JSON string', () => {
const req: { config_override?: Record<string, unknown>; config_override_yaml?: string } = {}
applyConfigOverride(req, { configOverride: 'claude:\n model: claude-opus-4-8' })
expect(req).toEqual({ config_override_yaml: 'claude:\n model: claude-opus-4-8' })
})

it('reads and parses a file override into the structured mapping', () => {
const path = write('override.yaml', 'limits:\n run: 5\n')
const req: { config_override?: Record<string, unknown>; config_override_yaml?: string } = {}
applyConfigOverride(req, { configOverrideFile: path })
expect(req).toEqual({ config_override: { limits: { run: 5 } } })
})

it('rejects passing both inline and file forms', () => {
const path = write('both.yaml', 'enabled: false\n')
expect(() =>
applyConfigOverride({}, { configOverride: 'enabled: false', configOverrideFile: path }),
).toThrow(/only one of --config-override \/ --config-override-file/)
})

it('is a no-op when neither form is given', () => {
const req: { config_override?: Record<string, unknown>; config_override_yaml?: string } = {}
applyConfigOverride(req, {})
expect(req).toEqual({})
})

it('surfaces an override-specific error when the file is missing', () => {
expect(() => applyConfigOverride({}, { configOverrideFile: join(dir, 'nope.yaml') })).toThrow(
/could not read config override file/,
)
})

it('surfaces an override-specific error for a non-mapping file', () => {
const path = write('list.yaml', '- a\n- b\n')
expect(() => applyConfigOverride({}, { configOverrideFile: path })).toThrow(
/could not parse YAML config override file/,
)
})
})
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
Expand Down
Loading