diff --git a/README.md b/README.md index e8c3a95..a0b13cc 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,12 @@ ellipsis session start --config-file f.json # ...or from an inline config ellipsis session start --template ellipsis-helper # ...or from a maintained template ellipsis session start --budget 5 "..." # cap this session's spend, in dollars ellipsis session start --image shot.png "..." # the agent sees the picture on its first turn -ellipsis session start --watch "..." # start and immediately stream it +ellipsis session start --watch "..." # start and stream it until its opening turn ends ellipsis session list --limit 20 # list recent sessions (filter by --source, --author, --since, …) ellipsis session get # inspect one session (prints a dashboard link) -ellipsis session get --watch # follow a session until it finishes +ellipsis session get --watch # follow the turn in progress until it ends ellipsis session record # read a session's stored transcript, one line per record -ellipsis session stop # stop an in-flight session +ellipsis session stop # stop a session's turn in progress ellipsis review 123 # review a pull request now, instead of waiting for a push ellipsis review get # a review's findings, scope, and whether it posted @@ -108,11 +108,6 @@ ellipsis slack members # workspace members, with linked GitHub ide ellipsis linear teams # teams in the connected Linear organization ellipsis sentry orgs # connected Sentry organizations -ellipsis file upload shot.png # store a PNG; prints an org-gated link to paste into a PR comment -ellipsis file list # list stored files (--session scopes to one run's uploads) -ellipsis file get -o shot.png # show one file, or download its bytes with -o -ellipsis file delete # delete a file (it disappears from list/get and its link stops resolving) - ellipsis variable list # list sandbox env variable names (values are write-only) ellipsis variable set A=1 B=2 # create/update variables (or --from-file .env/.json) ellipsis variable delete K # delete a variable @@ -139,9 +134,10 @@ the public REST API. Point it at a different instance durably with legacy `ELLIPSIS_API_BASE`). `--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. How the stream works is described in +output live over WebSocket until the turn it is waiting on ends (`completed`, +`failed`, `stopped`, or `cancelled`), falling back to polling the turn if the +live stream is unavailable. It exits 0 only for `completed`. Either way it +first prints a clickable dashboard link. How the stream works is described in [`docs/SESSION_STREAMING.md`](docs/SESSION_STREAMING.md). ### Auth diff --git a/bun.lock b/bun.lock index 56ea5d5..efcfc01 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "0.30.0", + "@ellipsis-dev/sdk": "0.31.0", "commander": "^12.1.0", "ws": "^8.18.0", "yaml": "^2.9.0", @@ -21,7 +21,7 @@ }, }, "packages": { - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.30.0", "", {}, "sha512-UfLdqiekpM9myblwFXamztC4+UtP2oU2xGtB0H6XCUbEEHHDvKrEzT4eotf/F4rvZcS9HMXvSOXC7APjym2CRw=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.31.0", "", {}, "sha512-wYcVrZT1Mu6Oz8Z7rHgE59279rZ0vncuIFJk4iV5wGTgL/hkc7h1+36pNGw8P9e7Ta7q1dmB6vk0Di0KK0/CEA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/SESSION_STREAMING.md b/docs/SESSION_STREAMING.md index 5c7c890..7efe14d 100644 --- a/docs/SESSION_STREAMING.md +++ b/docs/SESSION_STREAMING.md @@ -1,13 +1,17 @@ -# Session streaming: how `--watch` follows a session +# Session streaming: how `--watch` follows a turn `ellipsis session start --watch` and `ellipsis session get --watch` follow a -session's output live until it reaches a terminal status. The stream is -read-only: the CLI never sends anything to the session. Stopping one is -`ellipsis session stop`. +session's output live until the turn they are waiting on ends. For `start` +that is the opening turn the start created (a promptless start has none, so +there is nothing to wait for). For `get` it is the turn in progress: the +running turn, else the pending one. A session with no turn in progress has +nothing to wait for either: `get --watch` prints the latest turn's status and +exits. The stream is read-only: the CLI never sends anything to the session. +Stopping a turn is `ellipsis session stop`. The WebSocket client is `streamSession` from `@ellipsis-dev/sdk/stream`. This repo owns only the transport adapter (`src/lib/stream.ts`) and the rendering -(`watchSessionStreaming` in `src/commands/session.ts`). +(`streamTurn` in `src/commands/session.ts`). ## Endpoint @@ -35,17 +39,29 @@ the SDK package. | Frame | Payload | What the watch log does with it | | --- | --- | --- | -| `snapshot` | `session`, `messages`, `earliest_feed_seq`, `protocol` | prints the status word when it changes | -| `session` | `session`, the whole row, resent on any change | same: collapsed to status-word transitions | -| `records_append` | `records`, feed-ordered `SessionRecord`s | one line per transcript item, via `recordToItems` from `@ellipsis-dev/sdk/store` | +| `snapshot` | `session`, `messages`, `earliest_feed_seq`, `protocol` | prints the awaited turn's status when it changes | +| `session` | `session`, the whole object, resent on any change | same: collapsed to the awaited turn's status transitions; a final status ends the watch | +| `records_append` | `records`, feed-ordered `SessionRecord`s | one line per transcript item, via `recordToItems` from `@ellipsis-dev/sdk/store`; a `turn_ended` record for the awaited turn ends the watch | | `delta` | ephemeral partial output for a turn | skipped: the committed record supersedes it | | `heartbeat` | `ts` | skipped: liveness only | | `error` | `message` | printed to stderr; the watch ends with exit code 1 | -| `done` | none | ends the watch; the last seen status decides the exit code | +| `done` | none | the conversation closed, which happens only after its turn ended; the watch ends | + +The stream itself stays open for the whole conversation. A watch wants one +turn of it, so it closes the socket as soon as that turn's end arrives (its +`turn_ended` record, or a `session` frame carrying the turn's final status), +and resolves the turn with `GET /v1/sessions/{id}/turns/{turn_id}` if the +stream ended first. + +Platform records render with plain wording: environment preparation +(`environment_phase`), the customer's own hook output (`environment_output`), +`Environment ready`, how a turn ended (`turn_ended`), and `Conversation +closed`. Records whose type the SDK has no copy for, including types the +platform no longer emits, render nothing. `--json` with `--watch` prints one JSON object per frame (NDJSON) with the same filtering: `heartbeat` and `delta` are dropped, and `snapshot` and -`session` frames are printed only when the status word changes. +`session` frames are printed only when the awaited turn's status changes. ## Liveness, reconnect, fallback @@ -63,15 +79,14 @@ All of this is inside `streamSession`; the CLI configures none of it. fallback. `1002` and `1003` mean the protocol is unsupported and give up at once. Every other code is retried. - Fallback: giving up throws `StreamUnavailableError`. The CLI prints - `live stream unavailable (...); falling back to status polling` on stderr - and polls `GET /v1/sessions/{id}` every 2 seconds, printing status - transitions until a terminal status (`watchSession`). `--watch --quiet` + `live stream unavailable (...); falling back to polling the turn` on stderr + and polls `GET /v1/sessions/{id}/turns/{turn_id}` every 2 seconds, printing + status transitions until a final status (`pollTurn`). `--watch --quiet` takes this polling path directly, with no live output. ## Exit code -A watch exits 0 when the session ended in `completed`, `closed`, or `idle`, -and 1 otherwise (`exitCodeForStatus`). When a conversation closes, the -execution outcome (`lifecycle.last_execution_result.completion_reason`, for -example `budget_hit`) stands in for the lifecycle status, so a closed session -that hit its budget still exits 1. +A watch exits 0 when the turn ended `completed`, and 1 when it ended `failed`, +`stopped`, or `cancelled` (`exitCodeForStatus`). Its last line names the +outcome with the turn's `reason` and `detail`, for example +`✗ session session_1 turn failed (budget_hit): The session reached its budget.` diff --git a/package.json b/package.json index d90a19c..1f2d0da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ellipsis/cli", - "version": "2.30.0", + "version": "2.31.0", "description": "Ellipsis CLI: drive the Ellipsis cloud from your terminal", "license": "MIT", "type": "module", @@ -21,7 +21,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "0.30.0", + "@ellipsis-dev/sdk": "0.31.0", "commander": "^12.1.0", "ws": "^8.18.0", "yaml": "^2.9.0" diff --git a/skills/cli-conventions/SKILL.md b/skills/cli-conventions/SKILL.md index c135642..afc8143 100644 --- a/skills/cli-conventions/SKILL.md +++ b/skills/cli-conventions/SKILL.md @@ -18,45 +18,43 @@ route text. Singular nouns, one verb per action. ``` -ellipsis file list ellipsis file delete +ellipsis environment list ellipsis environment delete ellipsis session start ellipsis automation edit ``` -- **The noun is singular, always.** `file`, not `files`. `hook`, not +- **The noun is singular, always.** `environment`, not `environments`. `hook`, not `hooks`. `analytics` is the sole exception: it is a mass noun with no singular form. - **The plural still works, hidden.** Register it with `alsoKnownAs`, which - keeps it callable but strips it from every help surface. `ellipsis files list` + keeps it callable but strips it from every help surface. `ellipsis environments list` runs and prints nothing extra. -- **A renamed command keeps its old name, hidden.** `ellipsis file` was `agent - asset`, so it registers `asset` and `assets` alongside `files`. A caller who - learned the old spelling is never told it is wrong. +- **A renamed command keeps its old name, hidden.** Register the old spelling + with `alsoKnownAs` beside the new one. A caller who learned the old spelling + is never told it is wrong. - **Read-only integration browsers use a bare plural leaf**: `github repos`, `slack channels`, `linear teams`, `sentry orgs`. They have no get/create/delete to disambiguate against, so the extra `list` is noise. - Anything with more than one verb gets ` `: `file list`, - `file get`, `file upload`, `file delete`. + Anything with more than one verb gets ` `: `environment list`, + `environment get`, `environment create`, `environment delete`. - **`delete` is the shown verb**, with `rm` as a hidden alias. Never the reverse. - **`list` is the shown verb**, with `ls` hidden. ```ts -const file = alsoKnownAs( - program.command('file').description('...'), - 'files', - 'asset', - 'assets', +const environment = alsoKnownAs( + program.command('environment').description('...'), + 'environments', ) apiRoutes( - alsoKnownAs(file.command('delete ').description('...'), 'rm'), - 'DELETE /v1/files/{id}', + alsoKnownAs(environment.command('delete ').description('...'), 'rm'), + 'DELETE /v1/environments/{id}', ) ``` ## Arguments -Kebab-case placeholders: ``, ``, ``, +Kebab-case placeholders: ``, ``, ``, ``, ``. Never camelCase, and never a bare `` when the type matters. diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index b356dbf..54847f2 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -71,7 +71,7 @@ fee. There are no seats. reviewed, or commit a pipeline file to scope and customize it. - **Delegation from scripts or CI**: `ellipsis session start` or `POST /v1/sessions`. With `--watch` it streams into the log and exits nonzero - unless the session completes, so it works as a gate. + unless the turn completes, so it works as a gate. Things teams actually build: screenshot every pull request that touches the frontend so reviewers see the change; investigate Sentry alerts when they fire @@ -236,9 +236,9 @@ session: conversation, so an alert storm produces one investigation, not dozens of duplicates. Webhook deliveries are deduplicated, so a replay never double-runs an agent. -- React and cron sessions are single-shot. Mention and on-demand sessions are +- React and cron sessions run once. Mention and on-demand sessions are durable conversations: follow-ups keep the whole exchange and the working - tree, and an idle conversation costs near nothing between turns. + tree, and a conversation costs near nothing between turns. ## Code review @@ -389,7 +389,7 @@ ellipsis session start "triage the failing CI on api" # a bare ad-hoc session ellipsis automation run --input '{...}' # invoke an automation as defined ellipsis session start --config-file agents/my_agent.yaml --watch ellipsis session start --template ellipsis-helper --watch -ellipsis session get --watch # follow a running session +ellipsis session get --watch # follow the turn in progress ellipsis session stop ``` @@ -399,9 +399,9 @@ prompt is the sole instruction. The CLI also sends the repository you are standi the server clones it. Per-session overrides need no config edit: `--model`, `--system`, `--repo`, `--cpu`, `--memory`, `--timeout`, `--budget`, and `--override` for a full partial config patch. `--rebuild` skips the image -cache. `--detach` returns immediately. `--watch --quiet` prints only status -transitions and the result, and either watch form exits `0` only when the session -completes. +cache. `--detach` returns immediately. `--watch --quiet` prints only the turn's +status transitions and how it ended, and either watch form exits `0` only when +the turn completes. List and audit what agents have done: @@ -467,7 +467,6 @@ ellipsis variable list # names and timestamps only ellipsis integration # what is connected, in one table ellipsis github repos # also github members, slack channels, # linear teams, sentry orgs -ellipsis file upload shot.png # store a PNG, print an org-gated link ``` Most singular commands accept the plural spelling as a hidden alias, and @@ -510,19 +509,21 @@ than being silently dropped. Points that decide whether a config works: - `session.budget.session` defaults to $250, which is also the platform maximum, so it can only be lowered. `day`, `week`, and `month` are trailing 1, 7, and 28 day caps on this agent, with ceilings of $1,000, $10,000, and $40,000. A session - that reaches a cap stops mid-task and records `budget_hit`, which is a distinct - exit status from an error. Accounts also have their own trailing caps, plus + that reaches a cap stops mid-task and its turn fails with reason `budget_hit`, + distinct from an error. Accounts also have their own trailing caps, plus opt-in per-developer caps. - `session.output` makes an agent a function with a contract: it exits through your JSON Schema, so downstream automation gets typed data instead of prose to - parse. Schema failures exit loudly as `tool_call_failed`. It does not go + parse. Schema failures fail the turn with reason `tool_call_failed`. It does not go together with a mention trigger. -- Raw session starts accept `lifecycle.interactive: false` to run once. The - returned `lifecycle.prompting` describes whether direct messages are accepted. +- Raw session starts accept `conversation.interactive: false` to run once. The + returned `conversation.prompting` describes whether direct messages are + accepted, and `turn` is the turn to wait on: a message is answered when its + turn's status is `completed`, `failed`, `stopped`, or `cancelled`. Validation surfaces on push to the default branch, on config pull requests, in the dashboard editor, and at session start for checks that need the session's -own commit. Session-start failures record an exit status that names the cause: +own commit. A turn that cannot start fails with a reason that names the cause: `lifecycle_hook_failed`, `missing_repo_access`, `missing_token_permissions`, `missing_sandbox_variables`, `tool_call_failed`, `budget_hit`. @@ -545,7 +546,7 @@ Three `environment` fields define the sandbox, each with a different lifetime: never cached, for session-scoped setup such as authenticating a CLI. Capped at 5 minutes each. -A non-zero exit from any of them fails the session with +A non-zero exit from any of them fails the turn with reason `lifecycle_hook_failed`. The image is cached per repository set, commit, and image definition, so repeat sessions start in seconds instead of reinstalling dependencies. `ellipsis session start --config-file --rebuild --watch` @@ -589,9 +590,9 @@ print, so keep `image.setup` and hooks from echoing a value. Every session outlives its sandbox, which is what makes agent work reviewable rather than a black box. -- The live feed interleaves the agent's own output with lifecycle events, and - streams with lossless resume, so you can watch an agent work and catch a wrong - turn before it compounds. +- The live feed interleaves the agent's own output with environment and turn + events, and streams with lossless resume, so you can watch an agent work and + catch a wrong turn before it compounds. - Every turn and tool call is recorded, with the config version it ran and the exact instructions it launched with, so "what did the agent do" and "what was the agent told" are both reads rather than reconstructions. @@ -641,11 +642,10 @@ npx skills add ellipsis-dev/cli If `ELLIPSIS_SANDBOX_ID` is set in the environment, you are the agent in an Ellipsis session. The `ellipsis` CLI is pre-installed and pre-authenticated with a session-scoped token, so you can start child sessions, list the team's sessions, -read analytics, and upload screenshots as org-gated links -(`ellipsis file upload shot.png`) with no login. +and read analytics with no login. That token is deliberately narrower than a human's. It can list variable names -but not set or delete them, cannot delete a file, and cannot repoint an +but not set or delete them, and cannot repoint an account or repository default. An agent cannot overwrite the team's credentials or destroy the evidence it posted. diff --git a/src/cli.ts b/src/cli.ts index 62dedc6..7e5c8e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,6 @@ import { registerReview } from './commands/review' import { registerAutomation } from './commands/automation' import { registerEnvironment } from './commands/environment' import { registerVariable } from './commands/variable' -import { registerFile } from './commands/file' import { registerTemplate } from './commands/template' import { registerModel } from './commands/model' import { registerIntegration } from './commands/integrations' @@ -42,7 +41,6 @@ registerReview(program) registerAutomation(program) registerEnvironment(program) registerVariable(program) -registerFile(program) registerTemplate(program) registerModel(program) registerIntegration(program) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f00c308..0eb16ed 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -34,7 +34,7 @@ export function renderIdentity(me: WhoAmI): void { if (me.gh_user) console.log(`user: ${me.gh_user.login} (${me.user_id})`) else if (me.user_id) console.log(`user: ${me.user_id}`) if (me.api_key_id) console.log(`api key: ${me.api_key_id}`) - if (me.sandbox_id) console.log(`sandbox: ${me.sandbox_id}`) + if (me.session_id) console.log(`session: ${me.session_id}`) } export function registerAuth(program: Command): void { diff --git a/src/commands/file.ts b/src/commands/file.ts deleted file mode 100644 index 3e1d8ac..0000000 --- a/src/commands/file.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { type Command } from 'commander' -import { readFileSync, writeFileSync } from 'node:fs' -import { basename } from 'node:path' -import { api, APIError } from '../lib/api' -import { alsoKnownAs, apiRoutes } from '../lib/help' -import { formatTs, printJson, printTable, runAction } from '../lib/output' -import type { CreateFileRequest, FileView, GetFileResponse } from '../lib/types' - -// `ellipsis file `: persist files to Ellipsis platform storage and get back -// an org-membership-gated link. The primary caller is an agent inside a sandbox -// that took a screenshot of a UI change and wants a link to paste into a PR -// comment: the injected sandbox token authenticates it with zero setup, and the -// same commands work on a laptop with a device-login token. - -// Client-side mirrors of the server limits (files_service.py), so an -// oversized or non-PNG file fails fast with a clear message instead of a -// base64-inflated round trip to a 400. The server re-validates; these are -// UX, not enforcement. -export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 -const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - -// Well-known magic bytes we can name in the "not a PNG" error, so an agent -// that screenshotted to the wrong format learns what it actually produced. -const KNOWN_SIGNATURES: Array<[Buffer, string]> = [ - [Buffer.from([0xff, 0xd8, 0xff]), 'JPEG'], - [Buffer.from('GIF8', 'ascii'), 'GIF'], - [Buffer.from('BM', 'ascii'), 'BMP'], - [Buffer.from('%PDF', 'ascii'), 'PDF'], - [Buffer.from('= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF') { - return bytes.subarray(8, 12).toString('ascii') === 'WEBP' ? 'WebP' : 'RIFF' - } - for (const [magic, name] of KNOWN_SIGNATURES) { - if (bytes.subarray(0, magic.length).equals(magic)) return name - } - return null -} - -// Build the upload request from a file's bytes, throwing the fast client-side -// errors (empty, oversized, not a PNG). Exported for tests. -export function buildUploadRequest(path: string, bytes: Buffer): CreateFileRequest { - if (bytes.length === 0) throw new Error(`${path} is empty`) - if (bytes.length > MAX_FILE_SIZE_BYTES) { - throw new Error( - `${path} is ${formatSize(bytes.length)}; the limit is ` + - `${formatSize(MAX_FILE_SIZE_BYTES)} per file`, - ) - } - if (!bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) { - const guessed = sniffFormat(bytes) - throw new Error( - 'only PNG images are supported' + - (guessed ? ` (got what looks like ${guessed})` : ' (bytes are not a PNG)'), - ) - } - return { - filename: basename(path), - content_type: 'image/png', - data_b64: bytes.toString('base64'), - } -} - -// Human-readable byte count for tables and error messages. Binary units to -// match the server's MiB-denominated limits. -export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MiB` -} - -export function registerFile(program: Command): void { - const file = alsoKnownAs( - program - .command('file') - .description('Store files on the platform and share them as org-gated links'), - 'files', - 'asset', - 'assets', - ) - - apiRoutes( - file - .command('upload ') - .description('Upload a PNG and print its org-gated URL, ready to paste into a PR comment'), - 'POST /v1/files', - ) - .option('--json', 'output raw JSON') - .action(async (path: string, opts: { json?: boolean }) => { - await runAction(async () => { - const req = buildUploadRequest(path, readFileSync(path)) - const res = await api().files.create(req) - // The URL is the whole point — keep it the bare primary output so an - // agent (or $(...) in a script) can capture it directly. - if (opts.json) printJson(res) - else console.log(res.url) - }) - }) - - apiRoutes( - alsoKnownAs(file.command('list').description('List your stored files, newest first'), 'ls'), - 'GET /v1/files', - ) - .option('--session ', 'only files uploaded by this agent session') - .option('-l, --limit ', 'max results (server cap: 250)', parsePositiveInt) - .option('--json', 'output raw JSON') - .action(async (opts: { session?: string; limit?: number; json?: boolean }) => { - await runAction(async () => { - const files = ( - await api().files.list({ session_id: opts.session, limit: opts.limit }) - ).items - if (opts.json) { - printJson(files) - return - } - if (files.length === 0) { - console.log('No files.') - return - } - printTable( - ['ID', 'FILENAME', 'SIZE', 'CREATED', 'SESSION'], - files.map((f) => [ - f.id, - f.filename, - formatSize(f.size_bytes), - formatTs(f.created_at), - f.session_id ?? '-', - ]), - ) - }) - }) - - apiRoutes( - file - .command('get ') - .description("Print one file's metadata, or download its bytes with -o"), - 'GET /v1/files/{id}', - 'presigned S3 GET', - ) - .option('-o, --output ', 'write the file contents to this path') - .option('--json', 'output raw JSON (includes the short-lived download_url)') - .action(async (fileId: string, opts: { output?: string; json?: boolean }) => { - await runAction(async () => { - const res = await api().files.get(fileId) - if (opts.output) { - // download_url is a ~60s presigned S3 GET — fetch it immediately, - // while it's fresh. The JSON API never carries the bytes itself. - await downloadTo(res.download_url, opts.output) - if (!opts.json) { - console.log(`✓ wrote ${opts.output} (${formatSize(res.file.size_bytes)})`) - } - } - if (opts.json) printJson(res) - else if (!opts.output) renderFile(res) - }) - }) - - apiRoutes( - alsoKnownAs( - file.command('delete ').description('Delete a file, so its link stops resolving'), - 'rm', - ), - 'DELETE /v1/files/{id}', - ) - .option('--json', 'output raw JSON') - .action(async (fileId: string, opts: { json?: boolean }) => { - await runAction(async () => { - try { - await api().files.delete(fileId) - } catch (err) { - // A 404 covers "never existed", "someone else's", and "already - // deleted" — all the same "there's nothing here to delete" to the - // caller, so give one clear message instead of the raw HTTP error. - if (err instanceof APIError && err.status === 404) { - throw new Error(`file not found: ${fileId}`) - } - // A 403 is a real policy decision (e.g. sandbox tokens can't delete); - // surface the server's own explanation rather than masking it. - if (err instanceof APIError && err.status === 403) { - throw new Error(err.message) - } - throw err - } - // 204 No Content — nothing to echo, so confirm with the id. - if (opts.json) printJson({ id: fileId, deleted: true }) - else console.log(`✓ deleted ${fileId}`) - }) - }) -} - -function renderFile(res: GetFileResponse): void { - const f: FileView = res.file - console.log(`id: ${f.id}`) - console.log(`filename: ${f.filename}`) - console.log(`type: ${f.content_type}`) - console.log(`size: ${formatSize(f.size_bytes)}`) - console.log(`created: ${formatTs(f.created_at)}`) - if (f.session_id) console.log(`session: ${f.session_id}`) - console.log(`url: ${res.url}`) - console.log(`\ndownload the file with: ellipsis file get ${f.id} -o ${f.filename}`) -} - -// Pull the bytes from the presigned S3 URL. Deliberately bare fetch (no -// bearer header — the signature in the URL is the credential). Files are -// ≤10 MiB, so buffering in memory is fine. -async function downloadTo(url: string, path: string): Promise { - const res = await fetch(url) - if (!res.ok) { - throw new Error( - `download failed: ${res.status} ${res.statusText}` + - (res.status === 403 - ? ' (the presigned URL likely expired — re-run the command for a fresh one)' - : ''), - ) - } - writeFileSync(path, Buffer.from(await res.arrayBuffer())) -} - -function parsePositiveInt(raw: string): number { - const n = Number.parseInt(raw, 10) - if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid count '${raw}'`) - return n -} diff --git a/src/commands/review.ts b/src/commands/review.ts index 4869259..7c4da0e 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -5,7 +5,7 @@ import { api, APIError } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { repoFromCwd } from '../lib/git' import { formatTs, printJson, printTable, relativeAge, runAction, usdFromMillicents } from '../lib/output' -import { watchSessionStreaming } from './session' +import { followConversation } from './session' import type { Ellipsis } from '@ellipsis-dev/sdk' import type { CodeReviewRunStatus, @@ -30,9 +30,6 @@ import type { // A review is a pipeline of stage sessions, not a single session: its id is a // `crun_…`, and each stage's session id lives in `stages[]`. -// How long to wait between REST polls when the stream isn't available. -const FALLBACK_POLL_INTERVAL_SECONDS = 3 - // The only paths a committed pipeline may live at, in the precedence order the // server resolves them (CODE_REVIEW_CONFIG_PATHS). A file anywhere else is a // hard sync error, never a silently-unused config. @@ -91,8 +88,9 @@ export function registerReview(program: Command): void { return } - // Block-and-stream, then re-read: the findings are collected from the - // sandbox at teardown, so they only exist once the review finalizes. + // Follow the conversation to its close, then re-read: the findings + // are collected when the review's environment is torn down, after + // its turn ended, so they only exist once the conversation closes. // Same two-step `ellipsis file get` uses. if (!opts.json) { console.log( @@ -100,7 +98,7 @@ export function registerReview(program: Command): void { `(${started.id})`, ) } - await watchSessionStreaming(client, started.id, FALLBACK_POLL_INTERVAL_SECONDS, false) + await followConversation(client, started.id, false) const finished = await client.reviews.get(started.id) if (opts.json) printJson(finished) else renderReview(finished) diff --git a/src/commands/session.ts b/src/commands/session.ts index 219f5e8..bcd6689 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -17,7 +17,6 @@ import { collect, collectKeyValue, collectSource, - collectStatus, parseWhen, toInt, toNumber, @@ -35,11 +34,10 @@ import { } from '@ellipsis-dev/sdk/stream' import { recordToItems } from '@ellipsis-dev/sdk/store' import { makeOpenSocket, resolveWsBase } from '../lib/stream' -import type { Ellipsis, Session as FrameSession } from '@ellipsis-dev/sdk' +import { isTurnFinal, type Ellipsis } from '@ellipsis-dev/sdk' import type { AgentSession, AgentSessionSource, - AgentSessionStatus, SessionLogSegment, SessionRecord, StartAgentSessionRequest, @@ -65,15 +63,6 @@ export { startRequestFromConfig, withContextRepository } // streaming is unavailable). Not user-configurable — the fallback is rare. const FALLBACK_POLL_INTERVAL_SECONDS = 2 -// Statuses past which a session no longer changes — `--watch` stops here. -const TERMINAL_STATUSES: ReadonlySet = new Set([ - 'closed', - 'idle', - 'failed', - 'cancelled', - 'stopped', -]) - export function registerSession(program: Command): void { const session = alsoKnownAs( program.command('session').description('Start, inspect, and follow agent sessions'), @@ -148,11 +137,11 @@ export function registerSession(program: Command): void { .option('-d, --detach', 'start and return immediately, the default') .option( '-w, --watch', - 'block until the session reaches a terminal status, streaming live output', + 'block until the opening turn ends (completed, failed, stopped, or cancelled), streaming live output', ) .option( '--quiet', - 'with --watch, wait without streaming: print only the final result and exit with a matching code', + 'with --watch, wait without streaming: print only how the turn ended and exit with a matching code', ) .option('--json', 'output raw JSON') .action( @@ -251,13 +240,12 @@ export function registerSession(program: Command): void { req.images = opts.image.map((path) => readImageAttachment(path).attachment) } // Run settings ride top-level: --rebuild skips the image cache for - // the initial provision (wakes cache as usual; the fresh build's - // snapshot refreshes the cache). + // the initial provision (the fresh build's snapshot refreshes the + // cache). if (Object.keys(opts.metadata).length > 0) req.metadata = opts.metadata if (opts.rebuild) req.force_rebuild = true - // A promptless start opens idle: no fabricated kickoff message, - // Claude Code waits at the prompt like a local `claude` (the - // server-side contract since #6394 — nothing extra to send). + // A promptless start creates no turn: the session waits for its + // first message, nothing extra to send. const client = api() const { session } = await client.sessions.start(req) @@ -283,18 +271,9 @@ export function registerSession(program: Command): void { console.log(`✓ started session ${session.id}`) await printSessionUrl(client, session.id) } - // --quiet blocks on status only (no live output stream); either way - // the terminal status sets the exit code. - if (opts.quiet) { - await watchSession(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) - } else { - await watchSessionStreaming( - client, - session.id, - FALLBACK_POLL_INTERVAL_SECONDS, - opts.json, - ) - } + // The opening turn is the one this start created; a promptless + // start has none to wait on. + await watchTurn(client, session, opts) return } @@ -302,7 +281,8 @@ export function registerSession(program: Command): void { printJson(session) return } - console.log(`✓ started session ${session.id} (${session.lifecycle.status})`) + const opening = session.turn ? `turn ${session.turn.status}` : 'no turn yet' + console.log(`✓ started session ${session.id} (${opening})`) await printSessionUrl(client, session.id) console.log(` follow with: ellipsis session get ${session.id} --watch`) }) @@ -370,12 +350,13 @@ export function registerSession(program: Command): void { return } printTable( - ['ID', 'STATUS', 'SOURCE', 'CREATED', 'COST'], + ['ID', 'CONVERSATION', 'TURN', 'SOURCE', 'CREATED', 'COST'], sessions.map((s) => [ s.id, - s.lifecycle.status, + s.conversation.state, + s.turn?.status ?? '-', s.source ?? '-', - formatTs(s.lifecycle.timestamps.created_at), + formatTs(s.created_at), usdFromMillicents(s.cost?.total ?? 0), ]), ) @@ -503,15 +484,16 @@ export function registerSession(program: Command): void { apiRoutes( session .command('get ') - .description("Show one session's status, cost, and dashboard link"), + .description("Show one session's conversation state, turn, cost, and dashboard link"), 'GET /v1/sessions/{id}', 'WS /v1/sessions/{id}/stream with --watch', + 'GET /v1/sessions/{id}/turns/{turn_id} with --watch --quiet', ) .option( '-w, --watch', - 'block until the session reaches a terminal status, streaming live output', + 'block until the turn in progress ends (completed, failed, stopped, or cancelled), streaming live output', ) - .option('--quiet', 'with --watch, wait without streaming: print only the final result') + .option('--quiet', 'with --watch, wait without streaming: print only how the turn ended') .option('--json', 'output raw JSON') .action( async (sessionId: string, opts: { watch?: boolean; quiet?: boolean; json?: boolean }) => { @@ -522,16 +504,10 @@ export function registerSession(program: Command): void { } if (opts.watch) { if (!opts.json) await printSessionUrl(client, sessionId) - if (opts.quiet) { - await watchSession(client, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) - } else { - await watchSessionStreaming( - client, - sessionId, - FALLBACK_POLL_INTERVAL_SECONDS, - opts.json, - ) - } + // The turn to wait on is the one in progress (running, else + // pending); with none, the latest turn's status is the answer. + const { session: s } = await client.sessions.get(sessionId) + await watchTurn(client, s, opts) return } if (opts.json) { @@ -544,12 +520,12 @@ export function registerSession(program: Command): void { client.identity(), ]) printSessionSummary(s) - console.log(`url: ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) + console.log(row('url', sessionUrl(resolveAppBase(), me.customer_login, sessionId))) }) }) apiRoutes( - session.command('stop ').description('Stop an in-flight session'), + session.command('stop ').description("Stop a session's turn in progress"), 'POST /v1/sessions/{id}/stop', ) .option('--json', 'output raw JSON') @@ -560,73 +536,173 @@ export function registerSession(program: Command): void { printJson(s) return } - console.log(`✓ stopped session ${sessionId} (${s.lifecycle.status})`) + console.log(`✓ stopped session ${sessionId} (turn ${turnStatusText(s.turn)})`) }) }) } -// `--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( +// How a turn ended: its status plus the reason and detail a failed, stopped, +// or cancelled turn carries. The wire's own words, so the human line and +// `--json` never disagree. +export interface TurnEnd { + status: string + reason: string | null + detail: string | null +} + +// One line for where a turn is: `running`, `completed`, or +// `failed (budget_hit): The session reached its budget.` `none` when the +// session has no turn yet. +export function turnStatusText(turn: TurnEnd | null | undefined): string { + if (!turn) return 'none' + const reason = turn.reason ? ` (${turn.reason})` : '' + const detail = turn.detail ? `: ${turn.detail}` : '' + return `${turn.status}${reason}${detail}` +} + +// Exit 0 only when the turn completed; 1 when it failed, was stopped, or was +// cancelled (see docs/SESSION_STREAMING.md). +export function exitCodeForStatus(status: string): number { + return status === 'completed' ? 0 : 1 +} + +// `--watch` entry point: follow one turn until it reaches a final status. +// `session.turn` is the turn to wait on: the opening turn a start created, or +// the one in progress (running, else pending) that a GET found. With no turn +// in progress there is nothing to wait for: the latest turn's status is the +// answer, and the exit code follows it. +export async function watchTurn( + client: Ellipsis, + session: AgentSession, + opts: { quiet?: boolean; json?: boolean }, +): Promise { + const turn = session.turn + if (turn == null) { + if (opts.json) printJson(session) + else console.log(`session ${session.id} has no turn yet: nothing to wait for`) + return + } + if (isTurnFinal(turn.status)) { + if (opts.json) printJson(session) + endWatch(session.id, turn, opts.json) + return + } + if (opts.quiet) { + await pollTurn(client, session.id, turn.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } else { + await streamFrames(client, session.id, turn.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + } +} + +// Follow a session's whole conversation live until it closes. A session that +// runs once closes after its turn ended and the platform's teardown work is +// done: a review's findings are collected then, so `ellipsis review` waits +// for the close rather than the turn's end. +export async function followConversation( client: Ellipsis, sessionId: string, + json?: boolean, +): Promise { + await streamFrames(client, sessionId, null, FALLBACK_POLL_INTERVAL_SECONDS, json) +} + +// The watch's last word: one line naming how the turn ended, and the exit +// code that goes with it. `--json` callers have already printed the turn. +function endWatch(sessionId: string, turn: TurnEnd, json?: boolean): void { + if (!json) { + const mark = exitCodeForStatus(turn.status) === 0 ? '✓' : '✗' + console.log(`${mark} session ${sessionId} turn ${turnStatusText(turn)}`) + } + if (exitCodeForStatus(turn.status) !== 0) process.exitCode = 1 +} + +// Stream a session's output live over WebSocket, falling back to polling over +// REST if streaming is unavailable (e.g. a backend without the endpoint). +// With `turnId` the watch ends when that turn does; without one it follows +// the whole conversation until it closes. Either way the last line names how +// the turn ended and the exit code follows it. +async function streamFrames( + client: Ellipsis, + sessionId: string, + turnId: string | null, intervalSeconds: number, json?: boolean, ): Promise { const token = requireToken() const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) - // Session frames are LWW snapshots resent on any change (cost ticks - // included), so collapse to status-word transitions — both to keep the - // human log quiet and the NDJSON stream clean of near-duplicates. Heartbeats - // are liveness only; deltas are ephemeral partials the committed record - // supersedes — a line-oriented log skips both. - let lastStatus: string | undefined + // The stream stays open for the whole conversation; a turn watch wants one + // turn of it. That turn's end arrives twice, as its turn_ended record + // (cursored, never lost) and on the session frame carrying its final + // status, and either one ends the watch, which closes the socket itself. + const abort = new AbortController() + // What the frames have said: the latest turn seen (the awaited one, or + // whichever turn the session is on) and, for a turn watch, its end once + // seen. Session frames are LWW snapshots resent on any change (cost ticks + // included), so they collapse to the turn's status transitions to keep the + // human log quiet and the NDJSON stream clean of near-duplicates. + // Heartbeats are liveness only; deltas are ephemeral partials the committed + // record supersedes: a line-oriented log skips both. + const seen: { turn: (TurnEnd & { id: string }) | null; end: TurnEnd | null } = { + turn: null, + end: null, + } const onFrame = (frame: StreamFrame) => { - if (frame.type === 'session' || frame.type === 'snapshot') { - const word = sessionStatusWord( - (frame as unknown as { session: FrameSession }).session, - ) - if (word === lastStatus) return - lastStatus = word - } if (frame.type === 'heartbeat' || frame.type === 'delta') return - if (json) { - console.log(JSON.stringify(frame)) - return + if (frame.type === 'snapshot' || frame.type === 'session') { + const turn = frame.session.turn + if (turn == null || (turnId != null && turn.id !== turnId)) return + if (turn.id === seen.turn?.id && turn.status === seen.turn.status) return + seen.turn = { id: turn.id, status: turn.status, reason: turn.reason, detail: turn.detail } + if (turnId != null && isTurnFinal(turn.status)) seen.end = seen.turn + } else if (frame.type === 'records_append') { + for (const record of frame.records) { + if ( + turnId != null && + record.kind === 'platform' && + record.record_type === 'turn_ended' && + record.payload.turn_id === turnId + ) { + const { status, reason, detail } = record.payload + seen.end = { status, reason: reason ?? null, detail: detail ?? null } + } + } } - renderFrameHuman(frame, lastStatus) + if (json) console.log(JSON.stringify(frame)) + else renderFrameHuman(frame, seen.turn?.status) + if (seen.end) abort.abort() } let outcome: StreamOutcome try { - outcome = await streamSession({ sessionId, openSocket, onFrame }) + outcome = await streamSession({ sessionId, openSocket, onFrame, signal: abort.signal }) } catch (err) { if (err instanceof StreamUnavailableError) { if (!json) { - console.error( - `live stream unavailable (${err.message}); falling back to status polling`, - ) + console.error(`live stream unavailable (${err.message}); falling back to polling`) } - await watchSession(client, sessionId, intervalSeconds, json) + if (turnId != null) await pollTurn(client, sessionId, turnId, intervalSeconds, json) + else await pollConversation(client, sessionId, intervalSeconds, json) return } throw err // StreamAuthError and anything unexpected: surfaced by runAction. } - if (outcome.type === 'aborted') return if (outcome.type === 'error') { process.exitCode = 1 return } - // Terminal `done` frame. Output already streamed live; print a one-line cap. - if (!json) { - const mark = exitCodeForStatus(outcome.exitStatus ?? outcome.status) === 0 ? '✓' : '✗' - console.log(`\n${mark} session ${sessionId} ${outcome.status}`) + // `aborted` is a turn watch closing the socket once its turn ended; `done` + // is the conversation closing, after the last session frame carried its + // turn's end. A turn watch that saw neither fetches its turn. + const end = + seen.end ?? + (turnId != null ? (await client.sessions.turns.get(sessionId, turnId)).turn : seen.turn) + if (end == null) { + if (!json) console.log(`\nconversation ${sessionId} closed`) + return } - if (exitCodeForStatus(outcome.exitStatus ?? outcome.status) !== 0) process.exitCode = 1 + endWatch(sessionId, end, json) } function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { @@ -637,14 +713,12 @@ function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { break case 'records_append': { // Raw records, rendered client-side (the semantic-relay philosophy): - // one line per transcript item. - // Lifecycle records render too (recordToItems shapes them through - // lifecycleText): the startup narrative — scheduled, phase - // transitions with cache tier + duration, setup output, ready — - // belongs in a watch log; types without display copy (message_*/ - // turn_* bookkeeping) shape to nothing. - const records = (frame as { records: SessionRecord[] }).records - for (const record of records) { + // one line per transcript item. Platform records render too + // (recordToItems shapes them through lifecycleText): environment + // preparation with the customer's hook output, how a turn ended, the + // conversation closing. Types without display copy (message_* + // bookkeeping) shape to nothing. + for (const record of frame.records) { for (const item of recordToItems(record, `w${record.feed_seq}`)) { const line = item.detail ? `${item.text} ${item.detail}` : item.text if (line.trim()) console.log(line) @@ -653,7 +727,7 @@ function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { break } case 'error': - console.error(`error: ${(frame as { message?: string }).message ?? 'stream error'}`) + console.error(`error: ${frame.message ?? 'stream error'}`) break case 'done': break // handled by the caller @@ -662,55 +736,79 @@ function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { } } -// Exit 0 for a successful terminal status, non-zero otherwise (see docs/SESSION_STREAMING.md). -export function exitCodeForStatus(status: string): number { - return ['completed', 'closed', 'idle'].includes(status) ? 0 : 1 +// Poll one turn until it reaches a final status, printing each status +// transition. The status-level path: `--watch --quiet`, and the fallback when +// live streaming isn't available. +export async function pollTurn( + client: Ellipsis, + sessionId: string, + turnId: string, + intervalSeconds: number, + json?: boolean, +): Promise { + const intervalMs = Math.max(1, intervalSeconds) * 1000 + let last: string | undefined + for (;;) { + const { turn } = await client.sessions.turns.get(sessionId, turnId) + if (turn.status !== last) { + if (!json) console.log(`${nowClock()} ${turn.status}`) + last = turn.status + } + if (isTurnFinal(turn.status)) { + if (json) printJson(turn) + else console.log('') + endWatch(sessionId, turn, json) + return + } + await sleep(intervalMs) + } } -// 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 public REST API exposes session state, not the step-by-step stream. -export async function watchSession( +// Poll a session until its conversation closes, printing the turn's status +// transitions. The fallback for following a conversation when live streaming +// isn't available. +async function pollConversation( client: Ellipsis, sessionId: string, intervalSeconds: number, json?: boolean, ): Promise { const intervalMs = Math.max(1, intervalSeconds) * 1000 - let last: AgentSessionStatus | undefined + let last: string | undefined for (;;) { - const { session: s } = await client.sessions.get(sessionId) - if (s.lifecycle.status !== last) { - if (!json) { - const reason = s.lifecycle.detail ? `: ${s.lifecycle.detail}` : '' - console.log(`${nowClock()} ${s.lifecycle.status}${reason}`) - } - last = s.lifecycle.status + const { session } = await client.sessions.get(sessionId) + const word = sessionStatusWord(session) + if (word !== last) { + if (!json) console.log(`${nowClock()} ${word}`) + last = word } - if (TERMINAL_STATUSES.has(s.lifecycle.status)) { - if (json) { - printJson(s) - } else { - console.log('') - printSessionSummary(s) - } - if (exitCodeForStatus(s.lifecycle.last_execution_result?.completion_reason ?? s.lifecycle.status) !== 0) process.exitCode = 1 + if (session.conversation.state === 'closed') { + if (json) printJson(session) + else console.log('') + if (session.turn) endWatch(sessionId, session.turn, json) return } await sleep(intervalMs) } } +// One `label: value` line of `session get`, labels padded to one column. +function row(label: string, value: string): string { + return `${label}:`.padEnd(14) + value +} + function printSessionSummary(s: AgentSession): void { - console.log(`id: ${s.id}`) - console.log(`status: ${s.lifecycle.status}${s.lifecycle.detail ? ` (${s.lifecycle.detail})` : ''}`) - if (s.source) console.log(`source: ${s.source}`) + console.log(row('id', s.id)) + console.log(row('conversation', s.conversation.state)) + console.log(row('warm', s.conversation.warm ? 'yes' : 'no')) + console.log(row('turn', turnStatusText(s.turn))) + if (s.source) console.log(row('source', s.source)) const config = sessionConfigName(s) - if (config) console.log(`config: ${config}`) - console.log(`created: ${s.lifecycle.timestamps.created_at}`) - console.log(`updated: ${s.lifecycle.timestamps.updated_at}`) - console.log(`tokens: ${(s.tokens?.total ?? 0).toLocaleString()}`) - console.log(`cost: ${usdFromMillicents(s.cost?.total ?? 0)}`) + if (config) console.log(row('config', config)) + console.log(row('created', s.created_at)) + console.log(row('updated', s.updated_at)) + console.log(row('tokens', (s.tokens?.total ?? 0).toLocaleString())) + console.log(row('cost', usdFromMillicents(s.cost?.total ?? 0))) const keys = Object.keys(s.metadata ?? {}) if (keys.length) { console.log('metadata:') diff --git a/src/commands/usage.ts b/src/commands/usage.ts index bb71c7c..8b2a689 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -59,8 +59,8 @@ export function registerUsage(program: Command): void { for (const m of u.by_model) { const cost = usdFromMillicents( m.cost_tokens_millicents + - m.cost_sandbox_cpu_millicents + - m.cost_sandbox_memory_millicents + + m.cost_cpu_millicents + + m.cost_memory_millicents + m.cost_fee_millicents, ) console.log( diff --git a/src/lib/args.ts b/src/lib/args.ts index b33baa4..ca91b99 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -41,18 +41,16 @@ export const SESSION_SOURCES = [ 'cron', ] as const +// Turn statuses: the session search filter matches on the session's turn. export const SESSION_STATUSES = [ - 'scheduled', - 'creating_sandbox', + 'pending', 'running', - 'retrying', 'completed', - 'error', - 'cancelled', + 'failed', 'stopped', + 'cancelled', ] as const - function oneOf(kind: string, allowed: readonly string[], value: string): string { if (!allowed.includes(value)) { throw new InvalidArgumentError(`${kind} must be one of: ${allowed.join(', ')}`) diff --git a/src/lib/help.ts b/src/lib/help.ts index d650b02..c9ff2e6 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -20,7 +20,7 @@ function withoutAliases(term: string, cmd: Command): string { const TOP_LEVEL_GROUPS: ReadonlyArray<{ title: string; commands: readonly string[] }> = [ { title: 'Sessions', commands: ['session', 'review'] }, { title: 'Automations', commands: ['automation', 'model', 'template'] }, - { title: 'Platform', commands: ['variable', 'file'] }, + { title: 'Platform', commands: ['variable'] }, { title: 'Integrations', commands: ['integration', 'github', 'slack', 'linear', 'sentry'] }, { title: 'Spend', commands: ['budget', 'usage', 'analytics'] }, { title: 'Account', commands: ['auth', 'host'] }, diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 91d7db6..877afa2 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -1,49 +1,7 @@ -import { - deriveSandboxState as sdkDeriveSandboxState, - lifecycleText as sdkLifecycleText, - sessionLogText as sdkSessionLogText, - oneLine, - claudePayload, - recordToItems, - type SandboxState, -} from '@ellipsis-dev/sdk/store' +import { claudePayload, lifecycleText, oneLine, recordToItems } from '@ellipsis-dev/sdk/store' import { formatTs } from './output' import type { SessionRecord } from './types' -// Re-exported for the record-view callers below and their historical -// importers; the implementations live in the SDK's store layer now. -export { oneLine, sandboxOutputStep, sandboxOutputLine } from '@ellipsis-dev/sdk/store' - -// The SDK's wording, with its middot separators as commas — the CLI writes -// plain sentences. -const commas = (text: string): string => text.replaceAll(' · ', ', ') - -export function lifecycleText( - ...args: Parameters -): string | null { - const text = sdkLifecycleText(...args) - return text === null ? null : commas(text) -} - -export function sessionLogText( - ...args: Parameters -): string | null { - const text = sdkSessionLogText(...args) - return text === null ? null : commas(text) -} - -export function deriveSandboxState( - ...args: Parameters -): SandboxState | null { - const state = sdkDeriveSandboxState(...args) - if (!state) return null - return { - ...state, - headline: commas(state.headline), - log: state.log.map((line) => ({ ...line, text: commas(line.text) })), - } -} - // Record-rendering helpers for `session record` and the `--watch` log // (commands/session.ts re-exports them for compatibility). @@ -63,25 +21,28 @@ function fields(record: SessionRecord): Record { return (claudePayload(record) ?? record.payload) as Record } -// One session_record as a single display line: index, timestamp, record type, -// and the first ~120 characters of its text content. Exported for tests. +// One session_record as a single display line: feed position, timestamp, +// record type, and the first ~120 characters of its text content. Exported +// for tests. export function formatStepLine(record: SessionRecord): string { const raw = fields(record).subtype const subtype = typeof raw === 'string' ? raw : null const type = subtype ? `${record.record_type}/${subtype}` : record.record_type return [ - String(record.stream_seq).padStart(4), + String(record.feed_seq).padStart(4), formatTs(record.created_at), type.padEnd(16), oneLine(recordText(record), 120), ].join(' ') } -// Best-effort display text for a stored record. A lifecycle record shows its -// notification line; a claude_code record's `payload` is the raw agent stream -// event — a result step carries `result`, assistant/user steps carry `content`, -// a string or a list of blocks (text, thinking, tool_use, tool_result). -// Anything unrecognized falls back to its JSON. +// Best-effort display text for a stored record. A platform record shows its +// notification line (a record type the SDK has no copy for, including types +// the platform no longer emits, shows its bare type); a claude_code record's +// `payload` is the raw agent stream event — a result step carries `result`, +// assistant/user steps carry `content`, a string or a list of blocks (text, +// thinking, tool_use, tool_result). Anything unrecognized falls back to its +// JSON. export function recordText(record: SessionRecord): string { const data = fields(record) if (record.kind === 'platform') { diff --git a/src/lib/types.ts b/src/lib/types.ts index 108987a..6a85746 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -15,7 +15,8 @@ type S = components['schemas'] export type AgentSession = S['Session'] export type AgentSessionSource = S['SessionSource'] -export type AgentSessionStatus = S['SessionLifecycleStatus'] +export type SessionTurn = S['SessionTurn'] +export type TurnStatus = S['TurnStatus'] // The frames flavor, not `S['SessionRecord']`: the spec marks defaulted fields // optional, but on the wire the server always serializes every field, and the // SDK's transcript store types its inputs this way. Using it here keeps records @@ -74,14 +75,6 @@ export type CreateReviewRequest = S['CreateReviewRequest'] export type ListReviewsResponse = S['ReviewsListResponse'] export type CodeReviewRunStatus = S['CodeReviewRunStatus'] -// --------------------------------- files ---------------------------------- - -export type FileView = S['File'] -export type CreateFileRequest = Parameters[0] -export type CreateFileResponse = S['CreateFileResponse'] -export type GetFileResponse = S['GetFileResponse'] -export type ListFilesResponse = S['FilesListResponse'] - // ------------------------------- secrets ---------------------------------- // Customer-scoped environment variables injected into a sandbox when an agent // config names them. Values are write-only: the API accepts them but never diff --git a/test/args.test.ts b/test/args.test.ts index d8902b6..1206618 100644 --- a/test/args.test.ts +++ b/test/args.test.ts @@ -61,7 +61,7 @@ describe('collectSource / collectStatus', () => { it('reject unknown values listing the valid ones', () => { expect(() => collectSource('slack', [])).toThrow(/source must be one of: react, web/) - expect(() => collectStatus('done', [])).toThrow(/status must be one of: scheduled/) + expect(() => collectStatus('done', [])).toThrow(/status must be one of: pending, running/) }) }) diff --git a/test/auth.test.ts b/test/auth.test.ts index eee43bb..dc0aded 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -9,7 +9,7 @@ function base(overrides: Partial = {}): WhoAmI { user_id: null, gh_user: null, api_key_id: null, - sandbox_id: null, + session_id: null, ...overrides, } } diff --git a/test/file.test.ts b/test/file.test.ts deleted file mode 100644 index a7761e9..0000000 --- a/test/file.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_FILE_SIZE_BYTES, - buildUploadRequest, - formatSize, -} from '../src/commands/file' - -const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - -function pngBytes(extra = 8): Buffer { - return Buffer.concat([PNG_MAGIC, Buffer.alloc(extra, 1)]) -} - -describe('buildUploadRequest', () => { - it('builds the request from a valid PNG', () => { - const bytes = pngBytes() - expect(buildUploadRequest('/tmp/shots/settings.png', bytes)).toEqual({ - filename: 'settings.png', - content_type: 'image/png', - data_b64: bytes.toString('base64'), - }) - }) - - it('uses the basename, never the full path', () => { - const req = buildUploadRequest('../deep/nested/shot.png', pngBytes()) - expect(req.filename).toBe('shot.png') - }) - - it('rejects an empty file', () => { - expect(() => buildUploadRequest('empty.png', Buffer.alloc(0))).toThrow(/is empty/) - }) - - it('rejects files over the 10 MiB cap, with sizes in the message', () => { - const big = Buffer.concat([PNG_MAGIC, Buffer.alloc(MAX_FILE_SIZE_BYTES)]) - expect(() => buildUploadRequest('big.png', big)).toThrow(/10\.0 MiB per file/) - }) - - it('accepts a PNG exactly at the cap', () => { - const atCap = Buffer.concat([ - PNG_MAGIC, - Buffer.alloc(MAX_FILE_SIZE_BYTES - PNG_MAGIC.length), - ]) - expect(buildUploadRequest('cap.png', atCap).content_type).toBe('image/png') - }) - - it('names the format when the bytes look like a known non-PNG', () => { - const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]) - expect(() => buildUploadRequest('shot.png', jpeg)).toThrow(/looks like JPEG/) - - const gif = Buffer.from('GIF89a-------', 'ascii') - expect(() => buildUploadRequest('shot.png', gif)).toThrow(/looks like GIF/) - - const webp = Buffer.concat([ - Buffer.from('RIFF', 'ascii'), - Buffer.alloc(4), - Buffer.from('WEBP', 'ascii'), - Buffer.alloc(4), - ]) - expect(() => buildUploadRequest('shot.png', webp)).toThrow(/looks like WebP/) - }) - - it('falls back to a generic message for unrecognized bytes', () => { - expect(() => buildUploadRequest('shot.png', Buffer.from('hello world'))).toThrow( - /bytes are not a PNG/, - ) - }) - - it('rejects a truncated PNG magic', () => { - expect(() => buildUploadRequest('shot.png', PNG_MAGIC.subarray(0, 4))).toThrow( - /not a PNG/, - ) - }) -}) - -describe('formatSize', () => { - it('renders bytes, KiB, and MiB', () => { - expect(formatSize(512)).toBe('512 B') - expect(formatSize(2048)).toBe('2.0 KiB') - expect(formatSize(10 * 1024 * 1024)).toBe('10.0 MiB') - }) - - it('uses binary units at the boundaries', () => { - expect(formatSize(1023)).toBe('1023 B') - expect(formatSize(1024)).toBe('1.0 KiB') - expect(formatSize(1024 * 1024)).toBe('1.0 MiB') - }) -}) diff --git a/test/fixtures/session.ts b/test/fixtures/session.ts index 93b2ed1..6e96d20 100644 --- a/test/fixtures/session.ts +++ b/test/fixtures/session.ts @@ -1,7 +1,26 @@ -import type { AgentSession } from '../../src/lib/types' +import type { AgentSession, SessionTurn } from '../../src/lib/types' type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T +// One turn of the fixture session; override the status (and the reason and +// detail an ended turn carries) per test. +export function turn(overrides: Partial = {}): SessionTurn { + return { + id: 'turn_1', + index: 0, + status: 'running', + reason: null, + detail: null, + stopped: null, + created_at: '2026-07-07T00:00:00Z', + started_at: '2026-07-07T00:00:01Z', + ended_at: null, + cost: { llm: 0, cpu: 0, memory: 0, fee: 0, total: 0 }, + tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 0, model: '' }, + ...overrides, + } +} + export function session(overrides: DeepPartial = {}): AgentSession { return { id: 'session_1', @@ -10,26 +29,25 @@ export function session(overrides: DeepPartial = {}): AgentSession claude_code: {}, codex: null, budget: 0, - cost: { llm: 0, sandbox_cpu: 0, sandbox_memory: 0, fee: 0, total: 0 }, + cost: { llm: 0, cpu: 0, memory: 0, fee: 0, total: 0 }, tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 0, model: '' }, metadata: {}, + archived: null, + created_at: '2026-07-07T00:00:00Z', + updated_at: '2026-07-07T00:00:00Z', + turn: turn(), ...overrides, - lifecycle: { - status: 'working', - conversation: 'open', + conversation: { + state: 'open', interactive: true, - detail: null, - last_execution_result: null, - archived: null, - stopped: null, - ...overrides.lifecycle, - prompting: { enabled: true, blocked_reason: null, detail: null, surface_name: null, ...overrides.lifecycle?.prompting }, - timestamps: { - created_at: '2026-07-07T00:00:00Z', - updated_at: '2026-07-07T00:00:00Z', - last_activity_at: null, - last_message_at: null, - ...overrides.lifecycle?.timestamps, + warm: true, + ...overrides.conversation, + prompting: { + enabled: true, + blocked_reason: null, + detail: null, + surface_name: null, + ...overrides.conversation?.prompting, }, }, } as AgentSession diff --git a/test/sdk-030.test.ts b/test/sdk-030.test.ts index 9f62218..78fe910 100644 --- a/test/sdk-030.test.ts +++ b/test/sdk-030.test.ts @@ -6,9 +6,9 @@ import { parse } from 'yaml' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { registerAutomation } from '../src/commands/automation' import { registerEnvironment } from '../src/commands/environment' -import { registerSession, watchSession } from '../src/commands/session' +import { pollTurn, registerSession } from '../src/commands/session' import { api } from '../src/lib/api' -import { session } from './fixtures/session' +import { session, turn } from './fixtures/session' let dir: string let requests: { url: URL; body: Record | undefined }[] @@ -95,9 +95,13 @@ describe('SDK 0.30 request contracts', () => { expect(config).not.toHaveProperty('image') }) - it.each(['completed', 'budget_hit'] as const)('uses the execution outcome when a conversation closes: %s', async (reason) => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ session: session({ lifecycle: { status: 'closed', last_execution_result: { completion_reason: reason, detail: null } } }) })))) - await watchSession(api(), 's_1', 1, true) - expect(process.exitCode).toBe(reason === 'completed' ? 0 : 1) + it.each([ + ['completed', null, 0], + ['failed', 'budget_hit', 1], + ] as const)('waits on the turn and exits by how it ended: %s', async (status, reason, code) => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ turn: turn({ status, reason }) })))) + await pollTurn(api(), 's_1', 'turn_1', 1, true) + expect(requests.length).toBe(0) + expect(process.exitCode).toBe(code) }) }) diff --git a/test/sdk-upgrade.test.ts b/test/sdk-upgrade.test.ts index 84db13b..32a1e5d 100644 --- a/test/sdk-upgrade.test.ts +++ b/test/sdk-upgrade.test.ts @@ -10,13 +10,10 @@ import type { BudgetSummary } from '../src/lib/types' const envelope = { id: 'record_1', session_id: 'session_1', - session_execution_id: 'execution_1', - agent_turn_id: 'turn_1', - sandbox_id: 'sandbox_1', + turn_id: 'turn_1', session_message_id: null, created_at: '2026-09-10T12:00:00Z', feed_seq: 1, - stream_seq: 1, cost: null, duration: null, model: null, @@ -46,7 +43,6 @@ const result: SessionRecord = { ...envelope, id: 'record_2', feed_seq: 2, - stream_seq: 2, kind: 'claude_code', source: 'claude_code', record_format: 'claude_jsonl@1', diff --git a/test/search.test.ts b/test/search.test.ts index d572a39..0cea1fb 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -1,26 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Ellipsis } from '@ellipsis-dev/sdk' import { formatStepLine, recordText, resolveAuthorId } from '../src/commands/session' -import type { AgentSession, SessionRecord } from '../src/lib/types' - -function session(overrides: Partial = {}): AgentSession { - return { - id: 'session_1', - created_at: '2026-07-03T12:00:00+00:00', - updated_at: '2026-07-03T12:00:00+00:00', - status: 'completed', - status_reason: null, - config_id: null, - source: 'api', - harness: 'claude_code', - prompting: { enabled: true }, - budget: { cents: 0, source: 'system' }, - cost: { llm: 0, sandbox_cpu: 0, sandbox_memory: 0, fee: 0, total: 0 }, - tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 0, model: '' }, - metadata: {}, - ...overrides, - } -} +import type { SessionRecord } from '../src/lib/types' describe('getAgentSessionRecords', () => { afterEach(() => vi.unstubAllGlobals()) @@ -96,10 +77,9 @@ describe('recordText / formatStepLine', () => { id: 'rec_1', session_id: 'session_1', kind: overrides.source === 'lifecycle' ? 'platform' : 'claude_sdk', - session_execution_id: 'exec_1', + turn_id: 'turn_1', created_at: '2026-07-03T12:00:00+00:00', feed_seq: 3, - stream_seq: 3, source: 'claude_code', record_type: (payload.kind as string) ?? 'assistant', record_format: overrides.source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1', @@ -142,7 +122,7 @@ describe('recordText / formatStepLine', () => { }) it('formats one line with index, timestamp, type, and truncated text', () => { - // record_type + payload.subtype drive the type column; stream_seq the index. + // record_type + payload.subtype drive the type column; feed_seq the index. const line = formatStepLine( record( { subtype: 'init', content: 'line one\nline two' }, @@ -152,30 +132,61 @@ describe('recordText / formatStepLine', () => { expect(line).toBe(' 3 2026-07-03 12:00 system/init line one line two') }) - it('renders a lifecycle record as its notification line', () => { + it('renders a platform record as its notification line', () => { const line = formatStepLine( - record({}, { source: 'lifecycle', record_type: 'sandbox_ready', stream_seq: -2 }), + record( + { repositories: ['acme/repo'], duration_ms: 1200 }, + { source: 'lifecycle', record_type: 'environment_ready', feed_seq: 2 }, + ), ) - expect(line).toBe(' -2 2026-07-03 12:00 sandbox_ready Sandbox ready') + expect(line).toMatch(/^ 2 2026-07-03 12:00 environment_ready Environment ready/) }) - it('renders sandbox_ready cache tier and setup-output chunks', () => { - // sandbox_ready carries the image-cache tier so a slow start explains itself. - const ready = formatStepLine( + it('renders hook output and how a turn ended', () => { + // An environment_output chunk carries the customer's own hook output. + const chunk = formatStepLine( record( - { repositories: ['acme/repo'], cache_tier: 'full' }, - { source: 'lifecycle', record_type: 'sandbox_ready', stream_seq: -2 }, + { + phase: 'hooks', + step: 'after_checkout', + stream: 'stdout', + chunk: 3, + lines: ['Installing pandas (3.0.3)', ' '], + }, + { source: 'lifecycle', record_type: 'environment_output', feed_seq: 3 }, ), ) - expect(ready).toContain('Sandbox ready, acme/repo, full build') - // A sandbox-output chunk reads as the script's latest non-empty line. - const chunk = formatStepLine( + expect(chunk).toContain('Installing pandas (3.0.3)') + const ended = formatStepLine( record( - { phase: 'setup', chunk: 3, lines: ['Installing pandas (3.0.3)', ' '] }, - { source: 'lifecycle', record_type: 'sandbox_output', stream_seq: -3 }, + { + turn_id: 'turn_1', + turn_index: 0, + status: 'failed', + reason: 'budget_hit', + detail: 'The session reached its budget.', + }, + { source: 'lifecycle', record_type: 'turn_ended', feed_seq: 4 }, ), ) - expect(chunk).toContain('setup, Installing pandas (3.0.3)') + // The SDK's wording: the platform's explanation when the turn carries + // one, else the bare outcome. + expect(ended).toContain('The session reached its budget.') + const bare = formatStepLine( + record( + { turn_id: 'turn_1', turn_index: 0, status: 'stopped', reason: null, detail: null }, + { source: 'lifecycle', record_type: 'turn_ended', feed_seq: 4 }, + ), + ) + expect(bare).toContain('Turn stopped') + }) + + it('renders a record type it does not know as its bare type', () => { + // Historical feeds can still hold record types the platform no longer emits. + const line = formatStepLine( + record({}, { source: 'lifecycle', record_type: 'session_idle', feed_seq: 5 }), + ) + expect(line).toBe(' 5 2026-07-03 12:00 session_idle session_idle') }) it('truncates long text to about 120 characters', () => { diff --git a/test/session.test.ts b/test/session.test.ts index 9ad2f76..97fa5b8 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -5,20 +5,26 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildStartOverride, + exitCodeForStatus, fetchLogSegment, + pollTurn, readConfigFile, - watchSession, + turnStatusText, + watchTurn, } from '../src/commands/session' import type { Ellipsis } from '@ellipsis-dev/sdk' -import type { AgentSession, AgentSessionStatus, SessionLogSegment } from '../src/lib/types' +import type { SessionLogSegment, TurnStatus } from '../src/lib/types' -import { session as makeSession } from './fixtures/session' +import { session as makeSession, turn as makeTurn } from './fixtures/session' -function session(status: AgentSessionStatus): AgentSession { - return makeSession({ lifecycle: { status } }) +// A client whose turns.get answers each poll with the next status in order. +function turnsClient(statuses: TurnStatus[]): { client: Ellipsis; get: ReturnType } { + const get = vi.fn() + for (const status of statuses) get.mockResolvedValueOnce({ turn: makeTurn({ status }) }) + return { client: { sessions: { turns: { get } } } as unknown as Ellipsis, get } } -describe('watchSession', () => { +describe('pollTurn', () => { beforeEach(() => { vi.useFakeTimers() vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -26,58 +32,94 @@ describe('watchSession', () => { afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() + process.exitCode = 0 }) - it('polls until a terminal status, then stops', async () => { - const get = vi - .fn() - .mockResolvedValueOnce({ session: session('working') }) - .mockResolvedValueOnce({ session: session('working') }) - .mockResolvedValueOnce({ session: session('closed') }) - const client = { sessions: { get } } as unknown as Ellipsis + it('polls the turn until a final status, then stops', async () => { + const { client, get } = turnsClient(['pending', 'running', 'completed']) - const promise = watchSession(client, 'session_1', 1, true) - await vi.advanceTimersByTimeAsync(1000) // 1st poll running -> sleep -> 2nd poll + const promise = pollTurn(client, 'session_1', 'turn_1', 1, true) + await vi.advanceTimersByTimeAsync(1000) // 1st poll pending -> sleep -> 2nd poll await vi.advanceTimersByTimeAsync(1000) // -> 3rd poll completed -> return await promise expect(get).toHaveBeenCalledTimes(3) - expect(get).toHaveBeenCalledWith('session_1') + expect(get).toHaveBeenCalledWith('session_1', 'turn_1') }) - it('returns immediately when the session is already terminal', async () => { - const get = vi.fn().mockResolvedValueOnce({ session: session('failed') }) - const client = { sessions: { get } } as unknown as Ellipsis - - await watchSession(client, 'session_1', 5, true) // no timer advance needed + it('returns at once when the turn is already final', async () => { + const { client, get } = turnsClient(['failed']) + await pollTurn(client, 'session_1', 'turn_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 AgentSessionStatus[]) { - const get = vi.fn().mockResolvedValueOnce({ session: session(status) }) - const client = { sessions: { get } } as unknown as Ellipsis - await watchSession(client, 'session_1', 5, true) - expect(get).toHaveBeenCalledTimes(1) - } + it.each(['failed', 'stopped', 'cancelled'] as const)( + 'sets a failure exit code when the turn ended %s', + async (status) => { + const { client } = turnsClient([status]) + await pollTurn(client, 'session_1', 'turn_1', 5, true) + expect(process.exitCode).toBe(1) + }, + ) + + it('leaves the exit code clean when the turn completed', async () => { + const { client } = turnsClient(['completed']) + await pollTurn(client, 'session_1', 'turn_1', 5, true) + expect(process.exitCode).toBe(0) }) +}) - it('sets a failure exit code on a non-completed terminal status (for --wait)', async () => { +describe('watchTurn', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() process.exitCode = 0 - const get = vi.fn().mockResolvedValueOnce({ session: session('failed') }) - const client = { sessions: { get } } as unknown as Ellipsis - await watchSession(client, 'session_1', 5, true) + }) + + it('has nothing to wait for when the session has no turn', async () => { + const { client, get } = turnsClient([]) + await watchTurn(client, makeSession({ turn: null }), { quiet: true }) + expect(get).not.toHaveBeenCalled() + expect(process.exitCode).toBe(0) + }) + + it('answers with the latest turn, without polling, when none is in progress', async () => { + const { client, get } = turnsClient([]) + const failed = makeTurn({ status: 'failed', reason: 'budget_hit', detail: 'over budget' }) + await watchTurn(client, makeSession({ turn: failed }), { quiet: true }) + expect(get).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) - process.exitCode = 0 }) - it('leaves the exit code clean on a completed status', async () => { - process.exitCode = 0 - const get = vi.fn().mockResolvedValueOnce({ session: session('closed') }) - const client = { sessions: { get } } as unknown as Ellipsis - await watchSession(client, 'session_1', 5, true) + it('waits on the turn in progress', async () => { + const { client, get } = turnsClient(['completed']) + await watchTurn(client, makeSession({ turn: makeTurn({ status: 'running' }) }), { quiet: true }) + expect(get).toHaveBeenCalledWith('session_1', 'turn_1') expect(process.exitCode).toBe(0) - process.exitCode = 0 + }) +}) + +describe('turnStatusText / exitCodeForStatus', () => { + it('names the status, with the reason and detail a failed turn carries', () => { + expect(turnStatusText(makeTurn({ status: 'running' }))).toBe('running') + expect( + turnStatusText( + makeTurn({ status: 'failed', reason: 'budget_hit', detail: 'The session reached its budget.' }), + ), + ).toBe('failed (budget_hit): The session reached its budget.') + expect(turnStatusText(makeTurn({ status: 'stopped', detail: 'Stopped by hbrooks.' }))).toBe( + 'stopped: Stopped by hbrooks.', + ) + expect(turnStatusText(null)).toBe('none') + }) + + it('exits 0 only for a completed turn', () => { + expect(exitCodeForStatus('completed')).toBe(0) + for (const status of ['failed', 'stopped', 'cancelled', 'running', 'pending']) { + expect(exitCodeForStatus(status)).toBe(1) + } }) }) @@ -243,7 +285,7 @@ describe('session start prompt positional', () => { let seen: string | undefined const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { seen = JSON.parse(init?.body as string).claude_code?.prompt - return new Response(JSON.stringify({ session: session('scheduled') }), { status: 201 }) + return new Response(JSON.stringify({ session: makeSession() }), { status: 201 }) }) vi.stubGlobal('fetch', fetchMock) vi.spyOn(console, 'log').mockImplementation(() => {})