diff --git a/README.md b/README.md index 5980f2d..1d1134f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Ellipsis CLI Drive the [Ellipsis](https://ellipsis.dev) cloud from your terminal: start agent -runs, stream their output live, manage configurations, and open a run in the -browser IDE. +sessions, stream their output live, manage configurations, and open a session +in the browser IDE. This is a thin client. The agent runs in the Ellipsis cloud; the CLI authenticates, opens a WebSocket, and streams results. It is open source -(MIT) — the proprietary engine stays server-side. +(MIT), and the proprietary engine stays server-side. ## Install @@ -21,14 +21,15 @@ agent login # device-code auth (use --no-browser for SSH) agent logout # remove stored credentials agent me # show the current credential's identity -agent run start --config # start a run from a saved config -agent run start --config-file f.json # ...or from an inline config -agent run start --template welcome-to-ellipsis # ...or from a maintained template -agent run start --config --config-override "limits:\n run: 5" # override config fields for this run -agent run start --config --watch # start and immediately stream it -agent run list --limit 20 # list recent runs (filter by --source, --days, …) -agent run get # inspect one run (prints a dashboard link) -agent run get --watch # follow a run until it finishes +agent session start --config # start a session from a saved config +agent session start --config-file f.json # ...or from an inline config +agent session start --template welcome-to-ellipsis # ...or from a maintained template +agent session start --config --config-override "limits:\n run: 5" # override config fields for this session +agent session start --config --watch # start and immediately stream it +agent session list --limit 20 # list recent sessions (filter by --source, --days, …) +agent session get # inspect one session (prints a dashboard link) +agent session get --watch # follow a session until it finishes +agent session stop # stop an in-flight session agent config list # list saved agent configs agent config get # show one config as YAML (-o json for JSON) @@ -47,10 +48,10 @@ Most commands accept `--json` to print the raw API response. The CLI talks to the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL` (or the legacy `ELLIPSIS_API_BASE`). -`--watch` (on both `run start` and `run get`) streams the run's output live over -WebSocket until it reaches a terminal status, falling back to periodic status -polling if the live stream is unavailable. Either way it first prints a -clickable dashboard link. The stream protocol is specified in +`--watch` (on both `session start` and `session get`) streams the session's +output live over WebSocket until it reaches a terminal status, falling back to +periodic status polling if the live stream is unavailable. Either way it first +prints a clickable dashboard link. The stream protocol is specified in [`docs/RUN_STREAMING_SPEC.md`](docs/RUN_STREAMING_SPEC.md). ### Auth @@ -58,7 +59,7 @@ clickable dashboard link. The stream protocol is specified in `agent login` uses the device-code flow: it requests a code pair, prints a verification URL (and opens it unless `--no-browser`), and polls until you approve the request in the dashboard. The issued user token is stored under -`~/.config/ellipsis/config.json` (mode 0600) and attributes runs to you. +`~/.config/ellipsis/config.json` (mode 0600) and attributes sessions to you. **Credentials resolve in this order (highest wins):** explicit argument → environment (`ELLIPSIS_API_TOKEN` / `ELLIPSIS_API_BASE_URL`, with the legacy @@ -138,7 +139,7 @@ scoped to that one repo only — no account-wide PAT involved. ### Status -The full `/v1` REST surface (auth, runs, configs, budget/usage) is wired against -the live API. Still pending: the server-side WebSocket frame protocol behind -`run view`, a `run stop` endpoint, and replacing the hand-rolled request/response -types with the generated `@ellipsis/sdk` package. +The full `/v1` REST surface (auth, sessions, configs, budget/usage) is wired +against the live API, including live WebSocket streaming and `session stop`. +Still pending: replacing the hand-rolled request/response types with the +generated `@ellipsis/sdk` package. diff --git a/package.json b/package.json index 83ffc19..16d6973 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ellipsis/cli", "version": "0.1.6", - "description": "Ellipsis agent CLI — drive the Ellipsis cloud from your terminal", + "description": "Ellipsis agent CLI: drive the Ellipsis cloud from your terminal", "license": "MIT", "type": "module", "packageManager": "bun@1.3.14", diff --git a/src/cli.tsx b/src/cli.tsx index 59f4729..c7f9403 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,7 +1,7 @@ import { Command } from 'commander' import { registerLogin } from './commands/login' import { registerMe } from './commands/me' -import { registerRun } from './commands/run' +import { registerSession } from './commands/session' import { registerConfig } from './commands/config' import { registerSandbox } from './commands/sandbox' import { registerTemplate } from './commands/template' @@ -13,12 +13,12 @@ const program = new Command() program .name('agent') - .description('Ellipsis agent CLI — drive the Ellipsis cloud from your terminal') + .description('Ellipsis agent CLI: drive the Ellipsis cloud from your terminal') .version(VERSION) registerLogin(program) registerMe(program) -registerRun(program) +registerSession(program) registerConfig(program) registerSandbox(program) registerTemplate(program) diff --git a/src/commands/config.ts b/src/commands/config.ts index 6a74f69..f51e236 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -14,7 +14,7 @@ export function registerConfig(program: Command): void { config .command('list') - .description('List saved agent configurations (GET /v1/agents/configs)') + .description('List saved agent configurations (GET /v1/configs)') .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -41,7 +41,7 @@ export function registerConfig(program: Command): void { config .command('get ') - .description('Get a single agent configuration (GET /v1/agents/configs/{id})') + .description('Get a single agent configuration (GET /v1/configs/{id})') .option('-o, --output ', 'output format: yaml (default) or json', parseFormat, 'yaml') .action(async (configId: string, opts: { output: 'yaml' | 'json' }) => { await runAction(async () => { diff --git a/src/commands/run.tsx b/src/commands/session.tsx similarity index 62% rename from src/commands/run.tsx rename to src/commands/session.tsx index 6f4ac38..2c74a95 100644 --- a/src/commands/run.tsx +++ b/src/commands/session.tsx @@ -6,40 +6,40 @@ import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { formatTs, printJson, printTable, runAction, usdFromMillicents } from '../lib/output' import { collect, collectKeyValue, toInt } from '../lib/args' -import { runUrl } from '../lib/urls' +import { sessionUrl } from '../lib/urls' import { resolveWsBase, - streamRun, + streamSession, StreamUnavailableError, type StreamFrame, type StreamOutcome, } from '../lib/ws' import type { - AgentRun, - AgentRunSource, - AgentRunStatus, - ReplayAgentRunRequest, - StartAgentRunRequest, + AgentSession, + AgentSessionSource, + AgentSessionStatus, + ReplayAgentSessionRequest, + StartAgentSessionRequest, } from '../lib/types' // Poll cadence for the `--watch` REST fallback (used only when live WebSocket // streaming is unavailable). Not user-configurable — the fallback is rare. const FALLBACK_POLL_INTERVAL_SECONDS = 2 -// Statuses past which a run no longer changes — `--watch` stops here. -const TERMINAL_STATUSES: ReadonlySet = new Set([ +// Statuses past which a session no longer changes — `--watch` stops here. +const TERMINAL_STATUSES: ReadonlySet = new Set([ 'completed', 'error', 'cancelled', 'stopped', ]) -export function registerRun(program: Command): void { - const run = program.command('run').description('Start and inspect agent runs') +export function registerSession(program: Command): void { + const session = program.command('session').description('Start and inspect agent sessions') - run + session .command('start') - .description('Start a new agent run (POST /v1/agents/runs)') + .description('Start a new agent session (POST /v1/sessions)') .option('-c, --config ', 'start from a saved agent config id') .option( '-f, --config-file ', @@ -47,11 +47,11 @@ export function registerRun(program: Command): void { ) .option( '-t, --template ', - 'start from a maintained run template (e.g. welcome-to-ellipsis)', + 'start from a maintained session template (e.g. welcome-to-ellipsis)', ) .option( '-o, --config-override ', - 'partial agent config (YAML/JSON) 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 session, e.g. "limits:\\n run: 5"', ) .option( '--config-override-file ', @@ -59,7 +59,7 @@ export function registerRun(program: Command): void { ) .option( '-p, --prompt ', - 'per-run instructions appended to the initial user query for this run', + 'per-session instructions appended to the initial user query for this session', ) .option( '-m, --metadata ', @@ -67,7 +67,7 @@ export function registerRun(program: Command): void { collectKeyValue, {} as Record, ) - .option('-w, --watch', 'stream the run live until it reaches a terminal status') + .option('-w, --watch', 'stream the session live until it reaches a terminal status') .option('--json', 'output raw JSON') .action( async (opts: { @@ -91,51 +91,51 @@ export function registerRun(program: Command): void { if (sources.length > 1) { throw new Error('provide only one of --config / --config-file / --template') } - const req: StartAgentRunRequest = { + const req: StartAgentSessionRequest = { 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. + // limits.run here to override this session's budget. 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. + // Appended to the initial user query at build time; gives this + // session instructions on top of the config's shared system prompt. if (opts.prompt) req.prompt = opts.prompt const api = new ApiClient() - const run = await api.startAgentRun(req) + const session = await api.startAgentSession(req) if (opts.watch) { if (!opts.json) { - console.log(`✓ started run ${run.id}`) - await printRunUrl(api, run.id) + console.log(`✓ started session ${session.id}`) + await printSessionUrl(api, session.id) } - await watchRunStreaming(api, run.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) return } if (opts.json) { - printJson(run) + printJson(session) return } - console.log(`✓ started run ${run.id} (${run.status})`) - await printRunUrl(api, run.id) - console.log(` follow with: agent run get ${run.id} --watch`) + console.log(`✓ started session ${session.id} (${session.status})`) + await printSessionUrl(api, session.id) + console.log(` follow with: agent session get ${session.id} --watch`) }) }, ) - run + session .command('list') - .description('List recent agent runs (GET /v1/agents/runs)') + .description('List recent agent sessions (GET /v1/sessions)') .option('-c, --config-id ', 'filter by config id') .option('-s, --source ', 'filter by source (repeatable)', collect, [] as string[]) .option('-d, --days ', 'look back N days', toInt) .option('--start ', 'start of the time window (ISO 8601)') .option('--end ', 'end of the time window (ISO 8601)') - .option('-l, --limit ', 'max runs to return', toInt, 50) + .option('-l, --limit ', 'max sessions to return', toInt, 50) .option('--json', 'output raw JSON') .action( async (opts: { @@ -148,31 +148,31 @@ export function registerRun(program: Command): void { json?: boolean }) => { await runAction(async () => { - const runs = await new ApiClient().listAgentRuns({ + const sessions = await new ApiClient().listAgentSessions({ config_id: opts.configId, - source: opts.source.length ? (opts.source as AgentRunSource[]) : undefined, + source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, days: opts.days, start: opts.start, end: opts.end, limit: opts.limit, }) if (opts.json) { - printJson(runs) + printJson(sessions) return } - if (runs.length === 0) { - console.log('No runs found.') + if (sessions.length === 0) { + console.log('No sessions found.') return } printTable( ['ID', 'STATUS', 'SOURCE', 'CREATED', 'COST'], - runs.map((r) => [ - r.id, - r.status, - r.source ?? '—', - formatTs(r.created_at), + sessions.map((s) => [ + s.id, + s.status, + s.source ?? '—', + formatTs(s.created_at), usdFromMillicents( - r.cost_tokens + r.cost_sandbox_cpu + r.cost_sandbox_memory + r.cost_fee, + s.cost_tokens + s.cost_sandbox_cpu + s.cost_sandbox_memory + s.cost_fee, ), ]), ) @@ -180,36 +180,36 @@ export function registerRun(program: Command): void { }, ) - run - .command('get ') - .description('Get a single agent run (GET /v1/agents/runs/{id})') - .option('-w, --watch', 'stream the run live until it reaches a terminal status') + session + .command('get ') + .description('Get a single agent session (GET /v1/sessions/{id})') + .option('-w, --watch', 'stream the session live until it reaches a terminal status') .option('--json', 'output raw JSON') - .action(async (runId: string, opts: { watch?: boolean; json?: boolean }) => { + .action(async (sessionId: string, opts: { watch?: boolean; json?: boolean }) => { await runAction(async () => { const api = new ApiClient() if (opts.watch) { - if (!opts.json) await printRunUrl(api, runId) - await watchRunStreaming(api, runId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + if (!opts.json) await printSessionUrl(api, sessionId) + await watchSessionStreaming(api, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) return } if (opts.json) { - printJson(await api.getAgentRun(runId)) + printJson(await api.getAgentSession(sessionId)) return } - // Fetch the run and the login (for the link) together — no added latency. - const [r, me] = await Promise.all([api.getAgentRun(runId), api.whoami()]) - printRunSummary(r) - console.log(`url: ${runUrl(resolveAppBase(), me.customer_login, runId)}`) + // Fetch the session and the login (for the link) together — no added latency. + const [s, me] = await Promise.all([api.getAgentSession(sessionId), api.whoami()]) + printSessionSummary(s) + console.log(`url: ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) }) }) - run - .command('replay ') - .description("Re-run an existing run's trigger input (POST /v1/agents/runs/{id}/replay)") + session + .command('replay ') + .description("Re-run an existing session's trigger input (POST /v1/sessions/{id}/replay)") .option( '-c, --config-id ', - "run against a different saved config instead of the original run's snapshot", + "run against a different saved config instead of the original session's snapshot", ) .option( '-o, --config-override ', @@ -221,13 +221,13 @@ export function registerRun(program: Command): void { ) .option( '-p, --prompt ', - "per-run instructions; omit to inherit the original run's prompt, pass '' to clear it", + "per-session instructions; omit to inherit the original session's prompt, pass '' to clear it", ) - .option('-w, --watch', 'stream the run live until it reaches a terminal status') + .option('-w, --watch', 'stream the session live until it reaches a terminal status') .option('--json', 'output raw JSON') .action( async ( - runId: string, + sessionId: string, opts: { configId?: string configOverride?: string @@ -238,7 +238,7 @@ export function registerRun(program: Command): void { }, ) => { await runAction(async () => { - const req: ReplayAgentRunRequest = {} + const req: ReplayAgentSessionRequest = {} if (opts.configId) req.config_id = opts.configId applyConfigOverride(req, opts) // Distinguish "flag omitted" (inherit the original prompt) from @@ -246,46 +246,51 @@ export function registerRun(program: Command): void { if (opts.prompt !== undefined) req.prompt = opts.prompt const api = new ApiClient() - const run = await api.replayAgentRun(runId, req) + const session = await api.replayAgentSession(sessionId, req) if (opts.watch) { if (!opts.json) { - console.log(`✓ started replay ${run.id} (from ${runId})`) - await printRunUrl(api, run.id) + console.log(`✓ started replay ${session.id} (from ${sessionId})`) + await printSessionUrl(api, session.id) } - await watchRunStreaming(api, run.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) return } if (opts.json) { - printJson(run) + printJson(session) 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`) + console.log(`✓ started replay ${session.id} (${session.status}, from ${sessionId})`) + await printSessionUrl(api, session.id) + console.log(` follow with: agent session get ${session.id} --watch`) }) }, ) - run - .command('stop ') - .description('Stop an in-flight run') - .action((runId: string) => { - // No /v1 endpoint exists yet (deferred). Fail loudly rather than pretend. - console.error( - `stopping runs is not available in the /v1 API yet (run ${runId}). ` + - 'Stop it from the dashboard for now.', - ) - process.exitCode = 1 + session + .command('stop ') + .description('Stop an in-flight session (POST /v1/sessions/{id}/stop)') + .option('--json', 'output raw JSON') + .action(async (sessionId: string, opts: { json?: boolean }) => { + await runAction(async () => { + const api = new ApiClient() + const s = await api.stopAgentSession(sessionId) + if (opts.json) { + printJson(s) + return + } + console.log(`✓ stopped session ${sessionId} (${s.status})`) + }) }) } -// `--watch` entry point: stream the run's output live over WebSocket, and fall -// back to REST status polling if streaming is unavailable (e.g. a backend -// without the endpoint). Identical UX either way — the same flag covers both. -export async function watchRunStreaming( +// `--watch` entry point: stream the session's output live over WebSocket, and +// fall back to REST status polling if streaming is unavailable (e.g. a +// backend without the endpoint). Identical UX either way — the same flag +// covers both. +export async function watchSessionStreaming( api: ApiClient, - runId: string, + sessionId: string, intervalSeconds: number, json?: boolean, ): Promise { @@ -309,7 +314,7 @@ export async function watchRunStreaming( let outcome: StreamOutcome try { - outcome = await streamRun({ token, runId, wsBase, onFrame }) + outcome = await streamSession({ token, sessionId, wsBase, onFrame }) } catch (err) { if (err instanceof StreamUnavailableError) { if (!json) { @@ -317,7 +322,7 @@ export async function watchRunStreaming( `live stream unavailable (${err.message}); falling back to status polling`, ) } - await watchRun(api, runId, intervalSeconds, json) + await watchSession(api, sessionId, intervalSeconds, json) return } throw err // StreamAuthError and anything unexpected: surfaced by runAction. @@ -331,7 +336,7 @@ export async function watchRunStreaming( // Terminal `done` frame. Output already streamed live; print a one-line cap. if (!json) { const mark = outcome.status === 'completed' ? '✓' : '✗' - console.log(`\n${mark} run ${runId} ${outcome.status}`) + console.log(`\n${mark} session ${sessionId} ${outcome.status}`) } if (exitCodeForStatus(outcome.status) !== 0) process.exitCode = 1 } @@ -365,32 +370,32 @@ export function exitCodeForStatus(status: string): number { return status === 'completed' ? 0 : 1 } -// Poll a run until it reaches a terminal status, printing each status +// Poll a session until it reaches a terminal status, printing each status // transition. This is the status-level fallback used when live streaming isn't -// available: the /v1 REST API exposes run state, not the step-by-step stream. -export async function watchRun( +// available: the /v1 REST API exposes session state, not the step-by-step stream. +export async function watchSession( api: ApiClient, - runId: string, + sessionId: string, intervalSeconds: number, json?: boolean, ): Promise { const intervalMs = Math.max(1, intervalSeconds) * 1000 - let last: AgentRunStatus | undefined + let last: AgentSessionStatus | undefined for (;;) { - const r = await api.getAgentRun(runId) - if (r.status !== last) { + const s = await api.getAgentSession(sessionId) + if (s.status !== last) { if (!json) { - const reason = r.status_reason ? ` — ${r.status_reason}` : '' - console.log(`${nowClock()} ${r.status}${reason}`) + const reason = s.status_reason ? ` — ${s.status_reason}` : '' + console.log(`${nowClock()} ${s.status}${reason}`) } - last = r.status + last = s.status } - if (TERMINAL_STATUSES.has(r.status)) { + if (TERMINAL_STATUSES.has(s.status)) { if (json) { - printJson(r) + printJson(s) } else { console.log('') - printRunSummary(r) + printSessionSummary(s) } return } @@ -398,34 +403,34 @@ export async function watchRun( } } -function printRunSummary(r: AgentRun): void { - console.log(`id: ${r.id}`) - console.log(`status: ${r.status}${r.status_reason ? ` (${r.status_reason})` : ''}`) - if (r.source) console.log(`source: ${r.source}`) - if (r.agent_config_id) console.log(`config: ${r.agent_config_id}`) - console.log(`created: ${r.created_at}`) - console.log(`updated: ${r.updated_at}`) - console.log(`tokens: ${r.tokens_total.toLocaleString()}`) +function printSessionSummary(s: AgentSession): void { + console.log(`id: ${s.id}`) + console.log(`status: ${s.status}${s.status_reason ? ` (${s.status_reason})` : ''}`) + if (s.source) console.log(`source: ${s.source}`) + if (s.agent_config_id) console.log(`config: ${s.agent_config_id}`) + console.log(`created: ${s.created_at}`) + console.log(`updated: ${s.updated_at}`) + console.log(`tokens: ${s.tokens_total.toLocaleString()}`) console.log( `cost: ${usdFromMillicents( - r.cost_tokens + r.cost_sandbox_cpu + r.cost_sandbox_memory + r.cost_fee, + s.cost_tokens + s.cost_sandbox_cpu + s.cost_sandbox_memory + s.cost_fee, )}`, ) - const keys = Object.keys(r.metadata ?? {}) + const keys = Object.keys(s.metadata ?? {}) if (keys.length) { console.log('metadata:') - for (const k of keys) console.log(` ${k}=${r.metadata[k]}`) + for (const k of keys) console.log(` ${k}=${s.metadata[k]}`) } } -// Print a clickable dashboard link for a run. The route is scoped by account -// login, which isn't on the run object, so resolve it from /v1/me. -async function printRunUrl(api: ApiClient, runId: string): Promise { +// Print a clickable dashboard link for a session. The route is scoped by +// account login, which isn't on the session object, so resolve it from /v1/me. +async function printSessionUrl(api: ApiClient, sessionId: string): Promise { const me = await api.whoami() - console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`) + console.log(` ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) } -// Apply the mutually-exclusive config-override flags onto a run request. +// Apply the mutually-exclusive config-override flags onto a session 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. diff --git a/src/commands/template.ts b/src/commands/template.ts index 3449c2b..730560a 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -9,7 +9,7 @@ export function registerTemplate(program: Command): void { template .command('list') - .description('List built-in agent templates (GET /v1/agents/templates)') + .description('List built-in agent templates (GET /v1/templates)') .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index f4e77a5..6497a7a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,7 @@ import { resolveApiBase, resolveToken } from './config' import { USER_AGENT } from './constants' import type { - AgentRun, + AgentSession, AgentTemplate, BudgetSummary, CliAuthPoll, @@ -10,14 +10,14 @@ import type { CreatedAgentConfig, GetSandboxVariablesResponse, ListAgentConfigsResponse, - ListAgentRunsQuery, - ListAgentRunsResponse, + ListAgentSessionsQuery, + ListAgentSessionsResponse, ListAgentTemplatesResponse, - ReplayAgentRunRequest, + ReplayAgentSessionRequest, SandboxVariableInput, SandboxVariableSummary, SavedAgentConfig, - StartAgentRunRequest, + StartAgentSessionRequest, UsageDashboard, WhoAmI, } from './types' @@ -97,52 +97,53 @@ export class ApiClient { return this.request('GET', '/v1/usage') } - // ------------------------------ agent runs ------------------------------ + // ---------------------------- agent sessions ----------------------------- - startAgentRun(req: StartAgentRunRequest): Promise { - return this.request('POST', '/v1/agents/runs', req) + startAgentSession(req: StartAgentSessionRequest): Promise { + return this.request('POST', '/v1/sessions', req) } - async listAgentRuns(query?: ListAgentRunsQuery): Promise { - const res = await this.request( + async listAgentSessions(query?: ListAgentSessionsQuery): Promise { + const res = await this.request( 'GET', - '/v1/agents/runs', + '/v1/sessions', undefined, query as Record | undefined, ) - return res.runs + return res.sessions } - getAgentRun(runId: string): Promise { - return this.request('GET', `/v1/agents/runs/${encodeURIComponent(runId)}`) + getAgentSession(sessionId: string): Promise { + return this.request('GET', `/v1/sessions/${encodeURIComponent(sessionId)}`) } - replayAgentRun(runId: string, req: ReplayAgentRunRequest): Promise { + replayAgentSession(sessionId: string, req: ReplayAgentSessionRequest): Promise { return this.request( 'POST', - `/v1/agents/runs/${encodeURIComponent(runId)}/replay`, + `/v1/sessions/${encodeURIComponent(sessionId)}/replay`, req, ) } + stopAgentSession(sessionId: string): Promise { + return this.request('POST', `/v1/sessions/${encodeURIComponent(sessionId)}/stop`) + } + // ----------------------------- agent configs ---------------------------- async listAgentConfigs(): Promise { - const res = await this.request( - 'GET', - '/v1/agents/configs', - ) + const res = await this.request('GET', '/v1/configs') return res.configs } // Opens a pull request that adds the config's YAML to the repo's agents/ // directory; the agent goes live once it merges and syncs. createAgentConfig(req: CreateAgentConfigRequest): Promise { - return this.request('POST', '/v1/agents/configs', req) + return this.request('POST', '/v1/configs', req) } getAgentConfig(configId: string): Promise { - return this.request('GET', `/v1/agents/configs/${encodeURIComponent(configId)}`) + return this.request('GET', `/v1/configs/${encodeURIComponent(configId)}`) } // -------------------------- sandbox variables --------------------------- @@ -179,15 +180,12 @@ export class ApiClient { // ---------------------------- agent templates --------------------------- async listAgentTemplates(): Promise { - const res = await this.request( - 'GET', - '/v1/agents/templates', - ) + const res = await this.request('GET', '/v1/templates') return res.templates } getAgentTemplate(slug: string): Promise { - return this.request('GET', `/v1/agents/templates/${encodeURIComponent(slug)}`) + return this.request('GET', `/v1/templates/${encodeURIComponent(slug)}`) } // --------------------------- device-code auth --------------------------- diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a30203a..eae873b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,8 +6,9 @@ import pkg from '../../package.json' 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. +// started a session (stored on the session as client_version, shown for +// support). Not a security boundary — the server derives a session'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 diff --git a/src/lib/types.ts b/src/lib/types.ts index b70bf04..6a56862 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -74,29 +74,30 @@ export interface UsageDashboard { by_model: ModelUsageBreakdown[] } -// ------------------------------ agent runs ------------------------------ +// ----------------------------- agent sessions ---------------------------- -export type AgentRunSource = 'react' | 'manual' | 'api' | 'cli' | 'mention' | 'cron' +export type AgentSessionSource = 'react' | 'manual' | 'api' | 'cli' | 'mention' | 'cron' -export type AgentRunStatus = +export type AgentSessionStatus = | 'scheduled' | 'creating_sandbox' | 'running' + | 'retrying' | 'completed' | 'error' | 'cancelled' | 'stopped' // Loosely typed: the CLI reads a handful of summary fields and otherwise treats -// the run as opaque JSON. See AgentRun in the backend for the full shape. -export interface AgentRun { +// the session as opaque JSON. See AgentSession in the backend for the full shape. +export interface AgentSession { id: string customer_id: string created_at: string updated_at: string - status: AgentRunStatus + status: AgentSessionStatus status_reason: string | null - source?: AgentRunSource + source?: AgentSessionSource agent_config_id: string | null cost_tokens: number cost_sandbox_cpu: number @@ -113,61 +114,62 @@ export interface SavedAgentConfig { created_at: string updated_at: string deleted: boolean - last_job_run_id: string | null + last_agent_session_id: string | null + last_agent_session_created_at: string | null last_synced_commit_sha: string | null last_sync_error: string | null agent_config: Record [key: string]: unknown } -// Inline agent config payload accepted by POST /v1/agents/runs. Opaque to the +// Inline agent config payload accepted by POST /v1/sessions. Opaque to the // CLI — passed straight through from a user-supplied JSON file. export type AgentConfig = Record // --------------------------- request / response ------------------------- -export interface StartAgentRunRequest { +export interface StartAgentSessionRequest { config_id?: string config?: AgentConfig template_id?: string - // 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. + // No `source`: the server derives a session's provenance from the credential + // (a user token => `cli`), so it can't be spoofed by the request body. metadata?: Record // 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. + // server-side, e.g. raise just this session'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 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. + // Per-session 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 session 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 { +// Replay payload for POST /v1/sessions/{id}/replay. Re-runs an existing +// session's trigger input. Reuses the original session's frozen config +// snapshot unless config_id is given. The override fields behave exactly as on +// StartAgentSessionRequest (mapping or string, not both). `prompt` is omitted +// to inherit the original session's prompt, set to "" to clear it. +export interface ReplayAgentSessionRequest { config_id?: string config_override?: Record config_override_yaml?: string prompt?: string } -export interface ListAgentRunsResponse { - runs: AgentRun[] +export interface ListAgentSessionsResponse { + sessions: AgentSession[] } export interface ListAgentConfigsResponse { configs: SavedAgentConfig[] } -// Create-config payload for POST /v1/agents/configs. Exactly one of `config` -// (inline) or `template_id` (a gallery template slug). `repository` is a bare -// repo name in the caller's account — the owner is always the account. +// Create-config payload for POST /v1/configs. Exactly one of `config` (inline) +// or `template_id` (a gallery template slug). `repository` is a bare repo name +// in the caller's account — the owner is always the account. export interface CreateAgentConfigRequest { config?: AgentConfig template_id?: string @@ -186,7 +188,7 @@ export interface CreatedAgentConfig { pull_request_url: string } -// A built-in starter template served by GET /v1/agents/templates. `yaml` is the +// A built-in starter template served by GET /v1/templates. `yaml` is the // schema-valid agent config the CLI writes to disk; the rest is display copy. export interface AgentTemplate { slug: string @@ -202,9 +204,9 @@ export interface ListAgentTemplatesResponse { templates: AgentTemplate[] } -export interface ListAgentRunsQuery { +export interface ListAgentSessionsQuery { config_id?: string - source?: AgentRunSource[] + source?: AgentSessionSource[] days?: number start?: string end?: string diff --git a/src/lib/urls.ts b/src/lib/urls.ts index ae8f92a..db7945a 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -3,11 +3,11 @@ // and the customer's account login (from GET /v1/me — the routes are scoped by // login). Mirrors the backend's link format in github_brand.py. -export function runUrl(appBase: string, accountLogin: string, runId: string): string { - return `${appBase}/${encodeURIComponent(accountLogin)}/agents/runs/${encodeURIComponent(runId)}` +export function sessionUrl(appBase: string, accountLogin: string, sessionId: string): string { + return `${appBase}/${encodeURIComponent(accountLogin)}/sessions/${encodeURIComponent(sessionId)}` } // The agent (config) detail page is keyed by the config id (agent_id == config_id). export function configUrl(appBase: string, accountLogin: string, configId: string): string { - return `${appBase}/${encodeURIComponent(accountLogin)}/agents/${encodeURIComponent(configId)}` + return `${appBase}/${encodeURIComponent(accountLogin)}/agents/configs/${encodeURIComponent(configId)}` } diff --git a/src/lib/ws.ts b/src/lib/ws.ts index b5bff1f..df118f0 100644 --- a/src/lib/ws.ts +++ b/src/lib/ws.ts @@ -2,14 +2,14 @@ import WebSocket from 'ws' import { resolveApiBase } from './config' 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. +// The frame protocol spoken over the session WebSocket (server -> client). One +// JSON object per message. Mirrors session_stream.py in the backend. // status: { type, status, ts } // stdout/stderr: { type, data, seq, ts } // done: { type, status, exit_status } // error: { type, message } -// `seq` is a monotonic per-run cursor; the client resumes from the last seq it -// saw via `?after_seq=` so a dropped socket loses nothing. +// `seq` is a monotonic per-session cursor; the client resumes from the last seq +// it saw via `?after_seq=` so a dropped socket loses nothing. export interface StreamFrame { type: 'stdout' | 'stderr' | 'status' | 'done' | 'error' data?: string @@ -20,7 +20,7 @@ export interface StreamFrame { exit_status?: string | null } -// How streamRun() finished. `done`/`error` are normal terminal outcomes; +// How streamSession() finished. `done`/`error` are normal terminal outcomes; // `aborted` means the caller cancelled via the AbortSignal. export type StreamOutcome = | { type: 'done'; status: string; exitStatus?: string | null } @@ -36,7 +36,7 @@ export class StreamUnavailableError extends Error { } } -// Thrown when the server rejects the credential for this run (close 1008). +// Thrown when the server rejects the credential for this session (close 1008). // Polling would fail the same way, so this is not a fallback case. export class StreamAuthError extends Error { constructor(message: string) { @@ -47,7 +47,7 @@ export class StreamAuthError extends Error { export interface StreamOptions { token: string - runId: string + sessionId: string onFrame: (frame: StreamFrame) => void wsBase?: string afterSeq?: number @@ -57,7 +57,7 @@ export interface StreamOptions { connect?: SocketFactory } -// Minimal socket surface streamRun() depends on, so tests can drive the +// Minimal socket surface streamSession() depends on, so tests can drive the // reconnect/resume logic without a real server. export interface StreamSocket { onOpen(cb: () => void): void @@ -154,8 +154,8 @@ export function resolveWsBase(apiBase?: string): string { return DEFAULT_WS_BASE } -export function buildStreamUrl(wsBase: string, runId: string, afterSeq: number): string { - const url = `${wsBase}/v1/runs/${encodeURIComponent(runId)}/stream` +export function buildStreamUrl(wsBase: string, sessionId: string, afterSeq: number): string { + const url = `${wsBase}/v1/sessions/${encodeURIComponent(sessionId)}/stream` return afterSeq > 0 ? `${url}?after_seq=${afterSeq}` : url } @@ -244,11 +244,12 @@ function sleep(ms: number, signal?: AbortSignal): Promise { // ------------------------------ public API --------------------------------- -// Stream an agent run's output to completion, reconnecting with backoff and -// resuming from the last seen `seq` so a dropped socket loses no frames. Calls -// `onFrame` for every frame received. Resolves with the terminal outcome, or -// throws StreamUnavailableError (caller should poll instead) / StreamAuthError. -export async function streamRun(opts: StreamOptions): Promise { +// Stream an agent session's output to completion, reconnecting with backoff +// and resuming from the last seen `seq` so a dropped socket loses no frames. +// Calls `onFrame` for every frame received. Resolves with the terminal +// outcome, or throws StreamUnavailableError (caller should poll instead) / +// StreamAuthError. +export async function streamSession(opts: StreamOptions): Promise { const wsBase = opts.wsBase ?? resolveWsBase() const factory = opts.connect ?? defaultFactory const maxReconnects = opts.maxReconnects ?? DEFAULT_MAX_RECONNECTS @@ -265,7 +266,7 @@ export async function streamRun(opts: StreamOptions): Promise { for (;;) { if (opts.signal?.aborted) return { type: 'aborted' } - const url = buildStreamUrl(wsBase, opts.runId, afterSeq) + const url = buildStreamUrl(wsBase, opts.sessionId, afterSeq) const res = await connectOnce(url, opts.token, factory, emit, opts.signal) if (res.kind === 'done') { @@ -283,7 +284,7 @@ export async function streamRun(opts: StreamOptions): Promise { maxReconnects, }) if (decision.action === 'fail-auth') { - throw new StreamAuthError('not authorized to stream this run') + throw new StreamAuthError('not authorized to stream this session') } if (decision.action === 'fallback') { const why = diff --git a/src/ui/RunView.tsx b/src/ui/SessionView.tsx similarity index 81% rename from src/ui/RunView.tsx rename to src/ui/SessionView.tsx index b5807bd..08ae0ca 100644 --- a/src/ui/RunView.tsx +++ b/src/ui/SessionView.tsx @@ -1,14 +1,14 @@ import React, { useEffect, useState } from 'react' import { Box, Text, useApp } from 'ink' import Spinner from 'ink-spinner' -import { streamRun, type StreamFrame } from '../lib/ws' +import { streamSession, type StreamFrame } from '../lib/ws' interface Props { - runId: string + sessionId: string token: string } -export function RunView({ runId, token }: Props): React.ReactElement { +export function SessionView({ sessionId, token }: Props): React.ReactElement { const { exit } = useApp() const [lines, setLines] = useState([]) const [status, setStatus] = useState('connecting') @@ -26,7 +26,7 @@ export function RunView({ runId, token }: Props): React.ReactElement { break } } - streamRun({ token, runId, onFrame, signal: controller.signal }) + streamSession({ token, sessionId, onFrame, signal: controller.signal }) .then((outcome) => { if (outcome.type === 'error') setStatus(`error: ${outcome.message}`) else if (outcome.type === 'done') setStatus('done') @@ -37,7 +37,7 @@ export function RunView({ runId, token }: Props): React.ReactElement { exit() }) return () => controller.abort() - }, [runId, token, exit]) + }, [sessionId, token, exit]) const done = status === 'done' @@ -47,7 +47,7 @@ export function RunView({ runId, token }: Props): React.ReactElement { {done ? : } {' '} - run {runId} — {status} + session {sessionId}: {status} {lines.map((line, i) => ( diff --git a/test/api.test.ts b/test/api.test.ts index 76d9b12..9d0f20c 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -91,11 +91,13 @@ describe('ApiClient.request', () => { }) it('appends the query string', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ runs: [] }), { status: 200 })) + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ sessions: [] }), { status: 200 }), + ) vi.stubGlobal('fetch', fetchMock) - await new ApiClient('http://api.test', 't').listAgentRuns({ limit: 5, source: ['cli'] }) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/agents/runs?limit=5&source=cli') + await new ApiClient('http://api.test', 't').listAgentSessions({ limit: 5, source: ['cli'] }) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/sessions?limit=5&source=cli') }) it('throws ApiError carrying status + server detail on non-2xx', async () => { @@ -190,21 +192,21 @@ describe('ApiClient sandbox variables', () => { }) }) -describe('replayAgentRun', () => { +describe('replayAgentSession', () => { afterEach(() => vi.unstubAllGlobals()) - it('POSTs to the run-scoped replay path (encoded) with the body', async () => { + it('POSTs to the session-scoped replay path (encoded) with the body', async () => { const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ id: 'run_2' }), { status: 200 }), + async () => new Response(JSON.stringify({ id: 'session_2' }), { status: 200 }), ) vi.stubGlobal('fetch', fetchMock) - const out = await new ApiClient('http://api.test', 't').replayAgentRun('run/1', { + const out = await new ApiClient('http://api.test', 't').replayAgentSession('session/1', { config_override: { claude: { model: 'claude-opus-4-8' } }, }) - expect(out.id).toBe('run_2') + expect(out.id).toBe('session_2') const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/v1/agents/runs/run%2F1/replay') + expect(url).toBe('http://api.test/v1/sessions/session%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' } }, @@ -212,6 +214,23 @@ describe('replayAgentRun', () => { }) }) +describe('stopAgentSession', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('POSTs to the session-scoped stop path (encoded) and returns the session', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ id: 'session_1', status: 'stopped' }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').stopAgentSession('session/1') + expect(out).toEqual({ id: 'session_1', status: 'stopped' }) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/sessions/session%2F1/stop') + expect((init as RequestInit).method).toBe('POST') + }) +}) + describe('agent templates', () => { afterEach(() => vi.unstubAllGlobals()) @@ -226,7 +245,7 @@ describe('agent templates', () => { const out = await new ApiClient('http://api.test', 't').listAgentTemplates() expect(out.map((t) => t.slug)).toEqual(['a', 'b']) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/agents/templates') + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/templates') }) it('fetches a single template by slug (encoded)', async () => { @@ -238,9 +257,7 @@ describe('agent templates', () => { const out = await new ApiClient('http://api.test', 't').getAgentTemplate('ci-failure-triager') expect(out.yaml).toBe('x') - expect(fetchMock.mock.calls[0][0]).toBe( - 'http://api.test/v1/agents/templates/ci-failure-triager', - ) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/templates/ci-failure-triager') }) }) @@ -267,7 +284,7 @@ describe('createAgentConfig', () => { }) expect(out.pull_request_url).toBe('https://github.com/octocat/api/pull/7') const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/v1/agents/configs') + expect(url).toBe('http://api.test/v1/configs') expect((init as RequestInit).method).toBe('POST') expect(JSON.parse((init as RequestInit).body as string)).toEqual({ template_id: 'ci-failure-triager', diff --git a/test/run.test.ts b/test/session.test.ts similarity index 80% rename from test/run.test.ts rename to test/session.test.ts index 242ce56..e049f9f 100644 --- a/test/run.test.ts +++ b/test/session.test.ts @@ -2,13 +2,13 @@ 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 { applyConfigOverride, readConfigFile, watchRun } from '../src/commands/run' +import { applyConfigOverride, readConfigFile, watchSession } from '../src/commands/session' import type { ApiClient } from '../src/lib/api' -import type { AgentRun, AgentRunStatus } from '../src/lib/types' +import type { AgentSession, AgentSessionStatus } from '../src/lib/types' -function run(status: AgentRunStatus): AgentRun { +function session(status: AgentSessionStatus): AgentSession { return { - id: 'run_1', + id: 'session_1', customer_id: 'c', created_at: '2026-06-25T00:00:00+00:00', updated_at: '2026-06-25T00:00:00+00:00', @@ -24,7 +24,7 @@ function run(status: AgentRunStatus): AgentRun { } } -describe('watchRun', () => { +describe('watchSession', () => { beforeEach(() => { vi.useFakeTimers() vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -37,33 +37,33 @@ describe('watchRun', () => { it('polls until a terminal status, then stops', async () => { const get = vi .fn() - .mockResolvedValueOnce(run('running')) - .mockResolvedValueOnce(run('running')) - .mockResolvedValueOnce(run('completed')) - const api = { getAgentRun: get } as unknown as ApiClient + .mockResolvedValueOnce(session('running')) + .mockResolvedValueOnce(session('running')) + .mockResolvedValueOnce(session('completed')) + const api = { getAgentSession: get } as unknown as ApiClient - const promise = watchRun(api, 'run_1', 1, true) + const promise = watchSession(api, 'session_1', 1, true) await vi.advanceTimersByTimeAsync(1000) // 1st poll running -> sleep -> 2nd poll await vi.advanceTimersByTimeAsync(1000) // -> 3rd poll completed -> return await promise expect(get).toHaveBeenCalledTimes(3) - expect(get).toHaveBeenCalledWith('run_1') + expect(get).toHaveBeenCalledWith('session_1') }) - it('returns immediately when the run is already terminal', async () => { - const get = vi.fn().mockResolvedValueOnce(run('error')) - const api = { getAgentRun: get } as unknown as ApiClient + it('returns immediately when the session is already terminal', async () => { + const get = vi.fn().mockResolvedValueOnce(session('error')) + const api = { getAgentSession: get } as unknown as ApiClient - await watchRun(api, 'run_1', 5, true) // no timer advance needed + await watchSession(api, 'session_1', 5, true) // no timer advance needed expect(get).toHaveBeenCalledTimes(1) }) it('treats stopped/cancelled as terminal', async () => { - for (const status of ['stopped', 'cancelled'] as AgentRunStatus[]) { - const get = vi.fn().mockResolvedValueOnce(run(status)) - const api = { getAgentRun: get } as unknown as ApiClient - await watchRun(api, 'run_1', 5, true) + for (const status of ['stopped', 'cancelled'] as AgentSessionStatus[]) { + const get = vi.fn().mockResolvedValueOnce(session(status)) + const api = { getAgentSession: get } as unknown as ApiClient + await watchSession(api, 'session_1', 5, true) expect(get).toHaveBeenCalledTimes(1) } }) diff --git a/test/urls.test.ts b/test/urls.test.ts index 74c69c0..3a8f808 100644 --- a/test/urls.test.ts +++ b/test/urls.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest' -import { configUrl, runUrl } from '../src/lib/urls' +import { configUrl, sessionUrl } from '../src/lib/urls' -describe('runUrl', () => { - it('builds the run detail path scoped by account login', () => { - expect(runUrl('https://app.ellipsis.dev', 'octocat', 'run_8f2c')).toBe( - 'https://app.ellipsis.dev/octocat/agents/runs/run_8f2c', +describe('sessionUrl', () => { + it('builds the session detail path scoped by account login', () => { + expect(sessionUrl('https://app.ellipsis.dev', 'octocat', 'session_8f2c')).toBe( + 'https://app.ellipsis.dev/octocat/sessions/session_8f2c', ) }) - it('encodes the login and run id', () => { - expect(runUrl('https://app.ellipsis.dev', 'a/b', 'r d')).toBe( - 'https://app.ellipsis.dev/a%2Fb/agents/runs/r%20d', + it('encodes the login and session id', () => { + expect(sessionUrl('https://app.ellipsis.dev', 'a/b', 's d')).toBe( + 'https://app.ellipsis.dev/a%2Fb/sessions/s%20d', ) }) }) @@ -18,7 +18,7 @@ describe('runUrl', () => { describe('configUrl', () => { it('builds the agent (config) detail path scoped by account login', () => { expect(configUrl('https://app.ellipsis.dev', 'octocat', 'cfg_123')).toBe( - 'https://app.ellipsis.dev/octocat/agents/cfg_123', + 'https://app.ellipsis.dev/octocat/agents/configs/cfg_123', ) }) }) diff --git a/test/ws.test.ts b/test/ws.test.ts index 09d60ce..b1a9c33 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -5,7 +5,7 @@ import { decideReconnect, nextReconnectDelayMs, resolveWsBase, - streamRun, + streamSession, StreamAuthError, StreamUnavailableError, type SocketFactory, @@ -108,8 +108,8 @@ describe('pure helpers', () => { }) it('builds the stream URL with an optional after_seq cursor', () => { - expect(buildStreamUrl('wss://h', 'run 1', 0)).toBe('wss://h/v1/runs/run%201/stream') - expect(buildStreamUrl('wss://h', 'r', 7)).toBe('wss://h/v1/runs/r/stream?after_seq=7') + expect(buildStreamUrl('wss://h', 'session 1', 0)).toBe('wss://h/v1/sessions/session%201/stream') + expect(buildStreamUrl('wss://h', 's', 7)).toBe('wss://h/v1/sessions/s/stream?after_seq=7') }) it('resolves the ws base from env, then derives it from the api base', () => { @@ -121,9 +121,9 @@ describe('pure helpers', () => { }) }) -// ------------------------------ streamRun ----------------------------------- +// ---------------------------- streamSession --------------------------------- -describe('streamRun', () => { +describe('streamSession', () => { beforeEach(() => { vi.useFakeTimers() }) @@ -135,9 +135,9 @@ describe('streamRun', () => { it('emits every frame and resolves on done', async () => { const { factory, sockets } = makeFactory() const frames: StreamFrame[] = [] - const p = streamRun({ + const p = streamSession({ token: 't', - runId: 'r', + sessionId: 's', wsBase: 'ws://x', connect: factory, onFrame: (f) => frames.push(f), @@ -155,9 +155,9 @@ describe('streamRun', () => { it('reconnects after a drop and resumes from the last seq (no loss/dupes)', async () => { const { factory, sockets } = makeFactory() - const p = streamRun({ + const p = streamSession({ token: 't', - runId: 'r', + sessionId: 's', wsBase: 'ws://x', connect: factory, onFrame: () => {}, @@ -169,7 +169,7 @@ describe('streamRun', () => { await vi.advanceTimersByTimeAsync(500) // backoff for attempt 1 expect(sockets).toHaveLength(2) - expect(sockets[1].url).toBe('ws://x/v1/runs/r/stream?after_seq=2') + expect(sockets[1].url).toBe('ws://x/v1/sessions/s/stream?after_seq=2') sockets[1].sock.emitFrame({ type: 'done', status: 'completed', exit_status: null }) const outcome = await p @@ -178,21 +178,21 @@ describe('streamRun', () => { it('surfaces a server error frame as an error outcome (not a fallback)', async () => { const { factory, sockets } = makeFactory() - const p = streamRun({ token: 't', runId: 'r', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) + const p = streamSession({ token: 't', sessionId: 's', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) sockets[0].sock.emitFrame({ type: 'error', message: 'boom' }) expect(await p).toEqual({ type: 'error', message: 'boom' }) }) it('falls back (throws StreamUnavailableError) on an unsupported close', async () => { const { factory, sockets } = makeFactory() - const p = streamRun({ token: 't', runId: 'r', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) + const p = streamSession({ token: 't', sessionId: 's', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) sockets[0].sock.emitClose(1003) await expect(p).rejects.toBeInstanceOf(StreamUnavailableError) }) it('falls back when the socket never connects after a couple of tries', async () => { const { factory, sockets } = makeFactory() - const p = streamRun({ token: 't', runId: 'r', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) + const p = streamSession({ token: 't', sessionId: 's', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) const rejection = expect(p).rejects.toBeInstanceOf(StreamUnavailableError) sockets[0].sock.emitError(new Error('ECONNREFUSED')) await vi.advanceTimersByTimeAsync(500) @@ -203,7 +203,7 @@ describe('streamRun', () => { it('fails hard (StreamAuthError) on an auth-rejected close', async () => { const { factory, sockets } = makeFactory() - const p = streamRun({ token: 't', runId: 'r', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) + const p = streamSession({ token: 't', sessionId: 's', wsBase: 'ws://x', connect: factory, onFrame: () => {} }) sockets[0].sock.emitClose(1008) await expect(p).rejects.toBeInstanceOf(StreamAuthError) })