From e303ca07d30b04aec9a8499a2e25afbc55bc2eee Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 16 Sep 2026 15:05:12 -0400 Subject: [PATCH] Release CLI 2.30.0 with SDK 0.30.0 --- .github/workflows/ci.yml | 3 +- .github/workflows/release.yml | 24 +++-- README.md | 18 +++- bun.lock | 4 +- docs/RELEASING.md | 38 ++++---- package.json | 5 +- scripts/check-versions.mjs | 18 ++++ scripts/compile.sh | 13 +-- skills/ellipsis/SKILL.md | 49 +++++----- src/commands/automation.ts | 68 ++++++------- src/commands/environment.ts | 4 +- src/commands/session.tsx | 101 +++++++++++--------- src/lib/constants.ts | 13 +-- src/lib/sessions.ts | 120 +++++++++++++---------- src/lib/types.ts | 22 ++--- src/ui/ConnectApp.tsx | 7 +- src/ui/launch.tsx | 6 +- test/connect-app.test.ts | 5 +- test/connect.test.ts | 38 +------- test/fixtures/session.ts | 36 +++++++ test/sdk-030.test.ts | 111 ++++++++++++++++++++++ test/sdk-upgrade.test.ts | 92 +++++------------- test/session.test.ts | 50 +++------- test/sessions.test.ts | 173 ++++++++++++---------------------- 24 files changed, 534 insertions(+), 484 deletions(-) create mode 100644 scripts/check-versions.mjs create mode 100644 test/fixtures/session.ts create mode 100644 test/sdk-030.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07fcb6e..0619df6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,8 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: bun install + - run: bun install --frozen-lockfile + - run: bun run check:versions - run: bun run typecheck - run: bun run test - run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03b3fc6..0d75657 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to build (without leading v), e.g. 0.1.0" + description: "Must match package.json, e.g. 2.30.0 (SDK 0.30.0)" required: true permissions: @@ -25,21 +25,27 @@ jobs: - name: Resolve version id: version + env: + INPUT_VERSION: ${{ inputs.version }} run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" else echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" fi - - name: Stamp version into the binary - # package.json is the single source of truth — src/lib/constants.ts reads - # pkg.version, which tsup/bun inline at build time. This MUST match the tag - # because the Homebrew `test do` block asserts `agent --version` == version. - run: npm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version - - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile + + - name: Check CLI, SDK, and release versions + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: bun run check:versions "$RELEASE_VERSION" + + - name: Validate CLI + run: | + bun run typecheck + bun run test - name: Build platform binaries run: | diff --git a/README.md b/README.md index 197244a..761ffdb 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,22 @@ only inside their automation — dashboard starts, cron runs — do not match a repo filter, so `"repo": "any"` is the way to see those alongside the rest. +## SDK 0.30 configuration + +Session configs select one native block. Put the first message inside it: + +```yaml +session: + claude_code: + prompt: Fix the failing tests. + # Or: codex: {model: gpt-6-astra, prompt: Fix the failing tests.} +``` + +The old `harness`, `instructions`, and top-level `prompt` fields are no longer +accepted. `--system` now reports an error; put task instructions in the prompt +or a repository `AGENTS.md` file. Environment build scripts use +`hooks.build_base` and `hooks.after_checkout`; the old `image` block is removed. + ## Develop ```sh @@ -251,7 +267,7 @@ tarballs, and regenerates the formula in [`ellipsis-dev/homebrew-cli`](https://github.com/ellipsis-dev/homebrew-cli). ```sh -git tag v0.1.0 && git push origin v0.1.0 +git tag v2.30.0 && git push origin v2.30.0 ``` The cross-repo push to the tap uses a write-scoped **deploy key**: the public diff --git a/bun.lock b/bun.lock index 9d3c464..a31a0d1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.28.0", + "@ellipsis-dev/sdk": "0.30.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -35,7 +35,7 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.28.0", "", {}, "sha512-xkeXgI0cjUN79JRhKv+NH1IEUfHSOWhcEYPQfrRSVq0Jtd5FRvi/jHGOV2cvbXgJk5VAUyB6X77ZNqo/xmf7vg=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.30.0", "", {}, "sha512-UfLdqiekpM9myblwFXamztC4+UtP2oU2xGtB0H6XCUbEEHHDvKrEzT4eotf/F4rvZcS9HMXvSOXC7APjym2CRw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2f037e4..39f4de5 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,17 +2,21 @@ The CLI ships only as a Homebrew formula from the `ellipsis-dev/homebrew-cli` tap. It is never published to npm: `package.json` is `private`, has no `bin`, -and there is no `publishConfig`. The single `npm` call in the release workflow -(`npm version --no-git-tag-version`) is just a local tool to rewrite the -version field, not a registry publish. +and there is no `publishConfig`. + +CLI **2.X.Y** always uses SDK **0.X.Y**. For example, CLI **2.30.0** uses +`@ellipsis-dev/sdk` **0.30.0**. Keep the CLI version and exact SDK dependency +in `package.json` up to date together, and commit the regenerated `bun.lock`. +Version ranges such as `^0.30.0` are not allowed. CLI-only changes also need +a matching SDK release when advancing the CLI version. Publishing is fully automated by `.github/workflows/release.yml`, triggered by -pushing a `vX.Y.Z` git tag (there is also a `workflow_dispatch` fallback that +pushing a `v2.X.Y` git tag (there is also a `workflow_dispatch` fallback that takes a version input in the Actions UI). On a tag push it: -1. Stamps the version into `package.json` (the single source of truth: - `src/lib/constants.ts` reads `pkg.version`, which bun inlines into the - binary, so `agent --version` never drifts). +1. Installs dependencies with the frozen lockfile, checks that the release + version matches `package.json` and the installed SDK follows the version + rule, then runs typechecking and tests. Mismatches stop the release. 2. Cross-compiles four binaries (`darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`) with `bun build --compile`, tars each, and computes SHA-256 checksums. @@ -20,15 +24,13 @@ takes a version input in the Actions UI). On a tag push it: 4. Regenerates `Formula/agent.rb` in the tap repo from the template and pushes it, so `brew install ellipsis-dev/cli/agent` picks up the new version. -The only manual steps (Hunter cuts releases) are: ensure CI is green, then -create and push the `vX.Y.Z` tag on the main commit to release. No -`package.json` bump commit is needed — the workflow's `npm version` step -stamps the tag's version into `package.json` before building, so the field on -main goes stale by design (releases since v1.4.0 tag main directly). +The manual steps (Hunter cuts releases) are: commit the version updates and +SDK migration, ensure CI is green, then create and push the matching `v2.X.Y` +tag on the main commit to release. For CLI 2.30.0, use `v2.30.0`. The workflow +validates the committed version; it does not rewrite it. -Because the field is stale, local builds don't read their version from it: -`bun run compile` (scripts/compile.sh) stamps the binary from -`git describe --tags`, so `./agent --version` reports exactly what it was -built from — `1.6.0` on a clean tagged checkout, `1.6.0-2-g08ea24d-dirty` two -commits past the tag with uncommitted changes. Only `tsx` dev runs fall back -to the stale `package.json` field. +`package.json` is the version source for development, local compiled binaries, +and releases. `bun run compile` checks the CLI/SDK pair before building; +`./agent --version` reports `2.30.0` for this version, including local builds. +Run `bun run check:versions` to check the pair without building, or +`bun run check:versions 2.30.0` to also validate an intended release version. diff --git a/package.json b/package.json index c1c7004..9ab02a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ellipsis/cli", - "version": "1.3.0", + "version": "2.30.0", "description": "Ellipsis agent CLI: drive the Ellipsis cloud from your terminal", "license": "MIT", "type": "module", @@ -13,6 +13,7 @@ "scripts": { "dev": "tsx src/cli.tsx", "build": "tsup", + "check:versions": "bun scripts/check-versions.mjs", "typecheck": "tsc --noEmit", "compile": "./scripts/compile.sh", "start": "node dist/cli.js", @@ -20,7 +21,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.28.0", + "@ellipsis-dev/sdk": "0.30.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs new file mode 100644 index 0000000..533e2eb --- /dev/null +++ b/scripts/check-versions.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs' + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) +const sdk = JSON.parse(readFileSync(new URL('../node_modules/@ellipsis-dev/sdk/package.json', import.meta.url), 'utf8')) +const match = /^2\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(pkg.version) +if (!match) throw new Error('CLI version must be 2.X.Y') + +const expectedSdk = `0.${match[1]}.${match[2]}` +if (pkg.dependencies['@ellipsis-dev/sdk'] !== expectedSdk || sdk.version !== expectedSdk) { + throw new Error(`CLI ${pkg.version} requires SDK ${expectedSdk}, pinned exactly and installed`) +} + +const releaseVersion = process.argv[2] +if (releaseVersion !== undefined && releaseVersion !== pkg.version) { + throw new Error(`Release version ${releaseVersion} must match package.json version ${pkg.version}`) +} + +console.log(`CLI ${pkg.version} / SDK ${sdk.version}`) diff --git a/scripts/compile.sh b/scripts/compile.sh index d23a89a..ee86e29 100755 --- a/scripts/compile.sh +++ b/scripts/compile.sh @@ -1,13 +1,6 @@ #!/usr/bin/env bash -# Local/dev compile. Stamps the binary's version from `git describe` so -# `./agent --version` reports exactly what it was built from, e.g. -# "1.6.0-2-g08ea24d-dirty" = 2 commits past v1.6.0 at 08ea24d with -# uncommitted changes (a tagged, clean checkout reads the bare "1.6.0"). -# package.json's version field is NOT the truth for local builds: releases -# stopped bumping it (the release workflow rewrites it at tag time and -# builds directly, without this script), so it goes stale on main by design. +# Local builds use the same package.json version as release builds. set -euo pipefail cd "$(dirname "$0")/.." -version="$(git describe --tags --always --dirty | sed 's/^v//')" -exec bun build src/cli.tsx --compile --outfile agent \ - --define "BUILD_GIT_VERSION=\"${version}\"" +bun run check:versions +exec bun build src/cli.tsx --compile --outfile agent diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 3533747..0e1d2ff 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -98,6 +98,7 @@ and `web`: ```yaml ellipsis: version: v1 + kind: agent name: Recent work summary description: Summarizes the week's merged work across api and web @@ -106,14 +107,13 @@ trigger: schedule: "0 9 * * 1" session: - harness: - type: claude_code + claude_code: model: claude-haiku-4-5-20251001 - instructions: | - Summarize the pull requests merged in api and web over the last 7 - days. Group them by theme, lead with user-facing changes, and return - the summary as your answer. Ground every line in a real PR. Never - invent activity. + prompt: | + Summarize the pull requests merged in api and web over the last 7 + days. Group them by theme, lead with user-facing changes, and return + the summary as your answer. Ground every line in a real PR. Never + invent activity. environment: repositories: @@ -183,6 +183,7 @@ Actions and filters live inside the surface block: ```yaml ellipsis: version: v1 + kind: agent name: Migration reviewer description: Flags unsafe database migrations on pull requests @@ -195,14 +196,13 @@ trigger: paths: ["migrations/**"] session: - harness: - type: claude_code - instructions: | - Review the database migrations in this pull request for production - safety: locking that blocks writes on large tables, missing backfills - for new non-null columns, and rollout ordering that breaks if the - migration and the code deploy out of order. Comment on the pull - request with what you find. + claude_code: + prompt: | + Review the database migrations in this pull request for production + safety: locking that blocks writes on large tables, missing backfills + for new non-null columns, and rollout ordering that breaks if the + migration and the code deploy out of order. Comment on the pull + request with what you find. environment: repositories: @@ -487,7 +487,7 @@ Top-level keys, all optional except `ellipsis`: | Key | Purpose | | --- | --- | -| `ellipsis` | `version: v1`, `name`, `description`, `metadata`, and the `enabled`, `interactive`, `ide` flags. Its presence marks the file as a config. | +| `ellipsis` | `kind: agent`, `version: v1`, `name`, `description`, `metadata`, and `enabled`. Its presence marks the file as a config. | | `trigger` | One trigger, or omit for a manual-only agent. | | `input` | A JSON Schema for the payload `agent automation run` passes, and the message template it renders into. | | `session` | What every session runs on; the keys below. The same keys, flattened, are the body of `agent` / `POST /v1/sessions`. | @@ -496,9 +496,8 @@ Under `session`: | Key | Purpose | | --- | --- | -| `harness` | Required: `type: claude_code` or `type: codex`, plus that harness's native `model` and `effort` options. Claude Code also accepts `fallback_model`, `max_turns`, and `settings`. | -| `instructions` | Text or repository file references appended to the harness prompt. | -| `environment` | A saved environment by name, or an inline block: `repositories`, `variables`, `ports`, `compute`, `image`, `hooks`, `mcp_servers`. | +| `claude_code` or `codex` | Exactly one native block, with `prompt`, `model`, and `effort`. Claude Code also accepts `fallback_model`, `max_turns`, and `settings`. | +| `environment` | A saved environment by name, or an inline block: `repositories`, `variables`, `compute`, `hooks`, `mcp_servers`. | | `permissions` | What it may do: `github` scopes its GitHub token, `ellipsis` its API token. | | `skills` | Claude Code skills beyond what the cloned repositories provide. | | `output` | A JSON Schema contract, so downstream automation gets typed data. | @@ -507,10 +506,10 @@ Under `session`: The schema is strict, so an unknown or misplaced key fails validation rather than being silently dropped. Points that decide whether a config works: -- `session.instructions` takes inline text, a `{file: path}` reference to a repository - file, or an ordered list of both, joined at session start. It is appended to - the selected harness's default prompt. 64 KiB per file. -- `session.harness.model` selects a model for the chosen harness. Claude Code +- `session.claude_code.prompt` or `session.codex.prompt` supplies the first user + message verbatim. Put repository guidance in `AGENTS.md`. The former `harness` + and `instructions` keys are rejected. +- `session.claude_code.model` or `session.codex.model` selects a model. Claude Code inherits the organization default when omitted. `agent model list` reports the available ids and the harness certified for each. Digest and summary jobs run well on `claude-haiku-4-5-20251001`; judgment jobs earn the @@ -525,8 +524,8 @@ than being silently dropped. Points that decide whether a config works: 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 together with a mention trigger. -- `ellipsis.interactive: false` opts sessions out of messages entirely, for - fire-and-forget automations. `ellipsis.ide: false` locks the sandbox shut. +- Raw session starts accept `lifecycle.interactive: false` to run once. The + returned `lifecycle.prompting` describes whether direct messages are accepted. 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 diff --git a/src/commands/automation.ts b/src/commands/automation.ts index 6bf1989..43d055f 100644 --- a/src/commands/automation.ts +++ b/src/commands/automation.ts @@ -20,7 +20,7 @@ const DEFAULT_CONFIG_PATH = 'agents/my_agent.yaml' // Automations: saved agent definitions (trigger + persona + environment // reference) that run on their trigger or when invoked. Their files still -// live under agents/ in a repository; the API resource is /v1/automations. +// live under agents/ in a repository; the API resource is /v1/agents. export function registerAutomation(program: Command): void { const automation = alsoKnownAs( program.command('automation').description('Inspect, manage, and invoke your automations'), @@ -29,12 +29,12 @@ export function registerAutomation(program: Command): void { apiRoutes( alsoKnownAs(automation.command('list').description('List your automations'), 'ls'), - 'GET /v1/automations', + 'GET /v1/agents', ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const { automations } = await api().automations.list() + const { agents: automations } = await api().agents.list() if (opts.json) { printJson(automations) return @@ -60,7 +60,7 @@ export function registerAutomation(program: Command): void { automation .command('get ') .description('Print one automation as YAML, or as JSON with --json'), - 'GET /v1/automations/{id}', + 'GET /v1/agents/{id}', ) .option('--json', 'output raw JSON') .action(async (automationId: string, opts: { json?: boolean }) => { @@ -68,13 +68,13 @@ export function registerAutomation(program: Command): void { const client = api() // --json is the machine-readable mode: emit only the raw automation. if (opts.json) { - printJson((await client.automations.get(automationId)).automation) + printJson((await client.agents.get(automationId)).agent) return } // Fetch the automation and the login (for the link) together. The link // goes to stderr so the YAML on stdout stays clean for piping. - const [{ automation: a }, me] = await Promise.all([ - client.automations.get(automationId), + const [{ agent: a }, me] = await Promise.all([ + client.agents.get(automationId), client.identity(), ]) printYaml(a) @@ -86,12 +86,12 @@ export function registerAutomation(program: Command): void { automation .command('metrics ') .description("Print an automation's trailing spend against its budget and its session medians"), - 'GET /v1/automations/{id}/metrics', + 'GET /v1/agents/{id}/metrics', ) .option('--json', 'output raw JSON (every session sample and the spend windows)') .action(async (automationId: string, opts: { json?: boolean }) => { await runAction(async () => { - const metrics = await api().automations.metrics(automationId) + const metrics = await api().agents.metrics(automationId) if (opts.json) { printJson(metrics) return @@ -127,7 +127,7 @@ export function registerAutomation(program: Command): void { automation .command('run ') .description('Invoke an automation: run it exactly as defined, by id or name'), - 'POST /v1/automations/{id}/sessions', + 'POST /v1/agents/{id}/sessions', ) .option( '-i, --input ', @@ -167,7 +167,7 @@ export function registerAutomation(program: Command): void { input = parseInputObject(readFileSync(opts.inputFile, 'utf8'), opts.inputFile) } const client = api() - const { session } = await client.automations.run(automationId, { + const { session } = await client.agents.run(automationId, { ...(input !== undefined ? { input } : {}), ...(opts.budget !== undefined ? { budget: opts.budget } : {}), ...(Object.keys(opts.metadata).length > 0 ? { metadata: opts.metadata } : {}), @@ -193,7 +193,7 @@ export function registerAutomation(program: Command): void { automation .command('create') .description('Create an automation, live immediately or by pull request with --repo'), - 'POST /v1/automations', + 'POST /v1/agents', ) .option( '-r, --repo ', @@ -228,12 +228,12 @@ export function registerAutomation(program: Command): void { const definition = opts.file ? (readConfigFile(opts.file) as AutomationConfig) : (parseYaml((await client.templates.get(opts.template!)).yaml) as AutomationConfig) - const req: CreateAutomationRequest = { automation: definition } - const created = await client.automations.create(req) + const req: CreateAutomationRequest = { agent: definition } + const created = await client.agents.create(req) // With --repo the automation is then moved into the repository by // pull request (create + link, the two-step the API exposes). if (opts.repo) { - const linked = await client.automations.link(created.automation.id, { + const linked = await client.agents.link(created.agent.id, { repository: opts.repo, path: opts.path, }) @@ -242,7 +242,7 @@ export function registerAutomation(program: Command): void { return } console.log( - `✓ created "${automationName(linked.automation)}" (${linked.automation.id}) — live now`, + `✓ created "${automationName(linked.agent)}" (${linked.agent.id}) — live now`, ) console.log(`✓ opened a pull request adding the definition file (${linked.path})`) console.log(linked.pull_request_url) @@ -267,14 +267,14 @@ export function registerAutomation(program: Command): void { .description("Replace an API-managed automation's definition from a file, live immediately"), 'update', ), - 'PUT /v1/automations/{id}', + 'PUT /v1/agents/{id}', ) .requiredOption('-f, --file ', 'definition file (.yaml/.yml or .json) to replace it with') .option('--json', 'output raw JSON') .action(async (automationId: string, opts: { file: string; json?: boolean }) => { await runAction(async () => { - const { automation: updated } = await api().automations.update(automationId, { - automation: readConfigFile(opts.file) as AutomationConfig, + const { agent: updated } = await api().agents.update(automationId, { + agent: readConfigFile(opts.file) as AutomationConfig, }) if (opts.json) { printJson(updated) @@ -291,12 +291,12 @@ export function registerAutomation(program: Command): void { .description('Delete an API-managed automation; it stops running and frees its name'), 'rm', ), - 'DELETE /v1/automations/{id}', + 'DELETE /v1/agents/{id}', ) .option('--json', 'output raw JSON') .action(async (automationId: string, opts: { json?: boolean }) => { await runAction(async () => { - await api().automations.delete(automationId) + await api().agents.delete(automationId) // 204 No Content — nothing to echo, so confirm with what was addressed. if (opts.json) printJson({ id: automationId, deleted: true }) else console.log(`✓ deleted ${automationId}`) @@ -310,7 +310,7 @@ export function registerAutomation(program: Command): void { automation .command('link ') .description('Move an automation into a repository by opening a pull request that adds its file'), - 'POST /v1/automations/{id}/link', + 'POST /v1/agents/{id}/link', ) .requiredOption('-r, --repo ', 'repository in your account to move the automation into') .option( @@ -321,7 +321,7 @@ export function registerAutomation(program: Command): void { .action( async (automationId: string, opts: { repo: string; path?: string; json?: boolean }) => { await runAction(async () => { - const linked = await api().automations.link(automationId, { + const linked = await api().agents.link(automationId, { repository: opts.repo, path: opts.path, }) @@ -340,12 +340,12 @@ export function registerAutomation(program: Command): void { automation .command('unlink ') .description('Take an automation over from its file, so this API changes it instead'), - 'POST /v1/automations/{id}/unlink', + 'POST /v1/agents/{id}/unlink', ) .option('--json', 'output raw JSON') .action(async (automationId: string, opts: { json?: boolean }) => { await runAction(async () => { - const { automation: unlinked } = await api().automations.unlink(automationId) + const { agent: unlinked } = await api().agents.unlink(automationId) if (opts.json) { printJson(unlinked) return @@ -361,7 +361,7 @@ export function registerAutomation(program: Command): void { .description( `Scaffold a starter automation YAML locally (default: ${DEFAULT_CONFIG_PATH})`, ), - 'POST /v1/automations with --template', + 'POST /v1/agents with --template', ) // No `-f` short: CLI-wide, `-f` means an input file (see `automation create`). .option('--force', 'overwrite the file if it already exists') @@ -394,10 +394,10 @@ export function registerAutomation(program: Command): void { await runAction(async () => { const client = api() const template = await client.templates.get(opts.template!) - const created = await client.automations.create({ - automation: parseYaml(template.yaml) as AutomationConfig, + const created = await client.agents.create({ + agent: parseYaml(template.yaml) as AutomationConfig, }) - const linked = await client.automations.link(created.automation.id, { + const linked = await client.agents.link(created.agent.id, { repository: opts.repo!, path: opts.path, }) @@ -434,7 +434,7 @@ function parseInputObject(text: string, what: string): Record { function printCreated(created: CreatedAutomation): void { console.log( - `✓ created "${automationName(created.automation)}" (${created.automation.id}) — live now`, + `✓ created "${automationName(created.agent)}" (${created.agent.id}) — live now`, ) console.log( 'It has no file; change it with `agent automation edit`, or `agent automation link` to move it into a repo.', @@ -453,6 +453,7 @@ function starterConfig(name: string): string { # from GitHub. Valid locations: agents/, .agents/, ellipsis/, .ellipsis/ (any depth). ellipsis: version: v1 + kind: agent name: ${name} description: What this automation does. @@ -463,11 +464,10 @@ ellipsis: # What each session runs on. session: - harness: - type: claude_code + claude_code: # model: claude-opus-5 # optional; defaults to the organization default - instructions: | - You are an Ellipsis agent. Describe the task you want it to perform here. + prompt: | + You are an Ellipsis agent. Describe the task you want it to perform here. # environment: backend # a saved environment, by name ` } diff --git a/src/commands/environment.ts b/src/commands/environment.ts index 748a848..105b1bd 100644 --- a/src/commands/environment.ts +++ b/src/commands/environment.ts @@ -189,8 +189,8 @@ mcp_servers: # Authorization: "Bearer \${SENTRY_AUTH_TOKEN}" # Toolchain baked into the cached image (runs once per image build). -image: - setup: | +hooks: + build_base: | echo "install CLIs and dependencies here" # Sandbox sizing. diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 6f65ac3..d9bc3e9 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -52,6 +52,7 @@ import { readImageAttachment } from '../lib/images' import { formatStepLine, oneLine, recordText } from '../lib/steps' import { parseRepo, + withSessionPrompt, assertCurrentHarnessKeys, sessionConfigName, startRequestFromConfig, @@ -68,8 +69,9 @@ const FALLBACK_POLL_INTERVAL_SECONDS = 2 // Statuses past which a session no longer changes — `--watch` stops here. const TERMINAL_STATUSES: ReadonlySet = new Set([ - 'completed', - 'error', + 'closed', + 'idle', + 'failed', 'cancelled', 'stopped', ]) @@ -107,7 +109,7 @@ export function registerSession(program: Command): void { ) .option( '--override ', - 'partial patch (YAML/JSON) of session config keys merged onto the inline config, e.g. "harness:\\n effort: high"', + 'partial patch (YAML/JSON) of session config keys merged onto the inline config, e.g. "claude_code:\\n effort: high"', ) .option( '--override-file ', @@ -115,10 +117,10 @@ export function registerSession(program: Command): void { ) .option( '--model ', - 'override harness.model for this session (see `agent model list`)', + 'override the selected harness model for this session (see `agent model list`)', ) .option('--harness ', 'select claude_code or codex (default: claude_code)', toHarness) - .option('--system ', 'override the instructions appended to the harness prompt') + .option('--system ', 'retired; put instructions in the prompt or AGENTS.md') .option( '-r, --repo ', 'also check out a repository, in whichever environment the session runs (repeatable; a bare name means your account)', @@ -227,20 +229,20 @@ export function registerSession(program: Command): void { // The flat raw-session body: a SessionConfig plus run settings; // there is no base config to merge onto (a saved automation is // invoked with `agent automation run` instead). - let req: StartAgentSessionRequest = { harness: { type: 'claude_code' } } + let req: StartAgentSessionRequest = { claude_code: {} } if (opts.configFile) { - req = { ...req, ...startRequestFromConfig(readConfigFile(opts.configFile)) } + req = startRequestFromConfig(readConfigFile(opts.configFile)) } // Templates left the start request (#6394): resolve the slug to its // YAML via GET /v1/templates/{slug} and start inline. if (opts.template) { const template = await api().templates.get(opts.template) - req = { ...req, ...startRequestFromConfig(parseYaml(template.yaml)) } + req = startRequestFromConfig(parseYaml(template.yaml)) } // Sugar flags (--model, --repo, --cpu, ...) and the raw --override // are one structured patch, deep-merged onto the inline config so an // explicit flag wins over the same field from -f/-t. - const override = buildStartOverride(opts) + const override = buildStartOverride(opts, req.codex ? 'codex' : 'claude_code') if (override) req = deepMerge(req, override) as StartAgentSessionRequest // A NAMED environment re-picks it wholesale, so there is nothing for // the environment fields of an override to merge into. @@ -260,7 +262,7 @@ export function registerSession(program: Command): void { if (contextRepo) req = withContextRepository(req, contextRepo) // Appended to the initial user query at build time; gives this // session instructions on top of the config's shared system prompt. - if (promptText) req.prompt = promptText + if (promptText) req = withSessionPrompt(req, promptText) // Pictures ride the first message inline, the way a paste into a // local `claude` does: the prompt gains an `[Image #N]` placeholder // per file and the model sees each as a content block on turn 0. @@ -286,12 +288,12 @@ export function registerSession(program: Command): void { // app would land in scrollback); every other mode prints this note. let configNote: string | undefined const environmentSource = session.environment?.source - if (session.environment?.environment_id && environmentSource !== 'request') { + if (session.environment?.id && environmentSource !== 'request') { const label = - environmentSource === 'automation' + environmentSource === 'agent' ? 'from the automation' : environmentSource - const note = `using environment ${session.environment.environment_id} (${label})` + const note = `using environment ${session.environment.id} (${label})` configNote = configNote ? `${configNote}; ${note}` : note } @@ -300,7 +302,7 @@ export function registerSession(program: Command): void { // a connect would fail; degrade to watching the output instead. // The wire session carries no config blob, so interactivity comes // from the same projection POST /messages enforces. - if (!session.prompting.enabled) { + if (!session.lifecycle.prompting.enabled) { if (configNote) console.log(configNote) console.log( 'this agent is not interactive; watching output instead of connecting', @@ -340,7 +342,7 @@ export function registerSession(program: Command): void { printJson(session) return } - console.log(`✓ started session ${session.id} (${session.status})`) + console.log(`✓ started session ${session.id} (${session.lifecycle.status})`) await printSessionUrl(client, session.id) console.log(` follow with: agent session get ${session.id} --watch`) }) @@ -390,7 +392,7 @@ export function registerSession(program: Command): void { const client = api() const sessions = ( await client.sessions.list({ - automation: opts.automation, + agent: opts.automation, source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, author_id: opts.author ? await resolveAuthorId(client, opts.author) : undefined, days: opts.days, @@ -411,9 +413,9 @@ export function registerSession(program: Command): void { ['ID', 'STATUS', 'SOURCE', 'CREATED', 'COST'], sessions.map((s) => [ s.id, - s.status, + s.lifecycle.status, s.source ?? '-', - formatTs(s.created_at), + formatTs(s.lifecycle.timestamps.created_at), usdFromMillicents(s.cost?.total ?? 0), ]), ) @@ -457,7 +459,7 @@ export function registerSession(program: Command): void { 'log', 'logs', ), - 'GET /v1/sessions/{id}/export', + 'GET /v1/sessions/{id}/download', ) .option('-o, --output ', 'write to a file instead of stdout') .option('--gzip', 'keep the concatenated .jsonl.gz bytes as-is (skip gunzip)') @@ -472,7 +474,7 @@ export function registerSession(program: Command): void { }, ) => { await runAction(async () => { - const manifest = await api().sessions.export(sessionId) + const manifest = await api().sessions.download(sessionId) if (opts.json) { printJson(manifest) return @@ -598,7 +600,7 @@ export function registerSession(program: Command): void { printJson(s) return } - console.log(`✓ stopped session ${sessionId} (${s.status})`) + console.log(`✓ stopped session ${sessionId} (${s.lifecycle.status})`) }) }) } @@ -687,10 +689,10 @@ export async function watchSessionStreaming( } // Terminal `done` frame. Output already streamed live; print a one-line cap. if (!json) { - const mark = outcome.status === 'completed' ? '✓' : '✗' + const mark = exitCodeForStatus(outcome.exitStatus ?? outcome.status) === 0 ? '✓' : '✗' console.log(`\n${mark} session ${sessionId} ${outcome.status}`) } - if (exitCodeForStatus(outcome.status) !== 0) process.exitCode = 1 + if (exitCodeForStatus(outcome.exitStatus ?? outcome.status) !== 0) process.exitCode = 1 } function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { @@ -728,7 +730,7 @@ function renderFrameHuman(frame: StreamFrame, statusWord?: string): void { // Exit 0 for a successful terminal status, non-zero otherwise (spec §4.1). export function exitCodeForStatus(status: string): number { - return status === 'completed' ? 0 : 1 + return ['completed', 'closed', 'idle'].includes(status) ? 0 : 1 } // Poll a session until it reaches a terminal status, printing each status @@ -744,21 +746,21 @@ export async function watchSession( let last: AgentSessionStatus | undefined for (;;) { const { session: s } = await client.sessions.get(sessionId) - if (s.status !== last) { + if (s.lifecycle.status !== last) { if (!json) { - const reason = s.status_reason ? `: ${s.status_reason}` : '' - console.log(`${nowClock()} ${s.status}${reason}`) + const reason = s.lifecycle.detail ? `: ${s.lifecycle.detail}` : '' + console.log(`${nowClock()} ${s.lifecycle.status}${reason}`) } - last = s.status + last = s.lifecycle.status } - if (TERMINAL_STATUSES.has(s.status)) { + if (TERMINAL_STATUSES.has(s.lifecycle.status)) { if (json) { printJson(s) } else { console.log('') printSessionSummary(s) } - if (exitCodeForStatus(s.status) !== 0) process.exitCode = 1 + if (exitCodeForStatus(s.lifecycle.last_execution_result?.completion_reason ?? s.lifecycle.status) !== 0) process.exitCode = 1 return } await sleep(intervalMs) @@ -767,12 +769,12 @@ export async function watchSession( function printSessionSummary(s: AgentSession): void { console.log(`id: ${s.id}`) - console.log(`status: ${s.status}${s.status_reason ? ` (${s.status_reason})` : ''}`) + console.log(`status: ${s.lifecycle.status}${s.lifecycle.detail ? ` (${s.lifecycle.detail})` : ''}`) if (s.source) console.log(`source: ${s.source}`) const config = sessionConfigName(s) if (config) console.log(`config: ${config}`) - console.log(`created: ${s.created_at}`) - console.log(`updated: ${s.updated_at}`) + 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)}`) const keys = Object.keys(s.metadata ?? {}) @@ -807,7 +809,7 @@ export function buildStartOverride(opts: { memory?: string timeout?: string budget?: number -}): Record | undefined { +}, defaultHarness: 'claude_code' | 'codex' = 'claude_code'): Record | undefined { if (opts.override && opts.overrideFile) { throw new Error('provide only one of --override / --override-file') } @@ -824,11 +826,13 @@ export function buildStartOverride(opts: { assertCurrentHarnessKeys(base) const sugar: Record = {} - const harness: Record = {} - if (opts.harness !== undefined) harness.type = opts.harness - if (opts.model !== undefined) harness.model = opts.model - if (Object.keys(harness).length) sugar.harness = harness - if (opts.system !== undefined) sugar.instructions = opts.system + if (opts.system !== undefined) { + throw new Error('--system is no longer supported; put task instructions in the prompt or a repository AGENTS.md file') + } + const type = opts.harness ?? (base.codex ? 'codex' : base.claude_code ? 'claude_code' : defaultHarness) + if (opts.harness !== undefined || opts.model !== undefined) { + sugar[type] = { ...(opts.model !== undefined ? { model: opts.model } : {}) } + } const compute: Record = {} if (opts.cpu !== undefined) compute.cpu = opts.cpu @@ -864,12 +868,19 @@ function deepMerge( const out: Record = { ...base } for (const [k, v] of Object.entries(over)) { const b = out[k] - // Native options from another harness must not leak across a switch. - const changesHarness = - k === 'harness' && isPlainObject(b) && isPlainObject(v) && - b.type !== undefined && v.type !== undefined && b.type !== v.type - out[k] = - isPlainObject(b) && isPlainObject(v) && !changesHarness ? deepMerge(b, v) : v + if ((k === 'codex' || k === 'claude_code') && v != null) { + const other = k === 'codex' ? 'claude_code' : 'codex' + const previous = out[other] + // Carry the first message across a harness switch, never native options. + if (isPlainObject(previous) && isPlainObject(v) && v.prompt === undefined && previous.prompt !== undefined) { + out[k] = { ...v, prompt: previous.prompt } + } else { + out[k] = isPlainObject(b) && isPlainObject(v) ? deepMerge(b, v) : v + } + delete out[other] + } else { + out[k] = isPlainObject(b) && isPlainObject(v) ? deepMerge(b, v) : v + } } return out } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index bf7a14b..68767e8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,16 +1,7 @@ import pkg from '../../package.json' -// The version, in precedence order: -// 1. BUILD_GIT_VERSION — stamped by scripts/compile.sh (`bun run compile`) -// from `git describe`, so a locally built binary reports exactly the -// commit it was built from ("1.6.0-2-g08ea24d") instead of package.json's -// stale field (releases stopped bumping it; see docs/RELEASING.md). -// 2. pkg.version — release builds (the workflow rewrites package.json from -// the tag, then runs `bun build` directly, without the define) and tsx -// dev runs. -declare const BUILD_GIT_VERSION: string | undefined -export const VERSION: string = - typeof BUILD_GIT_VERSION === 'string' ? BUILD_GIT_VERSION : pkg.version +// CLI 2.X.Y uses SDK 0.X.Y. CI and releases check the exact version pair. +export const VERSION: string = pkg.version // Sent on every API/WebSocket request so the server can record which client // started a session (stored on the session as client_version, shown for diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index e261d00..ef06ef4 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -12,6 +12,17 @@ import type { SupportedModel, } from './types' +// Both REST sessions and start requests carry the prompt in the selected harness. +export function sessionPrompt(session: Pick): string | null | undefined { + return session.claude_code?.prompt ?? session.codex?.prompt +} + +export function withSessionPrompt(req: StartAgentSessionRequest, prompt: string): StartAgentSessionRequest { + return req.codex + ? { ...req, codex: { ...req.codex, prompt } } + : { ...req, claude_code: { ...req.claude_code, prompt } } +} + // Pure session-model helpers shared by the connect command and the // multi-session UI (SessionsApp). No I/O here — everything is testable. @@ -35,8 +46,8 @@ export function connectability(session: AgentSession): { canSend: boolean reason?: string } { - if (session.prompting.enabled) return { canSend: true } - const detail = session.prompting.detail?.trim() + if (session.lifecycle.prompting.enabled) return { canSend: true } + const detail = session.lifecycle.prompting.detail?.trim() return { canSend: false, reason: detail @@ -45,8 +56,7 @@ export function connectability(session: AgentSession): { } } -// The one-word display status for a session row (the SDK's surface-first -// projection over the raw status). +// The canonical lifecycle status for a session row. export function rowStatusWord(session: AgentSession): string { return sessionStatusWord(session as unknown as FrameSession) } @@ -80,7 +90,7 @@ export function rowGlyph(word: string): { glyph: string; color?: string; dim: bo export function rowDescription(session: AgentSession): string { const summary = session.summary?.description if (typeof summary === 'string' && summary.trim()) return oneLineText(summary) - const prompt = session.prompt + const prompt = sessionPrompt(session) if (typeof prompt === 'string' && prompt.trim()) return oneLineText(prompt) const source = typeof session.source === 'string' ? session.source : null return source ? `${source} session` : 'session' @@ -93,11 +103,11 @@ function oneLineText(text: string): string { // The instant the session last did anything visible — what the row's age // line counts from. export function lastEventAt(session: AgentSession): string { - const last = session.last_activity_at + const last = session.lifecycle.timestamps.last_activity_at if (typeof last === 'string' && last) return last - const msg = session.last_message_at + const msg = session.lifecycle.timestamps.last_message_at if (typeof msg === 'string' && msg) return msg - return session.updated_at + return session.lifecycle.timestamps.updated_at } // Compact age for the row's second line: "12s ago", "2m ago", "3h ago", @@ -130,7 +140,7 @@ export function rowMeta(session: AgentSession, now: Date = new Date()): string { // the id of the automation it was started from. Both are absent for raw // sessions, which have nothing to name. export function sessionConfigName(session: AgentSession): string | null { - const automation = session.automation + const automation = session.agent if (!automation) return null return automation.config.ellipsis.name ?? automation.id ?? null } @@ -162,7 +172,7 @@ export function statusBand(word: string): number { // Stable for equal keys (equal band + equal created_at keeps input order). export function sortSidebarSessions(sessions: readonly AgentSession[]): AgentSession[] { const band = (s: AgentSession): number => statusBand(rowStatusWord(s)) - const born = (s: AgentSession): number => Date.parse(s.created_at) || 0 + const born = (s: AgentSession): number => Date.parse(s.lifecycle.timestamps.created_at) || 0 return [...sessions].sort((a, b) => band(a) - band(b) || born(b) - born(a)) } @@ -250,7 +260,7 @@ export function filterSessions( if (!q) return sessions return sessions.filter((s) => { const summary = typeof s.summary?.description === 'string' ? s.summary.description : '' - const prompt = typeof s.prompt === 'string' ? s.prompt : '' + const prompt = typeof sessionPrompt(s) === 'string' ? sessionPrompt(s) : '' return `${summary}\n${prompt}`.toLowerCase().includes(q) }) } @@ -267,7 +277,7 @@ export function attentionFlip(prevWord: string | undefined, nextWord: string): b // --------------------------- start request shaping ------------------------- // POST /v1/sessions is flat: the request IS a SessionConfig plus run settings // (there is no base config and no merge; a saved automation is invoked with -// POST /v1/automations/{id}/sessions instead). +// POST /v1/agents/{id}/sessions instead). // Parse a repository value into an environment.repositories entry. // "owner/name" sets both; a bare "name" omits owner so the server defaults it @@ -287,8 +297,8 @@ export function parseRepo(value: string): { name: string; owner?: string } { // dollar number on the request where the file has a `budget.session`, so it // is lifted. const START_CONFIG_KEYS = [ - 'harness', - 'instructions', + 'claude_code', + 'codex', 'environment', 'output', 'permissions', @@ -296,11 +306,8 @@ const START_CONFIG_KEYS = [ ] as const export function assertCurrentHarnessKeys(config: Record): void { - if ('claude' in config || 'codex' in config) { - throw new Error( - 'replace the legacy claude/codex block with harness: {type: claude_code or codex, ...}, ' + - 'and move system to instructions', - ) + if ('claude' in config || 'harness' in config || 'instructions' in config || 'prompt' in config) { + throw new Error('use claude_code: {prompt: ...} or codex: {prompt: ...}; harness and instructions are no longer supported') } } @@ -317,12 +324,9 @@ export function startRequestFromConfig( ? (nested as Record) : document assertCurrentHarnessKeys(config) - const harness = config.harness - if ( - !harness || typeof harness !== 'object' || !('type' in harness) || - (harness.type !== 'claude_code' && harness.type !== 'codex') - ) { - throw new Error('session config must include harness.type: claude_code or codex') + const selected = [config.claude_code, config.codex].filter((v) => v != null) + if (selected.length !== 1 || typeof selected[0] !== 'object' || Array.isArray(selected[0])) { + throw new Error('session config must include exactly one claude_code or codex object') } const req: Record = {} for (const key of START_CONFIG_KEYS) { @@ -358,7 +362,7 @@ export function withContextRepository( // uses and the other two pickers leave unset: repositories and automations // are flat lists of names with no vendor to group under and no price to quote. export type ComposerModel = { - harness?: StartAgentSessionRequest['harness']['type'] + harness?: 'claude_code' | 'codex' modelId?: string id: string | null label: string @@ -514,7 +518,7 @@ export const REPOSITORIES_HEADING = 'repositories' export const VARIABLES_HEADING = 'variables' export const ADD_VARIABLE_LABEL = '+ new' export const COMPUTE_HEADING = 'compute' -export const IMAGE_HEADING = 'image' +export const IMAGE_HEADING = 'build' export const HOOKS_HEADING = 'hooks' export const MCP_SERVERS_HEADING = 'mcp servers' export const ADD_MCP_SERVER_LABEL = '+ new' @@ -530,16 +534,14 @@ export type CustomCompute = Readonly> export const EMPTY_COMPUTE: CustomCompute = { cpu: '', memory: '', timeout: '' } -// The image customization fields: `dockerfile_append` layers onto the image -// before any repo exists, `setup` runs at build time after checkout and is -// captured by the cached snapshot. One-line inputs here — a longer script -// belongs in an environment YAML. -export const IMAGE_FIELDS = ['dockerfile_append', 'setup'] as const +// Cached build scripts. Longer scripts and build inputs can also be declared +// in an environment YAML; the pane preserves the selected build inputs. +export const IMAGE_FIELDS = ['build_base', 'after_checkout'] as const export type ImageField = (typeof IMAGE_FIELDS)[number] export type CustomImage = Readonly> -export const EMPTY_IMAGE: CustomImage = { dockerfile_append: '', setup: '' } +export const EMPTY_IMAGE: CustomImage = { build_base: '', after_checkout: '' } // The lifecycle hooks: `post_start` runs after the container starts (before // any repo is cloned), `post_clone` after checkout, before the agent. Per-run @@ -803,6 +805,7 @@ export interface EnvironmentPaneState { // pane ships what it holds, so flattening for display must not reach the wire. image: CustomImage hooks: CustomHooks + rawHooks?: NonNullable>['hooks'] } export const EMPTY_PANE: EnvironmentPaneState = { @@ -849,6 +852,10 @@ export function resolveRepoFullName( // A saved environment's config as the pane's starting state. Every field the // pane can show, resolved to the strings its rows edit; anything the config // leaves unset stays blank, which reads as "whatever the server resolves". +function hookScript(value: string | { run: string } | null | undefined): string { + return typeof value === 'string' ? value : value?.run ?? '' +} + export function environmentPane( config: | { @@ -856,8 +863,7 @@ export function environmentPane( variables?: readonly { name: string; value?: string | null }[] mcp_servers?: readonly unknown[] compute?: { cpu?: number | null; memory?: unknown; timeout?: unknown } | null - image?: { dockerfile_append?: string | null; setup?: string | null } | null - hooks?: { post_start?: string | null; post_clone?: string | null } | null + hooks?: NonNullable>['hooks'] | null } | null | undefined, @@ -886,9 +892,10 @@ export function environmentPane( memory: typeof compute?.memory === 'string' ? compute.memory : '', timeout: typeof compute?.timeout === 'string' ? compute.timeout : '', }, + ...(config.hooks ? { rawHooks: config.hooks } : {}), image: { - dockerfile_append: config.image?.dockerfile_append ?? '', - setup: config.image?.setup ?? '', + build_base: hookScript(config.hooks?.build_base), + after_checkout: hookScript(config.hooks?.after_checkout), }, hooks: { post_start: config.hooks?.post_start ?? '', @@ -918,6 +925,7 @@ function paneKey(p: EnvironmentPaneState): unknown { compute: p.compute, image: p.image, hooks: p.hooks, + rawHooks: p.rawHooks, } } @@ -997,7 +1005,7 @@ export type ComposerEnvironment = | { kind: 'custom'; pane: EnvironmentPaneState } export interface ComposerChoices { - harness?: StartAgentSessionRequest['harness']['type'] + harness?: 'claude_code' | 'codex' environment: ComposerEnvironment model: string | null } @@ -1033,9 +1041,16 @@ export function paneEnvironment(pane: EnvironmentPaneState): Record 0) environment.compute = compute - const image = fieldsOverride(pane.image) - if (Object.keys(image).length > 0) environment.image = image - const hooks = fieldsOverride(pane.hooks) + const hooks = { ...pane.rawHooks, ...fieldsOverride(pane.hooks) } + for (const field of IMAGE_FIELDS) { + const script = pane.image[field].trim() + const previous = pane.rawHooks?.[field] + if (script) hooks[field] = typeof previous === 'object' && previous !== null ? { ...previous, run: script } : script + else delete hooks[field] + } + for (const field of HOOK_FIELDS) { + if (!pane.hooks[field].trim()) delete hooks[field] + } if (Object.keys(hooks).length > 0) environment.hooks = hooks return environment } @@ -1053,17 +1068,18 @@ export function applyComposerChoices( choices: ComposerChoices, ): StartAgentSessionRequest { const req: StartAgentSessionRequest = { ...base } - // Keep native options when the harness is unchanged; a model certified for - // another harness starts with that harness's own options. Instructions stay. - const type = choices.harness ?? req.harness?.type ?? 'claude_code' - if (choices.model) { - req.harness = - req.harness?.type === type - ? { ...req.harness, model: choices.model } - : { type, model: choices.model } - } else if (choices.harness && req.harness?.type !== type) { - if (type === 'codex') throw new Error('select a model for the Codex harness') - req.harness = { type: 'claude_code' } + const type = choices.harness ?? (req.codex ? 'codex' : 'claude_code') + const prompt = sessionPrompt(req) + if (choices.model || choices.harness) { + if (type === 'codex') { + const model = choices.model ?? req.codex?.model + if (!model) throw new Error('select a model for the Codex harness') + req.codex = { ...req.codex, ...(prompt != null ? { prompt } : {}), model } + delete req.claude_code + } else { + req.claude_code = { ...req.claude_code, ...(prompt != null ? { prompt } : {}), ...(choices.model ? { model: choices.model } : {}) } + delete req.codex + } } const env = choices.environment if (env.kind === 'named') req.environment = env.id diff --git a/src/lib/types.ts b/src/lib/types.ts index 1723145..42d897f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -20,9 +20,7 @@ type S = components['schemas'] export type AgentSession = S['Session'] export type AgentSessionSource = S['SessionSource'] -export type AgentSessionStatus = S['SessionStatus'] -export type SessionState = S['SessionState'] -export type SessionSurface = S['SessionSurface'] +export type AgentSessionStatus = S['SessionLifecycleStatus'] export type SessionPrompting = S['SessionPrompting'] // The frames flavor, not `S['SessionRecord']`: the spec marks defaulted fields // optional, but on the wire the server always serializes every field, and the @@ -44,21 +42,21 @@ export type GithubAccountSnippet = S['GithubAccountSnippet'] // ----------------------------- automations --------------------------------- // The automation file: identity + trigger + input + a `session:` block. -export type AutomationConfig = S['AutomationConfig'] +export type AutomationConfig = S['AgentConfig-Input'] // What one session runs on; the flat body of POST /v1/sessions. -export type SessionConfig = S['SessionConfig'] -export type Automation = S['Automation'] -export type ListAutomationsResponse = S['AutomationsListResponse'] -export type CreateAutomationRequest = Parameters[0] -export type CreatedAutomation = S['AutomationResponse'] +export type SessionConfig = S['SessionConfig-Input'] +export type Automation = S['Agent'] +export type ListAutomationsResponse = S['AgentsListResponse'] +export type CreateAutomationRequest = Parameters[0] +export type CreatedAutomation = S['AgentResponse'] export type ConfigManagedBy = S['ConfigManagedBy'] -export type LinkedAutomation = S['LinkAutomationResponse'] +export type LinkedAutomation = S['LinkAgentResponse'] // ------------------------------ environments ------------------------------- // The saved-environment file (identity + body); the inline session block is // S['EnvironmentConfig']. -export type EnvironmentDocument = S['EnvironmentDocument'] +export type EnvironmentDocument = S['EnvironmentDocument-Input'] export type SavedEnvironment = S['Environment'] export type ListEnvironmentsResponse = S['EnvironmentsListResponse'] @@ -183,7 +181,7 @@ export type CliAuthPollStatus = export interface ListAgentSessionsQuery { // An automation id or name: only sessions that automation started. - automation?: string + agent?: string source?: AgentSessionSource[] days?: number start?: string diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index a2c08a6..f67986b 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -1,3 +1,4 @@ +import { sessionPrompt } from '../lib/sessions' import React, { useCallback, useEffect, @@ -557,9 +558,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const pendingPrompt = useMemo(() => { if (items.length > 0) return null if (snapshot.records.some((r) => r.record_type === 'message_received')) return null - const prompt = snapshot.session?.prompt + const prompt = snapshot.session ? sessionPrompt(snapshot.session) : undefined return typeof prompt === 'string' && prompt.trim() ? prompt : null - }, [items.length, snapshot.records, snapshot.session?.prompt]) + }, [items.length, snapshot.records, snapshot.session]) // Whether a send is waiting on the agent — a queued row breathes while it // waits, like a running tool does. @@ -652,7 +653,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ...prev, { key: `note${prev.length}`, - text: `Stopped the agent (${s.status}). The conversation is saved. Send a message to pick it back up.`, + text: `Stopped the agent (${s.lifecycle.status}). The conversation is saved. Send a message to pick it back up.`, }, ]) return diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index a886d51..75122c0 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -6,7 +6,7 @@ import { repoFromCwd } from '../lib/git' import { makeOpenSocket, resolveWsBase } from '../lib/stream' import { applyDetectedThemeMode } from '../lib/terminalBackground' import type { StartAgentSessionRequest } from '../lib/types' -import { withContextRepository } from '../lib/sessions' +import { withContextRepository, withSessionPrompt } from '../lib/sessions' import { SessionsApp } from './SessionsApp' // Launches the multi-session UI (sidebar + chat) — the shared destination of @@ -43,8 +43,8 @@ export function canHostSessionsUi(): boolean { export function defaultStartRequest(prompt: string): StartAgentSessionRequest { // A promptless start opens idle by definition (the server-side contract // since #6394): Claude Code waits at its prompt for the first message. - let req: StartAgentSessionRequest = { harness: { type: 'claude_code' } } - if (prompt) req.prompt = prompt + let req: StartAgentSessionRequest = { claude_code: {} } + if (prompt) req = withSessionPrompt(req, prompt) const contextRepo = repoFromCwd(process.cwd()) if (contextRepo) req = withContextRepository(req, contextRepo) return req diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 25a79a4..0d4b703 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -234,14 +234,15 @@ describe('layOutItems', () => { const res = (key: string): TranscriptItem => ({ key, kind: 'tool_result', text: 'ok' }) const fold = (key: string): TranscriptItem => ({ key: `grp:${key}`, kind: 'notice', text: 'Ran 2' }) - it('lays every row out flat, tool runs included — the SDK contract since 0.27', () => { + it('keeps rows flat and attaches consecutive activity rows', () => { // A run is never indented under the message before it: an indented run // under a message that has scrolled by reads as if the message were the // subject, when the run is the agent's own next step in the turn. const think: TranscriptItem = { key: 'th', kind: 'thinking', text: 'hmm', gutter: '✻' } const out = layOutItems([prose('a'), call('t1'), res('r1'), think, fold('t2'), user('u')]) expect(out.map((p) => p.item.key)).toEqual(['a', 't1', 'r1', 'th', 'grp:t2', 'u']) - expect(out.every((p) => !p.nested && !p.attach)).toBe(true) + expect(out.every((p) => !p.nested)).toBe(true) + expect(out.map((p) => p.attach)).toEqual([false, false, true, true, true, false]) }) }) diff --git a/test/connect.test.ts b/test/connect.test.ts index 15e7d5f..03c2856 100644 --- a/test/connect.test.ts +++ b/test/connect.test.ts @@ -2,33 +2,7 @@ import { describe, expect, it } from 'vitest' import { connectability, resolveConnectSessionId } from '../src/commands/connect' import type { AgentSession } from '../src/lib/types' -function session(overrides: Partial): AgentSession { - return { - id: 'session_1', - created_at: '2026-07-07T00:00:00Z', - updated_at: '2026-07-07T00:00:00Z', - status: 'running', - status_reason: null, - config_id: null, - source: 'api', - harness: 'claude_code', - prompting: { enabled: true }, - resolved_budget_cents: 0, - resolved_budget_source: 'system', - cost_tokens: 0, - cost_sandbox_cpu: 0, - cost_sandbox_memory: 0, - cost_fee: 0, - tokens_total: 0, - tokens_input: 0, - tokens_output: 0, - tokens_cache_read: 0, - tokens_cache_creation: 0, - tokens_model: '', - metadata: {}, - ...overrides, - } -} +import { session } from './fixtures/session' describe('resolveConnectSessionId', () => { it('prefers the positional argument', () => { @@ -50,18 +24,16 @@ describe('resolveConnectSessionId', () => { describe('connectability', () => { it('sends when the server says prompting is enabled', () => { - expect(connectability(session({ prompting: { enabled: true } }))).toEqual({ canSend: true }) + expect(connectability(session({ lifecycle: { prompting: { enabled: true } } }))).toEqual({ canSend: true }) }) it('is watch-only when the server refuses, quoting its reason', () => { const res = connectability( - session({ - prompting: { + session({ lifecycle: { prompting: { enabled: false, blocked_reason: 'non_interactive', detail: 'This agent runs a workflow and takes no messages.', - }, - }), + } } }), ) expect(res.canSend).toBe(false) expect(res.reason).toContain('This agent runs a workflow') @@ -69,7 +41,7 @@ describe('connectability', () => { }) it('falls back to a generic reason when the server sends no detail', () => { - const res = connectability(session({ prompting: { enabled: false } })) + const res = connectability(session({ lifecycle: { prompting: { enabled: false } } })) expect(res.canSend).toBe(false) expect(res.reason).toMatch(/does not accept messages/) }) diff --git a/test/fixtures/session.ts b/test/fixtures/session.ts new file mode 100644 index 0000000..93b2ed1 --- /dev/null +++ b/test/fixtures/session.ts @@ -0,0 +1,36 @@ +import type { AgentSession } from '../../src/lib/types' + +type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T + +export function session(overrides: DeepPartial = {}): AgentSession { + return { + id: 'session_1', + agent: null, + source: 'api', + claude_code: {}, + codex: null, + budget: 0, + 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, + lifecycle: { + status: 'working', + conversation: '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, + }, + }, + } as AgentSession +} diff --git a/test/sdk-030.test.ts b/test/sdk-030.test.ts new file mode 100644 index 0000000..700e82d --- /dev/null +++ b/test/sdk-030.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +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 { api } from '../src/lib/api' +import { environmentPane, paneEnvironment } from '../src/lib/sessions' +import { session } from './fixtures/session' + +let dir: string +let requests: { url: URL; body: Record | undefined }[] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sdk-030-')) + requests = [] + vi.stubEnv('ELLIPSIS_CONFIG_DIR', dir) + vi.stubEnv('ELLIPSIS_API_BASE_URL', 'https://example.test') + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url: new URL(url), body: init?.body ? JSON.parse(String(init.body)) : undefined }) + return new Response(JSON.stringify({ session: session(), agents: [], agent: { id: 'a_1' }, segments: [] })) + })) + process.exitCode = 0 +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) + process.exitCode = 0 +}) + +async function command(args: string[]): Promise { + const program = new Command().exitOverride() + registerSession(program) + registerAutomation(program) + registerEnvironment(program) + await program.parseAsync(['node', 'agent', ...args]) + expect(process.exitCode).toBe(0) +} + +describe('SDK 0.30 request contracts', () => { + it('sends one native harness block after a config override switches harnesses', async () => { + const file = join(dir, 'agent.yaml') + writeFileSync(file, 'session:\n claude_code:\n prompt: fix the tests\n max_turns: 3\n') + await command(['session', 'start', '--config-file', file, '--harness', 'codex', '--model', 'gpt-6-astra', '--json']) + expect(requests[0].body).toMatchObject({ codex: { model: 'gpt-6-astra', prompt: 'fix the tests' } }) + expect(requests[0].body).not.toHaveProperty('claude_code') + expect(requests[0].body).not.toHaveProperty('harness') + expect(requests[0].body?.codex).not.toHaveProperty('max_turns') + }) + + it('applies --model to the harness from the config and --prompt to its native prompt', async () => { + const file = join(dir, 'agent.yaml') + writeFileSync(file, 'session:\n codex:\n model: gpt-6-astra\n prompt: original\n effort: high\n') + await command(['session', 'start', '--config-file', file, '--model', 'gpt-5.6-terra', '--prompt', 'new prompt', '--json']) + expect(requests[0].body?.codex).toEqual({ model: 'gpt-5.6-terra', prompt: 'new prompt', effort: 'high' }) + expect(requests[0].body).not.toHaveProperty('claude_code') + }) + + it('uses agents for automation commands and the session filter', async () => { + await command(['automation', 'list', '--json']) + expect(requests[0].url.pathname).toBe('/v1/agents') + await command(['session', 'list', '--automation', 'a_1', '--json']) + expect(requests[1].url.searchParams.get('agent')).toBe('a_1') + expect(requests[1].url.searchParams.has('automation')).toBe(false) + }) + + it('scaffolds and creates a native agent definition', async () => { + const file = join(dir, 'agent.yaml') + await command(['automation', 'init', file]) + const config = parse(readFileSync(file, 'utf8')) + expect(config.ellipsis.kind).toBe('agent') + expect(config.session.claude_code.prompt).toContain('Describe the task') + expect(config.session).not.toHaveProperty('harness') + await command(['automation', 'create', '--file', file, '--json']) + expect(requests[0].url.pathname).toBe('/v1/agents') + expect(requests[0].body).toEqual({ agent: config }) + }) + + it('downloads the archive through the renamed endpoint', async () => { + await command(['session', 'export', 's_1', '--json']) + expect(requests[0].url.pathname).toBe('/v1/sessions/s_1/download') + }) + + it('scaffolds supported environment hooks', async () => { + const file = join(dir, 'environment.yaml') + await command(['environment', 'init', file]) + const config = parse(readFileSync(file, 'utf8')) + expect(config.hooks.build_base).toContain('install CLIs') + expect(config).not.toHaveProperty('image') + }) + + it('preserves build inputs and before_start when editing environment build scripts', () => { + const pane = environmentPane({ hooks: { build_base: { inputs: ['api/package.json'], run: 'npm ci' }, before_start: { run: 'echo ready' } } }) + const env = paneEnvironment({ ...pane, image: { ...pane.image, build_base: 'npm install' } }) + expect(env.hooks).toEqual({ build_base: { inputs: ['api/package.json'], run: 'npm install' }, before_start: { run: 'echo ready' } }) + expect(env).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) + }) +}) diff --git a/test/sdk-upgrade.test.ts b/test/sdk-upgrade.test.ts index 758c167..55f5461 100644 --- a/test/sdk-upgrade.test.ts +++ b/test/sdk-upgrade.test.ts @@ -206,60 +206,31 @@ describe('budgetLines', () => { }) describe('explicit harness requests', () => { - it('preserves instructions and Codex options from an automation file', () => { - const instructions = ['Keep changes small.', { file: 'AGENTS.md' }] - expect( - startRequestFromConfig({ - ellipsis: { name: 'Code helper' }, - session: { - harness: { type: 'codex', model: 'gpt-6-astra', effort: 'high' }, - instructions, - budget: { session: 2 }, - }, - }), - ).toEqual({ - harness: { type: 'codex', model: 'gpt-6-astra', effort: 'high' }, - instructions, - budget: 2, - }) + it('preserves the native prompt and Codex options from an automation file', () => { + expect(startRequestFromConfig({ + ellipsis: { name: 'Code helper' }, + session: { codex: { model: 'gpt-6-astra', prompt: 'Keep changes small.', effort: 'high' }, budget: { session: 2 } }, + })).toEqual({ codex: { model: 'gpt-6-astra', prompt: 'Keep changes small.', effort: 'high' }, budget: 2 }) }) - it('rejects legacy configs and overrides instead of dropping their instructions', () => { - expect(() => startRequestFromConfig({ claude: { system: 'Do this.' } })).toThrow( - /move system to instructions/, - ) - expect(() => buildStartOverride({ override: 'claude:\n system: Do this.' })).toThrow( - /move system to instructions/, - ) - expect(() => startRequestFromConfig({ instructions: 'Do this.' })).toThrow( - /harness.type/, - ) + it('rejects retired configs and overrides instead of dropping instructions', () => { + for (const config of [{ claude: { system: 'Do this.' } }, { harness: { type: 'claude_code' } }, { instructions: 'Do this.' }]) { + expect(() => startRequestFromConfig(config)).toThrow(/no longer supported/) + expect(() => buildStartOverride({ override: JSON.stringify(config) })).toThrow(/no longer supported/) + } + expect(() => startRequestFromConfig({})).toThrow(/exactly one/) + expect(() => startRequestFromConfig({ claude_code: {}, codex: {} })).toThrow(/exactly one/) + expect(() => buildStartOverride({ system: 'Be brief.' })).toThrow(/--system is no longer supported/) }) - it('switches native options cleanly while keeping shared instructions', () => { - const base = { - harness: { - type: 'claude_code' as const, - model: 'claude-opus-5', - max_turns: 5, - effort: 'max' as const, - }, - instructions: 'Keep changes small.', - } - const unchanged = applyComposerChoices(base, { - environment: { kind: 'empty' }, - model: 'claude-fable-5', - harness: 'claude_code', - }) - expect(unchanged.harness).toEqual({ ...base.harness, model: 'claude-fable-5' }) - const changed = applyComposerChoices(base, { - environment: { kind: 'empty' }, - model: 'gpt-6-astra', - harness: 'codex', - }) - expect(changed.harness).toEqual({ type: 'codex', model: 'gpt-6-astra' }) - expect(changed.instructions).toBe(base.instructions) - expect(base.harness.max_turns).toBe(5) + it('switches native options cleanly while keeping the prompt', () => { + const base = { claude_code: { model: 'claude-opus-5', max_turns: 5, effort: 'max' as const, prompt: 'Keep changes small.' } } + const unchanged = applyComposerChoices(base, { environment: { kind: 'empty' }, model: 'claude-fable-5', harness: 'claude_code' }) + expect(unchanged.claude_code).toEqual({ ...base.claude_code, model: 'claude-fable-5' }) + const changed = applyComposerChoices(base, { environment: { kind: 'empty' }, model: 'gpt-6-astra', harness: 'codex' }) + expect(changed.codex).toEqual({ model: 'gpt-6-astra', prompt: 'Keep changes small.' }) + expect(changed.claude_code).toBeUndefined() + expect(base.claude_code.max_turns).toBe(5) }) it('keeps the certified harness and concrete model on a Codex default row', () => { @@ -283,25 +254,10 @@ describe('explicit harness requests', () => { expect(composerModelChoice(row)).toEqual({ harness: 'codex', model: 'gpt-6-astra' }) }) - it('validates the harness flag and maps shared instructions separately', () => { + it('validates the harness flag and replaces options on a switch', () => { expect(toHarness('codex')).toBe('codex') expect(() => toHarness('claude')).toThrow(/claude_code, codex/) - expect( - buildStartOverride({ harness: 'codex', model: 'gpt-6-astra', system: 'Be brief.' }), - ).toEqual({ - harness: { type: 'codex', model: 'gpt-6-astra' }, - instructions: 'Be brief.', - }) - expect( - buildStartOverride({ - override: - 'harness:\n type: claude_code\n max_turns: 3\ninstructions: Keep this.', - harness: 'codex', - model: 'gpt-6-astra', - }), - ).toEqual({ - harness: { type: 'codex', model: 'gpt-6-astra' }, - instructions: 'Keep this.', - }) + expect(buildStartOverride({ harness: 'codex', model: 'gpt-6-astra' })).toEqual({ codex: { model: 'gpt-6-astra' } }) + expect(buildStartOverride({ override: 'claude_code:\n max_turns: 3', harness: 'codex', model: 'gpt-6-astra' })).toEqual({ codex: { model: 'gpt-6-astra' } }) }) }) diff --git a/test/session.test.ts b/test/session.test.ts index 3f1ed41..7239d43 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -12,31 +12,10 @@ import { import type { Ellipsis } from '@ellipsis-dev/sdk' import type { AgentSession, AgentSessionStatus, SessionLogSegment } from '../src/lib/types' +import { session as makeSession } from './fixtures/session' + function session(status: AgentSessionStatus): AgentSession { - return { - id: 'session_1', - created_at: '2026-06-25T00:00:00+00:00', - updated_at: '2026-06-25T00:00:00+00:00', - status: status, - status_reason: null, - config_id: null, - source: 'api', - harness: 'claude_code', - prompting: { enabled: true }, - resolved_budget_cents: 0, - resolved_budget_source: 'system', - cost_tokens: 0, - cost_sandbox_cpu: 0, - cost_sandbox_memory: 0, - cost_fee: 0, - tokens_total: 0, - tokens_input: 0, - tokens_output: 0, - tokens_cache_read: 0, - tokens_cache_creation: 0, - tokens_model: '', - metadata: {}, - } + return makeSession({ lifecycle: { status } }) } describe('watchSession', () => { @@ -52,9 +31,9 @@ describe('watchSession', () => { it('polls until a terminal status, then stops', async () => { const get = vi .fn() - .mockResolvedValueOnce({ session: session('running') }) - .mockResolvedValueOnce({ session: session('running') }) - .mockResolvedValueOnce({ session: session('completed') }) + .mockResolvedValueOnce({ session: session('working') }) + .mockResolvedValueOnce({ session: session('working') }) + .mockResolvedValueOnce({ session: session('closed') }) const client = { sessions: { get } } as unknown as Ellipsis const promise = watchSession(client, 'session_1', 1, true) @@ -67,7 +46,7 @@ describe('watchSession', () => { }) it('returns immediately when the session is already terminal', async () => { - const get = vi.fn().mockResolvedValueOnce({ session: session('error') }) + 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 @@ -85,7 +64,7 @@ describe('watchSession', () => { it('sets a failure exit code on a non-completed terminal status (for --wait)', async () => { process.exitCode = 0 - const get = vi.fn().mockResolvedValueOnce({ session: session('error') }) + const get = vi.fn().mockResolvedValueOnce({ session: session('failed') }) const client = { sessions: { get } } as unknown as Ellipsis await watchSession(client, 'session_1', 5, true) expect(process.exitCode).toBe(1) @@ -94,7 +73,7 @@ describe('watchSession', () => { it('leaves the exit code clean on a completed status', async () => { process.exitCode = 0 - const get = vi.fn().mockResolvedValueOnce({ session: session('completed') }) + const get = vi.fn().mockResolvedValueOnce({ session: session('closed') }) const client = { sessions: { get } } as unknown as Ellipsis await watchSession(client, 'session_1', 5, true) expect(process.exitCode).toBe(0) @@ -161,7 +140,6 @@ describe('buildStartOverride', () => { expect( buildStartOverride({ model: 'claude-opus-4-8', - system: 'do the thing', repo: ['ellipsis-dev/ellipsis', 'solo'], cpu: 2, memory: '8GB', @@ -169,8 +147,7 @@ describe('buildStartOverride', () => { budget: 0.5, }), ).toEqual({ - harness: { model: 'claude-opus-4-8' }, - instructions: 'do the thing', + claude_code: { model: 'claude-opus-4-8' }, environment: { compute: { cpu: 2, memory: '8GB', timeout: '30m' }, }, @@ -188,12 +165,11 @@ describe('buildStartOverride', () => { it('deep-merges sugar flags on top of a raw inline override (flags win)', () => { expect( buildStartOverride({ - override: 'harness:\n type: claude_code\n model: claude-haiku-4-5-20251001\n effort: high\ninstructions: base', + override: 'claude_code:\n model: claude-haiku-4-5-20251001\n effort: high\n prompt: base', model: 'claude-opus-4-8', }), ).toEqual({ - harness: { type: 'claude_code', model: 'claude-opus-4-8', effort: 'high' }, - instructions: 'base', + claude_code: { model: 'claude-opus-4-8', effort: 'high', prompt: 'base' }, }) }) @@ -266,7 +242,7 @@ describe('session start prompt positional', () => { // POSTs is the only place the CLI's own assembly is observable. let seen: string | undefined const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { - seen = JSON.parse(init?.body as string).prompt + seen = JSON.parse(init?.body as string).claude_code?.prompt return new Response(JSON.stringify({ session: session('scheduled') }), { status: 201 }) }) vi.stubGlobal('fetch', fetchMock) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index e99a97e..d7637ac 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -55,35 +55,13 @@ import { mergeSidebarSessions, } from '../src/lib/sessions' import { theme } from '../src/lib/theme' -import type { AutomationConfig, AgentSession, SessionConfig, SupportedModel } from '../src/lib/types' +import type { SupportedModel } from '../src/lib/types' -// The session fixtures only read the automation's name, so the rest is a stub. -const BARE_CONFIG = { ellipsis: { name: null } } as unknown as AutomationConfig -const BARE_SESSION_CONFIG = {} as unknown as SessionConfig - -function session(overrides: Partial): AgentSession { - return { - id: 'session_1', - created_at: '2026-07-07T00:00:00Z', - updated_at: '2026-07-07T00:00:00Z', - status: 'running', - status_reason: null, - config: BARE_SESSION_CONFIG, - automation: { id: null, config: BARE_CONFIG }, - 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 { session } from './fixtures/session' describe('connectability', () => { it('sends when the server says prompting is enabled', () => { - expect(connectability(session({ prompting: { enabled: true } }))).toEqual({ canSend: true }) + expect(connectability(session({ lifecycle: { prompting: { enabled: true } } }))).toEqual({ canSend: true }) }) it('honors the server prompting projection', () => { @@ -91,16 +69,12 @@ describe('connectability', () => { // the Slack thread, so the server refuses direct messages and we open // watch-only instead of a composer whose first Enter would 409. const c = connectability( - session({ - session_key: 'slack:D1:1.1', - session_state: 'idle', - prompting: { + session({ lifecycle: { prompting: { enabled: false, blocked_reason: 'mention_surface', detail: 'This conversation lives on Slack. Reply there to steer the agent.', surface_name: 'Slack', - }, - }), + } } }), ) expect(c.canSend).toBe(false) // The server's own sentence is shown verbatim, so the reason names Slack. @@ -110,16 +84,13 @@ describe('connectability', () => { }) describe('rowStatusWord / rowGlyph', () => { - it('prefers the surface projection over the raw status', () => { - const s = session({ - status: 'running', - surface: { session: 'alive', run: 'working', status: 'waiting' }, - }) + it('reads the canonical lifecycle status', () => { + const s = session({ lifecycle: { status: 'waiting' } }) expect(rowStatusWord(s)).toBe('waiting') }) - it('falls back to the raw status without a surface', () => { - expect(rowStatusWord(session({ status: 'completed' }))).toBe('completed') + it('reads a closed lifecycle', () => { + expect(rowStatusWord(session({ lifecycle: { status: 'closed' } }))).toBe('closed') }) it('is always a dot — status is told by color, the arrow means selection', () => { @@ -146,23 +117,20 @@ describe('rowStatusWord / rowGlyph', () => { describe('rowDescription', () => { it('prefers the live summary, collapsed to one line', () => { - const s = session({ - summary: { description: 'fixing the\n webhook tests', created_at: null }, - prompt: 'do a thing', - }) + const s = session({ summary: { description: 'fixing the\n webhook tests', created_at: null }, claude_code: { prompt: 'do a thing' } }) expect(rowDescription(s)).toBe('fixing the webhook tests') }) it('falls back to the prompt, then the source', () => { - expect(rowDescription(session({ prompt: 'fix the tests' }))).toBe('fix the tests') + expect(rowDescription(session({ claude_code: { prompt: 'fix the tests' } }))).toBe('fix the tests') expect(rowDescription(session({ source: 'react' }))).toBe('react session') // Every session carries a source, so that is the floor. - expect(rowDescription(session({}))).toBe('api session') + expect(rowDescription(session({ }))).toBe('api session') }) it('ignores whitespace-only summaries', () => { expect( - rowDescription(session({ summary: { description: ' \n ', created_at: null }, prompt: 'p' })), + rowDescription(session({ summary: { description: ' \n ', created_at: null }, claude_code: { prompt: 'p' } })), ).toBe('p') }) }) @@ -171,11 +139,11 @@ describe('lastEventAt / shortAge', () => { it('prefers last_activity_at, then last_message_at, then updated_at', () => { expect( lastEventAt( - session({ last_activity_at: 'A', last_message_at: 'B', updated_at: 'C' } as never), + session({ lifecycle: { timestamps: { last_activity_at: 'A', last_message_at: 'B', updated_at: 'C' } } } as never), ), ).toBe('A') - expect(lastEventAt(session({ last_message_at: 'B', updated_at: 'C' } as never))).toBe('B') - expect(lastEventAt(session({ updated_at: 'C' }))).toBe('C') + expect(lastEventAt(session({ lifecycle: { timestamps: { last_message_at: 'B', updated_at: 'C' } } } as never))).toBe('B') + expect(lastEventAt(session({ lifecycle: { timestamps: { updated_at: 'C' } } }))).toBe('C') }) it('renders compact ages and never goes negative', () => { @@ -192,28 +160,20 @@ describe('rowMeta', () => { const now = new Date('2026-07-23T12:00:00Z') it('reads spend and age, never the token count', () => { - const s = session({ - tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 84_200, model: '' }, - cost: { llm: 30_000, sandbox_cpu: 10_000, sandbox_memory: 2_000, fee: 0, total: 42_000 }, - updated_at: '2026-07-23T11:58:00Z', - } as never) + const s = session({ tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0, total: 84_200, model: '' }, cost: { llm: 30_000, sandbox_cpu: 10_000, sandbox_memory: 2_000, fee: 0, total: 42_000 }, lifecycle: { timestamps: { updated_at: '2026-07-23T11:58:00Z' } } } as never) expect(rowMeta(s, now)).toBe('$0.42, 2m ago') }) it('drops the spend a fresh session has none of', () => { - const s = session({ updated_at: '2026-07-23T11:59:48Z' }) + const s = session({ lifecycle: { timestamps: { updated_at: '2026-07-23T11:59:48Z' } } }) expect(rowMeta(s, now)).toBe('12s ago') }) }) describe('filterSessions', () => { - const budget = session({ - id: 'budget', - summary: { description: 'Checking the monthly budget' }, - prompt: 'can you check my budget', - }) - const greet = session({ id: 'greet', summary: null, prompt: 'Hey!' }) - const bare = session({ id: 'bare', summary: null, prompt: null }) + const budget = session({ id: 'budget', summary: { description: 'Checking the monthly budget' }, claude_code: { prompt: 'can you check my budget' } }) + const greet = session({ id: 'greet', summary: null, claude_code: { prompt: 'Hey!' } }) + const bare = session({ id: 'bare', summary: null, claude_code: { prompt: null } }) it('matches all on an empty or blank query', () => { expect(filterSessions([budget, greet], '')).toEqual([budget, greet]) @@ -290,23 +250,11 @@ describe('sessionBarQuery', () => { describe('statusBand / sortSidebarSessions', () => { // A row per band, deliberately born newest-first-is-wrong-order so a // recency sort can't accidentally pass. - const waiting = session({ - id: 'waiting', - created_at: '2026-07-20T00:00:00Z', - surface: { session: 'alive', run: 'waiting', status: 'waiting' }, - }) - const working = session({ - id: 'working', - created_at: '2026-07-21T00:00:00Z', - surface: { session: 'alive', run: 'working', status: 'working' }, - }) - const sleeping = session({ - id: 'sleeping', - created_at: '2026-07-22T00:00:00Z', - surface: { session: 'sleeping', run: 'done', status: 'sleeping' }, - }) - const done = session({ id: 'done', created_at: '2026-07-23T00:00:00Z', status: 'completed' }) - const failed = session({ id: 'failed', created_at: '2026-07-24T00:00:00Z', status: 'error' }) + const waiting = session({ id: 'waiting', lifecycle: { status: 'waiting', timestamps: { created_at: '2026-07-20T00:00:00Z' } } }) + const working = session({ id: 'working', lifecycle: { status: 'working', timestamps: { created_at: '2026-07-21T00:00:00Z' } } }) + const sleeping = session({ id: 'sleeping', lifecycle: { status: 'idle', timestamps: { created_at: '2026-07-22T00:00:00Z' } } }) + const done = session({ id: 'done', lifecycle: { status: 'closed', timestamps: { created_at: '2026-07-23T00:00:00Z' } } }) + const failed = session({ id: 'failed', lifecycle: { status: 'failed', timestamps: { created_at: '2026-07-24T00:00:00Z' } } }) it('bands by status: live, parked, done, dead', () => { expect(sortSidebarSessions([failed, done, sleeping, waiting, working]).map((s) => s.id)).toEqual( @@ -322,18 +270,18 @@ describe('statusBand / sortSidebarSessions', () => { expect(statusBand('waiting')).toBe(statusBand('starting')) const mid = sortSidebarSessions([waiting, working, sleeping]).map((s) => s.id) const after = sortSidebarSessions([ - { ...waiting, surface: { session: 'alive', run: 'working', status: 'working' } }, - { ...working, surface: { session: 'alive', run: 'waiting', status: 'waiting' } }, + session({ ...waiting, lifecycle: { ...waiting.lifecycle, status: 'working' } }), + session({ ...working, lifecycle: { ...working.lifecycle, status: 'waiting' } }), sleeping, ] as AgentSession[]).map((s) => s.id) expect(after).toEqual(mid) }) it('orders within a band newest-born first, ignoring event recency', () => { - const old = session({ id: 'old', created_at: '2026-07-20T00:00:00Z', status: 'running' }) - const fresh = session({ id: 'fresh', created_at: '2026-07-22T00:00:00Z', status: 'running' }) + const old = session({ id: 'old', lifecycle: { status: 'working', timestamps: { created_at: '2026-07-20T00:00:00Z' } } }) + const fresh = session({ id: 'fresh', lifecycle: { status: 'working', timestamps: { created_at: '2026-07-22T00:00:00Z' } } }) // `old` just spoke; that must not lift it above the younger session. - const chatty = { ...old, last_activity_at: '2026-07-23T00:00:00Z' } as AgentSession + const chatty = session({ ...old, lifecycle: { ...old.lifecycle, timestamps: { ...old.lifecycle.timestamps, last_activity_at: '2026-07-23T00:00:00Z' } } }) expect(sortSidebarSessions([chatty, fresh]).map((s) => s.id)).toEqual(['fresh', 'old']) expect(sortSidebarSessions([fresh, chatty]).map((s) => s.id)).toEqual(['fresh', 'old']) }) @@ -341,17 +289,17 @@ describe('statusBand / sortSidebarSessions', () => { describe('mergeSidebarSessions', () => { it('keeps local sessions the poll has not returned yet', () => { - const polled = session({ id: 'a', created_at: '2026-07-23T10:00:00Z' }) - const local = session({ id: 'b', created_at: '2026-07-23T11:00:00Z' }) + const polled = session({ id: 'a', lifecycle: { timestamps: { created_at: '2026-07-23T10:00:00Z' } } }) + const local = session({ id: 'b', lifecycle: { timestamps: { created_at: '2026-07-23T11:00:00Z' } } }) expect(mergeSidebarSessions([polled], [local]).map((s) => s.id)).toEqual(['b', 'a']) }) it('emits one row when a session is in both lists, preferring the polled copy', () => { - const polled = session({ id: 'a', status: 'completed', session_state: 'closed' }) - const local = session({ id: 'a', status: 'running' }) + const polled = session({ id: 'a', lifecycle: { status: 'closed' } }) + const local = session({ id: 'a', lifecycle: { status: 'working' } }) const merged = mergeSidebarSessions([polled], [local]) expect(merged.map((s) => s.id)).toEqual(['a']) - expect(merged[0]?.status).toBe('completed') + expect(merged[0]?.lifecycle.status).toBe('closed') }) }) @@ -582,7 +530,7 @@ describe('applyComposerChoices', () => { expect(req).toEqual({ repositories: ['acme/api'], environment: {}, - harness: { type: 'claude_code', model: 'claude-opus-5' }, + claude_code: { model: 'claude-opus-5' }, }) }) @@ -590,21 +538,20 @@ describe('applyComposerChoices', () => { // context repo rides along as the additive key. it('names a chosen environment on the request, keeping the context repo', () => { const req = applyComposerChoices( - { prompt: 'ship it', repositories: ['acme/api'] }, + { claude_code: { prompt: 'ship it' }, repositories: ['acme/api'] }, { ...untouched, environment: { kind: 'named', id: 'env_1' } }, ) - expect(req).toEqual({ prompt: 'ship it', repositories: ['acme/api'], environment: 'env_1' }) + expect(req).toEqual({ claude_code: { prompt: 'ship it' }, repositories: ['acme/api'], environment: 'env_1' }) }) it('carries an environment and a model through together', () => { const req = applyComposerChoices( - { prompt: 'ship it' }, + { claude_code: { prompt: 'ship it' } }, { environment: { kind: 'named', id: 'env_1' }, model: 'claude-fable-5' }, ) expect(req).toEqual({ - prompt: 'ship it', + claude_code: { prompt: 'ship it', model: 'claude-fable-5' }, environment: 'env_1', - harness: { type: 'claude_code', model: 'claude-fable-5' }, }) }) @@ -618,9 +565,9 @@ describe('applyComposerChoices', () => { }) it('does not mutate the request it was given', () => { - const base = { prompt: 'hi', repositories: ['acme/api'] } + const base = { claude_code: { prompt: 'hi' }, repositories: ['acme/api'] } applyComposerChoices(base, { ...untouched, environment: { kind: 'custom', pane: EMPTY_PANE } }) - expect(base).toEqual({ prompt: 'hi', repositories: ['acme/api'] }) + expect(base).toEqual({ claude_code: { prompt: 'hi' }, repositories: ['acme/api'] }) }) // The whole point of the pane: once custom it IS the sandbox, shipped as the @@ -679,8 +626,8 @@ describe('paneEnvironment', () => { it('sends only the set image fields', () => { expect( - paneEnvironment({ ...EMPTY_PANE, image: { dockerfile_append: '', setup: 'npm install' } }), - ).toMatchObject({ image: { setup: 'npm install' } }) + paneEnvironment({ ...EMPTY_PANE, image: { build_base: '', after_checkout: 'npm install' } }), + ).toMatchObject({ hooks: { after_checkout: 'npm install' } }) }) it('sends only the set hook fields', () => { @@ -712,8 +659,7 @@ describe('environmentPane', () => { variables: [{ name: 'A', value: '1' }, { name: 'B' }], mcp_servers: ['linear', { name: 'docs', url: 'https://x' }], compute: { cpu: 4, memory: '16GB' }, - image: { setup: 'npm ci' }, - hooks: { post_clone: 'make' }, + hooks: { after_checkout: 'npm ci', before_start: 'make' }, }), ).toEqual({ repositories: [ @@ -729,8 +675,9 @@ describe('environmentPane', () => { { name: 'docs', command: null, url: 'https://x', raw: { name: 'docs', url: 'https://x' } }, ], compute: { cpu: '4', memory: '16GB', timeout: '' }, - image: { dockerfile_append: '', setup: 'npm ci' }, - hooks: { post_start: '', post_clone: 'make' }, + image: { build_base: '', after_checkout: 'npm ci' }, + hooks: { post_start: '', post_clone: '' }, + rawHooks: { after_checkout: 'npm ci', before_start: 'make' }, }) }) @@ -742,7 +689,7 @@ describe('environmentPane', () => { // A script keeps its newlines here: the pane ships what it holds, and only its // row flattens (oneLine). it('keeps a multi-line script whole', () => { - expect(environmentPane({ image: { setup: 'a\nb' } }).image.setup).toBe('a\nb') + expect(environmentPane({ hooks: { after_checkout: 'a\nb' } }).image.after_checkout).toBe('a\nb') }) }) @@ -891,8 +838,8 @@ describe('computeOverride', () => { describe('fieldsOverride', () => { it('keeps only the set fields, trimmed', () => { - expect(fieldsOverride({ dockerfile_append: '', setup: ' npm install ' })).toEqual({ - setup: 'npm install', + expect(fieldsOverride({ build_base: '', after_checkout: ' npm install ' })).toEqual({ + after_checkout: 'npm install', }) expect(fieldsOverride(EMPTY_IMAGE)).toEqual({}) }) @@ -945,8 +892,8 @@ describe('environmentSectionRows', () => { { kind: 'addVariable', hover: 3 }, ]) expect(environmentSectionRows(input, 'image')).toEqual([ - { kind: 'image', field: 'dockerfile_append', hover: 0 }, - { kind: 'image', field: 'setup', hover: 1 }, + { kind: 'image', field: 'build_base', hover: 0 }, + { kind: 'image', field: 'after_checkout', hover: 1 }, ]) expect(environmentSectionRows(input, 'hooks')).toEqual([ { kind: 'hook', field: 'post_start', hover: 0 }, @@ -1047,7 +994,7 @@ describe('environmentSectionAt', () => { expect(environmentSectionAt(input, 'variables', 1)).toEqual({ kind: 'addVariable' }) expect(environmentSectionAt(input, 'image', 0)).toEqual({ kind: 'image', - field: 'dockerfile_append', + field: 'build_base', }) }) @@ -1202,15 +1149,13 @@ describe('start request shaping', () => { trigger: { type: 'cron', schedule: '* * * * *' }, input: { json_schema: {} }, session: { - harness: { type: 'claude_code', model: 'claude-opus-5' }, - instructions: 'do it', + claude_code: { model: 'claude-opus-5', prompt: 'do it' }, environment: { repositories: [{ name: 'api' }] }, budget: { session: 5 }, }, }), ).toEqual({ - harness: { type: 'claude_code', model: 'claude-opus-5' }, - instructions: 'do it', + claude_code: { model: 'claude-opus-5', prompt: 'do it' }, environment: { repositories: [{ name: 'api' }] }, budget: 5, }) @@ -1219,11 +1164,11 @@ describe('start request shaping', () => { it('accepts a bare session config too', () => { expect( startRequestFromConfig({ - harness: { type: 'claude_code' }, instructions: 'do it', + claude_code: { prompt: 'do it' }, budget: { session: 2 }, trigger: { type: 'cron', schedule: '* * * * *' }, }), - ).toEqual({ harness: { type: 'claude_code' }, instructions: 'do it', budget: 2 }) + ).toEqual({ claude_code: { prompt: 'do it' }, budget: 2 }) }) // The context repo rides the request's additive `repositories` key and