diff --git a/agents/update_docs.yaml b/agents/update_docs.yaml index 660171f..ddb868c 100644 --- a/agents/update_docs.yaml +++ b/agents/update_docs.yaml @@ -10,10 +10,11 @@ trigger: repositories: [cli] session: - claude: + harness: + type: claude_code model: claude-opus-4-8 - system: | - You are an Ellipsis agent. You are tasked with updating the Ellipsis documentation (www.ellipsis.dev/docs) when new CLI changes are made. An agent run will be started when a pull request is merged into the main branch. Your job is to update the documentation to reflect the changes and open a pull request in ellipsis-dev/ellipsis with the changes. Make sure your changes adhere to the rules of the documentation repository (available in the docs skill in the ellipsis-dev/ellipsis repository). + instructions: | + You are an Ellipsis agent. You are tasked with updating the Ellipsis documentation (www.ellipsis.dev/docs) when new CLI changes are made. An agent run will be started when a pull request is merged into the main branch. Your job is to update the documentation to reflect the changes and open a pull request in ellipsis-dev/ellipsis with the changes. Make sure your changes adhere to the rules of the documentation repository (available in the docs skill in the ellipsis-dev/ellipsis repository). environment: repositories: - name: ellipsis diff --git a/bun.lock b/bun.lock index dad5add..9d3c464 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.27.0", + "@ellipsis-dev/sdk": "^0.28.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.27.0", "", {}, "sha512-6E7BUmePFQ98djwhw33nfLIcInumcjAknxY3uMbi7AUinlF3Z2f+yeAKyZStwffAB3QTWWJLipBJNqWpX99Dmg=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.28.0", "", {}, "sha512-xkeXgI0cjUN79JRhKv+NH1IEUfHSOWhcEYPQfrRSVq0Jtd5FRvi/jHGOV2cvbXgJk5VAUyB6X77ZNqo/xmf7vg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 905e577..c1c7004 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.27.0", + "@ellipsis-dev/sdk": "^0.28.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 413e50f..3533747 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -106,13 +106,14 @@ trigger: schedule: "0 9 * * 1" session: - claude: + harness: + type: claude_code model: claude-haiku-4-5-20251001 - system: | - 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. + 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. environment: repositories: @@ -194,13 +195,14 @@ trigger: paths: ["migrations/**"] session: - claude: - system: | - 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. + 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. environment: repositories: @@ -304,21 +306,23 @@ pull_requests: review: - name: migration-safety - claude: - system: | - Review database migrations for production safety. Check for - 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. + harness: + type: claude_code + instructions: | + Review database migrations for production safety. Check for + 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. pull_requests: paths: ["**/migrations/**"] filter: name: strict-gate - claude: - system: | - Approve only findings a staff engineer would raise in review. - Reject style opinions and anything a linter catches. + harness: + type: claude_code + instructions: | + Approve only findings a staff engineer would raise in review. + Reject style opinions and anything a linter catches. budget: run: 15.00 @@ -450,7 +454,7 @@ agent automation delete # delete it; it stops and frees its agent automation link --repo api # move it into a repo, via a pull request agent automation unlink # take it over from its file agent template list # built-in templates and their slugs -agent model list # the model ids valid under claude.model +agent model list # model ids and their supported harnesses ``` An agent is owned by one of two writers, and that is what these verbs move. @@ -492,8 +496,8 @@ Under `session`: | Key | Purpose | | --- | --- | -| `claude` | `system`, `model`, `effort`, `fallback_model`, `max_turns`, `settings`. | -| `codex` | Run on OpenAI's Codex CLI instead. Declaring the block selects the harness. | +| `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`. | | `permissions` | What it may do: `github` scopes its GitHub token, `ellipsis` its API token. | | `skills` | Claude Code skills beyond what the cloned repositories provide. | @@ -503,11 +507,12 @@ 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.claude.system` takes inline text, a `{file: path}` reference to a repository +- `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 - Claude Code's default prompt. 64 KiB per file. -- `session.claude.model` defaults to `claude-opus-5`. Claude, GPT, and GLM models are - available, and `agent model list` is the authoritative list of ids. Digest and + the selected harness's default prompt. 64 KiB per file. +- `session.harness.model` selects a model for the chosen harness. 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 frontier model. - `session.budget.session` defaults to $250, which is also the platform maximum, so it diff --git a/src/commands/automation.ts b/src/commands/automation.ts index 34c1c28..6bf1989 100644 --- a/src/commands/automation.ts +++ b/src/commands/automation.ts @@ -75,7 +75,7 @@ export function registerAutomation(program: Command): void { // goes to stderr so the YAML on stdout stays clean for piping. const [{ automation: a }, me] = await Promise.all([ client.automations.get(automationId), - client.me(), + client.identity(), ]) printYaml(a) console.error(`\nview: ${automationUrl(resolveAppBase(), me.customer_login, automationId)}`) @@ -176,7 +176,7 @@ export function registerAutomation(program: Command): void { printJson(session) return } - const me = await client.me() + const me = await client.identity() console.log(`✓ started ${session.id}`) console.log(` ${sessionUrl(resolveAppBase(), me.customer_login, session.id)}`) console.log(` follow with: agent session get ${session.id} --watch`) @@ -445,8 +445,8 @@ function automationName(a: Automation): string { return a.config.ellipsis.name ?? a.id } -// A minimal valid automation. `session.claude.system` is the only required -// field; everything else has a server-side default. Roots Ellipsis syncs from: +// A minimal automation with an explicit harness and task instructions. +// Roots Ellipsis syncs from: // agents/, .agents/, ellipsis/, .ellipsis/ (any depth), as .yaml/.yml. function starterConfig(name: string): string { return `# Ellipsis automation. Commit this to your default branch; Ellipsis syncs it @@ -463,11 +463,11 @@ ellipsis: # What each session runs on. session: - claude: - # System prompt defining the agent's behavior (required). - system: | - You are an Ellipsis agent. Describe the task you want it to perform here. + harness: + type: 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. # environment: backend # a saved environment, by name ` } diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 460820a..682f3f2 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -98,7 +98,7 @@ export async function runConnect( const [{ session }, me] = await Promise.all([ client.sessions.get(sessionId), - client.me(), + client.identity(), // Pick the palette for this terminal's background before the first frame. applyDetectedThemeMode(), ]) diff --git a/src/commands/me.ts b/src/commands/me.ts index 52be8c2..0ee677c 100644 --- a/src/commands/me.ts +++ b/src/commands/me.ts @@ -18,7 +18,7 @@ export function renderMe(me: WhoAmI): void { export function registerMe(program: Command): void { apiRoutes( program.command('me').description('Show the identity behind the current credential'), - 'GET /v1/me', + 'GET /v1/identity', ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { @@ -27,7 +27,7 @@ export function registerMe(program: Command): void { // without this the request would go out unauthenticated and come back // as a 401. requireToken() - const me = await api().me() + const me = await api().identity() if (opts.json) { printJson(me) return diff --git a/src/commands/model.ts b/src/commands/model.ts index afcaad1..48fa3bd 100644 --- a/src/commands/model.ts +++ b/src/commands/model.ts @@ -18,12 +18,12 @@ export function registerModel(program: Command): void { ), 'ls', ), - 'GET /v1/models', + 'GET /v1/account/models', ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const { models } = await api().models.list() + const { models } = await api().account.models.list() if (opts.json) { printJson(models) return @@ -33,10 +33,10 @@ export function registerModel(program: Command): void { return } printTable( - ['ID', 'NAME', 'DEFAULT'], - models.map((m) => [m.id, m.display_name, m.is_default_agent_model ? 'yes' : '']), + ['ID', 'NAME', 'HARNESS', 'DEFAULT'], + models.map((m) => [m.id, m.display_name, m.harness, m.is_default_agent_model ? 'yes' : '']), ) - console.log('\nSelect one by setting `model:` under `claude:` in your automation YAML.') + console.log('\nSet `session.harness.type` and `session.harness.model` in your automation YAML, or use `agent session start --harness --model `.') }) }) } diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 01a60d5..dc624c4 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -7,14 +7,14 @@ export function registerPing(program: Command): void { program .command('ping') .description('Check that the API is reachable and the credential is valid'), - 'GET /v1/me', + 'GET /v1/identity', ) .action(async () => { // There's no unauthenticated health route on the public API, so we probe // the lightest authenticated endpoint (/me): a 200 proves the API is // reachable AND the stored token is valid. try { - const me = await api().me() + const me = await api().identity() console.log(`ok: ${me.customer_login} (${me.customer_id})`) } catch (err) { if (err instanceof APIError && err.status === 401) { diff --git a/src/commands/review.ts b/src/commands/review.ts index 2c76537..57a3429 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -250,9 +250,10 @@ ellipsis: # # review: # - name: migration-safety -# claude: -# system: | -# Review SQL migrations for locks that block writes on a large table. +# harness: +# type: claude_code +# instructions: | +# Review SQL migrations for locks that block writes on a large table. # pull_requests: # paths: ["sql/migrations/**"] # @@ -261,9 +262,10 @@ ellipsis: # # filter: # name: gatekeeper -# claude: -# system: | -# Drop any finding that is not worth the author's time. +# harness: +# type: claude_code +# instructions: | +# Drop any finding that is not worth the author's time. budget: run: 2.00 diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 7a00aab..6f65ac3 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -21,6 +21,7 @@ import { parseWhen, toInt, toNumber, + toHarness, } from '../lib/args' import { alsoKnownAs, apiRoutes } from '../lib/help' import { sessionUrl } from '../lib/urls' @@ -51,6 +52,7 @@ import { readImageAttachment } from '../lib/images' import { formatStepLine, oneLine, recordText } from '../lib/steps' import { parseRepo, + assertCurrentHarnessKeys, sessionConfigName, startRequestFromConfig, withContextRepository, @@ -105,7 +107,7 @@ export function registerSession(program: Command): void { ) .option( '--override ', - 'partial patch (YAML/JSON) of session config keys merged onto the inline config, e.g. "claude:\\n effort: high"', + 'partial patch (YAML/JSON) of session config keys merged onto the inline config, e.g. "harness:\\n effort: high"', ) .option( '--override-file ', @@ -113,9 +115,10 @@ export function registerSession(program: Command): void { ) .option( '--model ', - 'override claude.model for this session (see `agent model list`)', + 'override harness.model for this session (see `agent model list`)', ) - .option('--system ', 'override claude.system, the agent system prompt') + .option('--harness ', 'select claude_code or codex (default: claude_code)', toHarness) + .option('--system ', 'override the instructions appended to the harness prompt') .option( '-r, --repo ', 'also check out a repository, in whichever environment the session runs (repeatable; a bare name means your account)', @@ -170,6 +173,7 @@ export function registerSession(program: Command): void { override?: string overrideFile?: string model?: string + harness?: 'claude_code' | 'codex' system?: string repo: string[] cpu?: number @@ -223,7 +227,7 @@ 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 = {} + let req: StartAgentSessionRequest = { harness: { type: 'claude_code' } } if (opts.configFile) { req = { ...req, ...startRequestFromConfig(readConfigFile(opts.configFile)) } } @@ -281,12 +285,10 @@ export function registerSession(program: Command): void { // UI shows it in its footer meta line (anything printed before the // app would land in scrollback); every other mode prints this note. let configNote: string | undefined - // Widened to string: `automation` replaced `agent_config` on the wire - // (SDK 0.28.0); the pinned SDK's enum predates it. - const environmentSource: string | null | undefined = session.environment?.source + const environmentSource = session.environment?.source if (session.environment?.environment_id && environmentSource !== 'request') { const label = - environmentSource === 'automation' || environmentSource === 'agent_config' + environmentSource === 'automation' ? 'from the automation' : environmentSource const note = `using environment ${session.environment.environment_id} (${label})` @@ -577,7 +579,7 @@ export function registerSession(program: Command): void { // Fetch the session and the login (for the link) together — no added latency. const [{ session: s }, me] = await Promise.all([ client.sessions.get(sessionId), - client.me(), + client.identity(), ]) printSessionSummary(s) console.log(`url: ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) @@ -781,9 +783,9 @@ function printSessionSummary(s: AgentSession): void { } // Print a clickable dashboard link for a session. The route is scoped by -// account login, which isn't on the session object, so resolve it from /me. +// account login, which isn't on the session object, so resolve it from /identity. async function printSessionUrl(client: Ellipsis, sessionId: string): Promise { - const me = await client.me() + const me = await client.identity() console.log(` ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) } @@ -798,6 +800,7 @@ export function buildStartOverride(opts: { override?: string overrideFile?: string model?: string + harness?: 'claude_code' | 'codex' system?: string repo?: string[] cpu?: number @@ -819,11 +822,13 @@ export function buildStartOverride(opts: { base = parsed as Record } + assertCurrentHarnessKeys(base) const sugar: Record = {} - const claude: Record = {} - if (opts.model !== undefined) claude.model = opts.model - if (opts.system !== undefined) claude.system = opts.system - if (Object.keys(claude).length) sugar.claude = claude + 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 const compute: Record = {} if (opts.cpu !== undefined) compute.cpu = opts.cpu @@ -859,7 +864,12 @@ function deepMerge( const out: Record = { ...base } for (const [k, v] of Object.entries(over)) { const b = out[k] - out[k] = isPlainObject(b) && isPlainObject(v) ? deepMerge(b, v) : v + // 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 } return out } diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 17f71ff..bb71c7c 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -2,25 +2,38 @@ import type { Command } from 'commander' import { api } from '../lib/api' import { apiRoutes } from '../lib/help' import { printJson, runAction, usd, usdFromMillicents } from '../lib/output' +import type { BudgetSummary } from '../lib/types' + +export function budgetLines(budget: BudgetSummary): string[] { + return [ + `credit: ${usd(budget.credit_balance_usd)}${budget.credit_exhausted ? ' (exhausted)' : ''}`, + `as of: ${budget.as_of}`, + '', + ...budget.windows.map((window) => { + const period = `trailing ${window.window_days} ${window.window_days === 1 ? 'day' : 'days'}` + const used = `${(window.fraction_used * 100).toFixed(1)}%` + const exhausted = window.exhausted ? ' (exhausted)' : '' + return `${period}: ${usd(window.spent_usd)} of ${usd(window.limit_usd)} (${used}), ${usd(window.remaining_usd)} remaining${exhausted}` + }), + ] +} export function registerUsage(program: Command): void { apiRoutes( - program.command('budget').description("Show this period's spend against the account budget"), - 'GET /v1/budget', + program + .command('budget') + .description('Show prepaid credit and trailing account budgets'), + 'GET /v1/account/budget', ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const b = await api().budget() + const b = await api().account.budget() if (opts.json) { printJson(b) return } - const pct = (b.fraction_used * 100).toFixed(1) - console.log(`period: ${b.period}`) - console.log(`spent: ${usd(b.spent_usd)} of ${usd(b.budget_usd)} (${pct}%)`) - console.log(`remaining: ${usd(b.remaining_usd)}`) - console.log(`pause at limit: ${b.pause_at_limit ? 'yes' : 'no'}`) + for (const line of budgetLines(b)) console.log(line) }) }) @@ -28,12 +41,12 @@ export function registerUsage(program: Command): void { program .command('usage') .description("Show this period's tokens and cost, broken down by model"), - 'GET /v1/usage', + 'GET /v1/account/usage', ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const u = await api().usage() + const u = await api().account.usage() if (opts.json) { printJson(u) return @@ -50,7 +63,9 @@ export function registerUsage(program: Command): void { m.cost_sandbox_memory_millicents + m.cost_fee_millicents, ) - console.log(` ${m.model_id.padEnd(28)} ${m.tokens.toLocaleString().padStart(14)} ${cost}`) + console.log( + ` ${m.model_id.padEnd(28)} ${m.tokens.toLocaleString().padStart(14)} ${cost}`, + ) } } }) diff --git a/src/lib/args.ts b/src/lib/args.ts index ae4ac84..94c4404 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -61,6 +61,10 @@ function oneOf(kind: string, allowed: readonly string[], value: string): string return value } +export function toHarness(value: string): 'claude_code' | 'codex' { + return oneOf('harness', ['claude_code', 'codex'], value) as 'claude_code' | 'codex' +} + // Repeatable, validated variants of `collect` for the list facets. export function collectSource(value: string, previous: string[]): string[] { return [...previous, oneOf('source', SESSION_SOURCES, value)] diff --git a/src/lib/chatItems.ts b/src/lib/chatItems.ts index 5175260..c170b52 100644 --- a/src/lib/chatItems.ts +++ b/src/lib/chatItems.ts @@ -1,6 +1,8 @@ import { chatTurnsToItems as sdkChatTurnsToItems, recordToItems, + claudePayload, + foldCosts, type ChatTurn, type TranscriptItem, } from '@ellipsis-dev/sdk/store' @@ -19,6 +21,51 @@ export function chatTurnsToItems(turns: readonly ChatTurn[]): TranscriptItem[] { ) } +// Native Claude events keep their original payloads; the SDK derives the +// legacy display view used by the last-turn cost footer. +export function foldRecordCosts(records: readonly SessionRecord[]) { + return foldCosts(records.map(claudePayload).filter((payload) => payload !== null)) +} + +// Lifecycle and native bookkeeping records intentionally have no chat row. +// Unknown records still count, even when their producer or type looks familiar. +function isSilentRecord(record: SessionRecord): boolean { + switch (record.kind) { + case 'platform': + return true + case 'claude_sdk': + return record.payload.kind === 'system' || record.payload.kind === 'rate_limit' + case 'claude_code': + return ['system', 'rate_limit_event', 'conversation_reset'].includes( + record.payload.type, + ) + case 'codex': { + const event = record.payload + return ( + [ + 'thread.started', + 'turn.started', + 'turn.completed', + 'item.started', + 'item.updated', + ].includes(event.type) || + (event.type === 'item.completed' && event.item.type === 'todo_list') + ) + } + case 'codex_app_server': { + const event = record.payload + if (event.method === 'turn/completed') + return event.params.turn.status === 'completed' + if (event.method === 'error') return event.params.willRetry + if (event.method === 'item/completed') + return event.params.item.type === 'userMessage' + return true + } + default: + return false + } +} + // Agent records that arrived and would render NOTHING — the signal for a // payload shape this build cannot read (a harness change on the server, an // out-of-date CLI). Without it such a record is invisible twice over: no row, @@ -31,14 +78,14 @@ export function undisplayedRecordCount( ): number { let undisplayed = 0 for (const r of records) { - if (r.feed_seq <= minRenderFeedSeq || r.source === 'lifecycle') continue + if (r.feed_seq <= minRenderFeedSeq || isSilentRecord(r)) continue let rendered: TranscriptItem[] try { rendered = recordToItems(r, `s${r.feed_seq}`) ?? [] } catch { rendered = [] } - if (rendered.length === 0 && r.record_type !== 'system') undisplayed++ + if (rendered.length === 0) undisplayed++ } return undisplayed } diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 6df5ab0..e261d00 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -287,14 +287,23 @@ 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 = [ - 'claude', - 'codex', + 'harness', + 'instructions', 'environment', 'output', 'permissions', 'skills', ] 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', + ) + } +} + // An inline config file (`session start -f/-t`) as a start request. The file // is either an automation document (session keys under `session:`, as every // template and `agent automation init` file is) or a bare session config; @@ -307,6 +316,14 @@ export function startRequestFromConfig( nested && typeof nested === 'object' && !Array.isArray(nested) ? (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 req: Record = {} for (const key of START_CONFIG_KEYS) { if (config[key] !== undefined) req[key] = config[key] @@ -341,6 +358,8 @@ 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'] + modelId?: string id: string | null label: string // The heading this row sits under. Consecutive rows sharing a group print @@ -448,12 +467,25 @@ export function composerModelOptions(models: readonly SupportedModel[]): Compose .map((m) => ({ id: (m.is_default_agent_model ? null : m.id) as string | null, label: m.id, + harness: m.harness, + modelId: m.id, group: manufacturerLabel(m.manufacturer), rate: modelRate(m.rate_card), })) return hasDefault ? rows : [{ id: null, label: 'Default', group: null }, ...rows] } +// The default row inherits the account model for Claude. Codex's request +// requires a concrete model, so retain the catalog id even on its default row. +export function composerModelChoice( + option: ComposerModel | undefined, +): Pick { + return { + model: option?.id ?? (option?.harness === 'codex' ? option.modelId ?? null : null), + harness: option?.harness, + } +} + // A picker's display rows: each group's heading, then the options under it. // Headings are DECORATION — they carry no index, ↑/↓ never lands on one, and // activating a row can't select one — so the list scrolls over these rows @@ -965,6 +997,7 @@ export type ComposerEnvironment = | { kind: 'custom'; pane: EnvironmentPaneState } export interface ComposerChoices { + harness?: StartAgentSessionRequest['harness']['type'] environment: ComposerEnvironment model: string | null } @@ -1020,10 +1053,17 @@ export function applyComposerChoices( choices: ComposerChoices, ): StartAgentSessionRequest { const req: StartAgentSessionRequest = { ...base } - // Only the model: sending any sibling claude field would override the - // inline config's own (system especially). + // 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.claude = { model: choices.model } as StartAgentSessionRequest['claude'] + 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 env = choices.environment if (env.kind === 'named') req.environment = env.id diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 9315c6e..49c97d6 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -3,6 +3,8 @@ import { lifecycleText as sdkLifecycleText, sessionLogText as sdkSessionLogText, oneLine, + claudePayload, + recordToItems, type SandboxState, } from '@ellipsis-dev/sdk/store' import { formatTs } from './output' @@ -57,12 +59,9 @@ interface StepContentBlock { content?: unknown } -// The payload as a loose bag. Every read below is best-effort display text -// across three harness formats (claude_sdk@1, codex_jsonl@1, -// ellipsis_lifecycle@1), so narrowing the SDK's per-format union at each field -// would buy nothing a `typeof` guard doesn't already give. +// Display reads a derived Claude view; the stored native payload stays intact. function fields(record: SessionRecord): Record { - return record.payload as Record + return (claudePayload(record) ?? record.payload) as Record } // One session_record as a single display line: index, timestamp, record type, @@ -86,9 +85,17 @@ export function formatStepLine(record: SessionRecord): string { // Anything unrecognized falls back to its JSON. export function recordText(record: SessionRecord): string { const data = fields(record) - if (record.source === 'lifecycle') { + if (record.kind === 'platform') { return lifecycleText(record.record_type, data) ?? record.record_type } + if (record.kind === 'codex' || record.kind === 'codex_app_server') { + const items = recordToItems(record, record.id) + if (items.length) { + return items + .map((item) => [item.text, item.detail].filter(Boolean).join(' ')) + .join(' ') + } + } if (typeof data.result === 'string') return data.result const text = contentText(data.content) if (text) return text diff --git a/src/lib/types.ts b/src/lib/types.ts index 693b108..1723145 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -107,11 +107,11 @@ export type PutSandboxVariablesRequest = S['PutSecretsRequest'] // ----------------------------- usage / budget ----------------------------- -export type BudgetWindow = S['BudgetWindow'] +export type BudgetWindow = S['AccountBudgetWindow'] export type BudgetSummary = S['BudgetSummary'] export type UsageDailyPoint = S['UsageDailyPoint'] export type ModelUsageBreakdown = S['ModelUsageBreakdown'] -export type UsageDashboard = S['GetUsageDashboardResponse'] +export type UsageDashboard = S['GetUsageOverTimeResponse'] // ------------------------------- analytics -------------------------------- @@ -153,7 +153,7 @@ export type ListSentryOrganizationsResponse = S['SentryOrganizationsListResponse // -------------------------------- identity -------------------------------- -export type WhoAmI = S['WhoAmIResponse'] +export type WhoAmI = S['Identity'] // The GitHub user behind a user_id. Loosely typed on purpose: the CLI only // reads `login`, and this shape is also what it caches to disk, where an older diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index e76e35a..a2c08a6 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -17,7 +17,6 @@ import { awaitingAgentPhase, collapseToolRuns, deliveredUnechoedSends, - foldCosts, groupRecordsToChatTurns, humanDuration, lastLines, @@ -29,9 +28,9 @@ import { type SessionTranscriptStore, } from '@ellipsis-dev/sdk/store' import { deriveSandboxState } from '../lib/steps' -import { chatTurnsToItems, undisplayedRecordCount } from '../lib/chatItems' +import { chatTurnsToItems, foldRecordCosts, undisplayedRecordCount } from '../lib/chatItems' import { errorDetail } from '../lib/api' -import type { Ellipsis, SdkRecord } from '@ellipsis-dev/sdk' +import type { Ellipsis, Session, SessionRecord } from '@ellipsis-dev/sdk' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' @@ -339,12 +338,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // CC-result fold as the last-turn readout and older-backend fallback (§6: // record folding is display-only). const cost = useMemo( - () => - foldCosts( - snapshot.records - .filter((r) => r.source === 'claude_code') - .map((r) => r.payload as SdkRecord), - ), + () => foldRecordCosts(snapshot.records), [snapshot.records], ) const serverCostUsd = snapshot.session @@ -413,10 +407,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ]) if (page.records.length) { // Inbox state (message_received/delivered/requeued) rides the record - // feed now (protocol v3) — the store folds it as records land. - store.ingest({ type: 'records_append', records: page.records }) + // feed: the store folds it as records land. REST and frame types + // differ in optional defaults but carry the same serialized values. + store.ingest({ type: 'records_append', records: page.records as SessionRecord[] }) } - store.ingest({ type: 'session', session }) + store.ingest({ type: 'session', session: session as Session }) } catch { // Transient fetch failure — the next tick retries. } diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 2f557a6..52afd11 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -22,6 +22,7 @@ import { attentionFlip, paneWithRepository, composerModelOptions, + composerModelChoice, composerPickerRows, connectability, CUSTOM_ENVIRONMENT_ID, @@ -350,7 +351,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { .catch((err) => { reportApiError('integrations', err) }) - void api.models + void api.account.models .list() .then((r) => setModels(r.models)) .catch((err) => { @@ -933,7 +934,10 @@ function Launcher({ : pickedPreset!.id === EMPTY_ENVIRONMENT_ID ? { kind: 'empty' } : { kind: 'named', id: pickedPreset!.id } - onSubmit(text.trim(), { environment, model: modelOptions[modelIdx]?.id ?? null }) + onSubmit(text.trim(), { + environment, + ...composerModelChoice(modelOptions[modelIdx]), + }) } // The rows below the prompt: the typed text filters the list live, so the diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index bde929a..a886d51 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -43,7 +43,7 @@ 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 = {} + let req: StartAgentSessionRequest = { harness: { type: 'claude_code' } } if (prompt) req.prompt = prompt const contextRepo = repoFromCwd(process.cwd()) if (contextRepo) req = withContextRepository(req, contextRepo) @@ -55,7 +55,7 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { const token = requireToken() const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) // Pick the palette for this terminal's background before the first frame. - const [me] = await Promise.all([client.me(), applyDetectedThemeMode()]) + const [me] = await Promise.all([client.identity(), applyDetectedThemeMode()]) // No screen-clearing dance: the chat prints its settled transcript into THIS // terminal's scrollback (see ConnectApp), so the conversation grows down the diff --git a/test/api.test.ts b/test/api.test.ts index 6306c76..3d64026 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -38,10 +38,10 @@ function stubOk(body: unknown = { ok: true }): ReturnType { describe('api', () => { it('sends the resolved bearer token and hits the resolved base', async () => { const fetchMock = stubOk({ customer_login: 'acme' }) - await api('http://api.test', 'tok_123').me() + await api('http://api.test', 'tok_123').identity() const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe('http://api.test/v1/me') + expect(url).toBe('http://api.test/v1/identity') expect(init.headers).toMatchObject({ Authorization: 'Bearer tok_123' }) }) @@ -49,16 +49,16 @@ describe('api', () => { process.env.ELLIPSIS_API_TOKEN = 'env_tok' process.env.ELLIPSIS_API_BASE_URL = 'http://env.test' const fetchMock = stubOk() - await api().budget() + await api().account.budget() const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe('http://env.test/v1/budget') + expect(url).toBe('http://env.test/v1/account/budget') expect(init.headers).toMatchObject({ Authorization: 'Bearer env_tok' }) }) it('stamps the CLI user agent on every request, so calls stay attributable', async () => { const fetchMock = stubOk() - await api('http://api.test', 't').usage() + await api('http://api.test', 't').account.usage() const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] expect(init.headers).toMatchObject({ 'user-agent': USER_AGENT }) diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index efb0d5f..25a79a4 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -18,9 +18,7 @@ import { theme } from '../src/lib/theme' import type { TranscriptItem } from '@ellipsis-dev/sdk/store' let seq = 0 -// `record_format` is what recordToItems and groupRecordsToChatTurns switch on, -// so a fixture carries the same token the wire does: claude_sdk@1 for agent -// records, ellipsis_lifecycle@1 for platform ones. +// SDK 0.28 narrows records by kind, including archived Claude SDK payloads. function rec(recordType: string, payload: Record = {}, source = 'lifecycle') { const feed = ++seq return { @@ -28,6 +26,7 @@ function rec(recordType: string, payload: Record = {}, source = created_at: '2026-01-01T00:00:00Z', feed_seq: feed, source, + kind: source === 'lifecycle' ? 'platform' : source === 'claude_code' ? 'claude_sdk' : 'unknown', record_type: recordType, record_format: source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1', payload, @@ -202,7 +201,7 @@ describe('undisplayedRecordCount', () => { 'claude_code', ) expect(count([assistant('fine'), result])).toBe(0) - expect(count([rec('system', { type: 'system', subtype: 'init' }, 'claude_code')])).toBe(0) + expect(count([rec('system', { kind: 'system', subtype: 'init' }, 'claude_code')])).toBe(0) expect(count([rec('session_idle')])).toBe(0) }) diff --git a/test/sdk-upgrade.test.ts b/test/sdk-upgrade.test.ts new file mode 100644 index 0000000..758c167 --- /dev/null +++ b/test/sdk-upgrade.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest' +import type { SessionRecord } from '@ellipsis-dev/sdk' +import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' +import { budgetLines } from '../src/commands/usage' +import { + chatTurnsToItems, + foldRecordCosts, + undisplayedRecordCount, +} from '../src/lib/chatItems' +import { recordText } from '../src/lib/steps' +import { buildStartOverride } from '../src/commands/session' +import { toHarness } from '../src/lib/args' +import { + applyComposerChoices, + composerModelChoice, + composerModelOptions, + startRequestFromConfig, +} from '../src/lib/sessions' +import type { BudgetSummary, SupportedModel } from '../src/lib/types' + +const envelope = { + id: 'record_1', + session_id: 'session_1', + session_execution_id: 'execution_1', + agent_turn_id: 'turn_1', + sandbox_id: 'sandbox_1', + session_message_id: null, + created_at: '2026-09-10T12:00:00Z', + feed_seq: 1, + stream_seq: 1, + cost: null, + duration: null, + model: null, + tokens_info: null, + tools: null, +} + +const assistant: SessionRecord = { + ...envelope, + kind: 'claude_code', + source: 'claude_code', + record_format: 'claude_jsonl@1', + record_type: 'assistant', + payload: { + type: 'assistant', + message: { + type: 'message', + id: 'message_1', + role: 'assistant', + model: 'claude-opus-5', + content: [{ type: 'text', text: 'All tests pass.' }], + }, + }, +} + +const result: SessionRecord = { + ...envelope, + id: 'record_2', + feed_seq: 2, + stream_seq: 2, + kind: 'claude_code', + source: 'claude_code', + record_format: 'claude_jsonl@1', + record_type: 'result', + payload: { + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 2000, + duration_api_ms: 1800, + num_turns: 1, + total_cost_usd: 0.12, + result: 'All tests pass.', + }, +} + +describe('native session records', () => { + it('renders native Claude replies through the CLI transcript and record listing', () => { + const original = JSON.stringify(assistant) + const store = new SessionTranscriptStore() + store.ingest({ type: 'records_append', records: [assistant, result] }) + expect(chatTurnsToItems(store.chatTurns()).map((item) => item.text)).toEqual([ + 'All tests pass.', + ]) + expect(recordText(assistant)).toBe('All tests pass.') + expect(recordText(result)).toBe('All tests pass.') + expect(undisplayedRecordCount([assistant, result], 0)).toBe(0) + expect(JSON.stringify(assistant)).toBe(original) + }) + + it('keeps the cost footer populated for native and archived Claude results', () => { + const archived: SessionRecord = { + ...envelope, + kind: 'claude_sdk', + source: 'claude_code', + record_format: 'claude_sdk@1', + record_type: 'result', + payload: { + kind: 'result', + subtype: 'success', + is_error: false, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + cost_usd: 0.08, + }, + } + expect(foldRecordCosts([archived, result])).toMatchObject({ + total: 0.2, + lastStep: 0.12, + }) + }) + + it('renders native Codex replies and ignores successful turn completion', () => { + const reply: SessionRecord = { + ...envelope, + kind: 'codex_app_server', + source: 'codex', + record_format: 'codex_app_server@1', + record_type: 'item/completed', + payload: { + method: 'item/completed', + params: { + threadId: 'thread_1', + turnId: 'turn_1', + item: { type: 'agentMessage', id: 'message_1', text: 'Done.' }, + }, + }, + } + const done: SessionRecord = { + ...reply, + id: 'record_2', + feed_seq: 2, + record_type: 'turn/completed', + payload: { + method: 'turn/completed', + params: { + threadId: 'thread_1', + turn: { id: 'turn_1', status: 'completed', items: [] }, + }, + }, + } + const store = new SessionTranscriptStore() + store.ingest({ type: 'records_append', records: [reply, done] }) + expect(chatTurnsToItems(store.chatTurns()).map((item) => item.text)).toEqual([ + 'Done.', + ]) + expect(recordText(reply)).toBe('Done.') + expect(undisplayedRecordCount([reply, done], 0)).toBe(0) + }) + + it('ignores native bookkeeping but still warns about unknown records from a known producer', () => { + const system: SessionRecord = { + ...assistant, + record_type: 'system', + payload: { type: 'system', subtype: 'init' }, + } + const unknown: SessionRecord = { + ...envelope, + kind: 'unknown', + source: 'claude_code', + record_type: 'system', + record_format: 'claude_jsonl@2', + payload: { future: true }, + } + expect(undisplayedRecordCount([system], 0)).toBe(0) + expect(undisplayedRecordCount([system, unknown], 0)).toBe(1) + expect(recordText(unknown)).toBe('{"future":true}') + }) +}) + +describe('budgetLines', () => { + // Only the fields used by this read-only summary; policy and alert editing + // remain outside the command's scope. + const budget = (credit: number, spent: number): BudgetSummary => + ({ + as_of: '2026-09-10T12:00:00Z', + credit_balance_usd: credit, + credit_exhausted: credit <= 0, + windows: [1, 7, 28].map((days) => ({ + window_days: days, + spent_usd: spent, + limit_usd: 10, + remaining_usd: 10 - spent, + fraction_used: spent / 10, + exhausted: spent >= 10, + platform_max_usd: 100, + })), + }) as BudgetSummary + + it('shows prepaid credit separately from every trailing budget window', () => { + const lines = budgetLines(budget(25, 2.5)) + expect(lines[0]).toBe('credit: $25.00') + expect(lines).toContain('trailing 1 day: $2.50 of $10.00 (25.0%), $7.50 remaining') + expect(lines).toContain('trailing 7 days: $2.50 of $10.00 (25.0%), $7.50 remaining') + expect(lines).toContain('trailing 28 days: $2.50 of $10.00 (25.0%), $7.50 remaining') + }) + + it('preserves negative balances and flags exhausted credit and budgets', () => { + const lines = budgetLines(budget(-0.25, 12)) + expect(lines[0]).toBe('credit: $-0.25 (exhausted)') + expect(lines).toContain( + 'trailing 1 day: $12.00 of $10.00 (120.0%), $-2.00 remaining (exhausted)', + ) + }) +}) + +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('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('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('keeps the certified harness and concrete model on a Codex default row', () => { + const model: SupportedModel = { + id: 'gpt-6-astra', + display_name: 'GPT-6 Astra', + harness: 'codex', + capabilities: [], + manufacturer: 'openai', + is_default_agent_model: true, + rate_card: { + input_millicents_per_1m_tokens: 0, + output_millicents_per_1m_tokens: 0, + cache_write_5m_millicents_per_1m_tokens: 0, + cache_write_1h_millicents_per_1m_tokens: 0, + cache_read_millicents_per_1m_tokens: 0, + }, + } + const [row] = composerModelOptions([model]) + expect(row.id).toBeNull() + expect(composerModelChoice(row)).toEqual({ harness: 'codex', model: 'gpt-6-astra' }) + }) + + it('validates the harness flag and maps shared instructions separately', () => { + 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.', + }) + }) +}) diff --git a/test/search.test.ts b/test/search.test.ts index 36930bb..d572a39 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -94,14 +94,15 @@ describe('recordText / formatStepLine', () => { overrides: Partial = {}, ): SessionRecord => ({ id: 'rec_1', - agent_session_id: 'session_1', + session_id: 'session_1', + kind: overrides.source === 'lifecycle' ? 'platform' : 'claude_sdk', session_execution_id: 'exec_1', created_at: '2026-07-03T12:00:00+00:00', feed_seq: 3, stream_seq: 3, source: 'claude_code', record_type: (payload.kind as string) ?? 'assistant', - record_format: 'claude_stream_json@2.0', + record_format: overrides.source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1', payload, ...overrides, }) diff --git a/test/session.test.ts b/test/session.test.ts index a600010..3f1ed41 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -169,7 +169,8 @@ describe('buildStartOverride', () => { budget: 0.5, }), ).toEqual({ - claude: { model: 'claude-opus-4-8', system: 'do the thing' }, + harness: { model: 'claude-opus-4-8' }, + instructions: 'do the thing', environment: { compute: { cpu: 2, memory: '8GB', timeout: '30m' }, }, @@ -187,12 +188,12 @@ describe('buildStartOverride', () => { it('deep-merges sugar flags on top of a raw inline override (flags win)', () => { expect( buildStartOverride({ - override: 'claude:\n model: claude-haiku-4-5-20251001\n system: base\nenabled: false', + override: 'harness:\n type: claude_code\n model: claude-haiku-4-5-20251001\n effort: high\ninstructions: base', model: 'claude-opus-4-8', }), ).toEqual({ - claude: { model: 'claude-opus-4-8', system: 'base' }, - enabled: false, + harness: { type: 'claude_code', model: 'claude-opus-4-8', effort: 'high' }, + instructions: 'base', }) }) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 5459fe5..e99a97e 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -574,7 +574,7 @@ describe('applyComposerChoices', () => { // stand-in when nothing else is checked. const untouched = { environment: { kind: 'empty' } as const, model: null } - it('keeps the context repo and patches the model as a claude block', () => { + it('keeps the context repo and patches the selected harness model', () => { const req = applyComposerChoices( { repositories: ['acme/api'] }, { ...untouched, model: 'claude-opus-5' }, @@ -582,7 +582,7 @@ describe('applyComposerChoices', () => { expect(req).toEqual({ repositories: ['acme/api'], environment: {}, - claude: { model: 'claude-opus-5' }, + harness: { type: 'claude_code', model: 'claude-opus-5' }, }) }) @@ -604,7 +604,7 @@ describe('applyComposerChoices', () => { expect(req).toEqual({ prompt: 'ship it', environment: 'env_1', - claude: { model: 'claude-fable-5' }, + harness: { type: 'claude_code', model: 'claude-fable-5' }, }) }) @@ -1202,13 +1202,15 @@ describe('start request shaping', () => { trigger: { type: 'cron', schedule: '* * * * *' }, input: { json_schema: {} }, session: { - claude: { system: 'do it', model: 'claude-opus-5' }, + harness: { type: 'claude_code', model: 'claude-opus-5' }, + instructions: 'do it', environment: { repositories: [{ name: 'api' }] }, budget: { session: 5 }, }, }), ).toEqual({ - claude: { system: 'do it', model: 'claude-opus-5' }, + harness: { type: 'claude_code', model: 'claude-opus-5' }, + instructions: 'do it', environment: { repositories: [{ name: 'api' }] }, budget: 5, }) @@ -1217,11 +1219,11 @@ describe('start request shaping', () => { it('accepts a bare session config too', () => { expect( startRequestFromConfig({ - claude: { system: 'do it' }, + harness: { type: 'claude_code' }, instructions: 'do it', budget: { session: 2 }, trigger: { type: 'cron', schedule: '* * * * *' }, }), - ).toEqual({ claude: { system: 'do it' }, budget: 2 }) + ).toEqual({ harness: { type: 'claude_code' }, instructions: 'do it', budget: 2 }) }) // The context repo rides the request's additive `repositories` key and