diff --git a/README.md b/README.md index c74b498..ac255c5 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ agent host delete beta # remove a host and its stored token agent session start --config # start a session from a saved config agent session start --config-file f.json # ...or from an inline config agent session start --template ellipsis-helper # ...or from a maintained template -agent session start --config --config-override "budget:\n session: 5" # override config fields for this session +agent session start --config --override "budget:\n session: 5" # override config fields for this session agent session start --config --watch # start and immediately stream it agent session list --limit 20 # list recent sessions (filter by --source, --author, --since, …) agent session search "webhook retries" # search session history: transcripts, recaps, created PRs, similarity @@ -81,9 +81,6 @@ agent config edit --file agents/foo.yaml # replace its definition agent config delete # delete it; the agent stops and its name is freed agent config link --repo api # move it into a repository, via a pull request agent config unlink # take it over from its file, so the API changes it -agent config default # the effective default agent for the repo you are standing in -agent config default set # set the account default agent (--repo [owner/name] for one repo) -agent config default clear # clear the account default (--repo [owner/name] for one repo) agent model list # list selectable agent models (the account default is marked) diff --git a/bun.lock b/bun.lock index d9779e3..a7dd5e7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "/tmp/ellipsis-dev-sdk-0.17.0.tgz", + "@ellipsis-dev/sdk": "^0.18.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@/tmp/ellipsis-dev-sdk-0.17.0.tgz", {}, "sha512-6x19WBjT+vLpAR7qzuXetkoYlLMniQp47wGMxHvDY8jaGBrmqw2UbViPUk+vQoR1ASmNO0YgMy9q4YLshtp1Ug=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.18.0", "", {}, "sha512-GPY4/1daXnSBa9YaYl05hrTdyKOOYoC8h2VeiFsgk8ScdmmZq8C42QAiyNGMW9fhG8E2Bgofe/WQ9lSFC5FUuA=="], "@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 fba0063..646a97c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.17.0", + "@ellipsis-dev/sdk": "^0.18.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/skills/cli-conventions/SKILL.md b/skills/cli-conventions/SKILL.md index 2ac341b..be2b950 100644 --- a/skills/cli-conventions/SKILL.md +++ b/skills/cli-conventions/SKILL.md @@ -19,7 +19,7 @@ Singular nouns, one verb per action. ``` agent file list agent file delete -agent session start agent config default set +agent session start agent config edit ``` - **The noun is singular, always.** `file`, not `files`. `hook`, not diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 56b8c9b..ae76c0f 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -381,24 +381,23 @@ wins: the environment variable, then the token stored in `~/.ellipsis/config.jso Start and follow work: ```sh -agent session start "triage the failing CI on api" # runs the resolved default agent +agent session start "triage the failing CI on api" # a bare ad-hoc session agent session start --config --watch # stream until terminal agent session start --config-file agents/my_agent.yaml --watch agent session start --template ellipsis-helper --watch agent session get --watch # follow a running session agent session connect # live view plus send messages agent session stop -agent session replay # re-run against its frozen snapshot agent session ide # browser IDE into the live sandbox agent session port 3000 # preview a port the sandbox serves ``` -With no config source, a bare `start` resolves the repository default, then the -account default, then a bare ad-hoc config on `claude-opus-5`, so the prompt is -the sole instruction. The CLI also sends the repository you are standing in, and +With no config source, a bare `start` runs the bare ad-hoc config — an empty +system prompt on the account's default model in the basic sandbox — so the +prompt is the sole instruction. The CLI also sends the repository you are standing in, and the server clones it. Per-session overrides need no config edit: `--model`, `--system`, `--repo`, `--cpu`, `--memory`, `--timeout`, `--budget`, and -`--config-override` for a full partial config. `--rebuild` skips the image +`--override` for a full partial config patch. `--rebuild` skips the image cache. `--detach` returns immediately. `--watch --quiet` prints only status transitions and the result, and either watch form exits `0` only when the session completes. @@ -447,7 +446,6 @@ agent config edit --file agents/my_agent.yaml # replace its definition, l agent config delete # delete it; it stops and frees its name agent config link --repo api # move it into a repo, via a pull request agent config unlink # take it over from its file -agent config default set # the account default (--repo for one repo) agent template list # built-in templates and their slugs agent model list # the model ids valid under claude.model ``` diff --git a/src/commands/config.ts b/src/commands/config.ts index 4d871aa..bda8e86 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -11,7 +11,6 @@ import { configUrl } from '../lib/urls' import { readConfigFile } from './session' import type { AgentConfig, - AgentDefaults, CreateAgentConfigRequest, CreatedAgentConfig, SavedAgentConfig, @@ -23,7 +22,7 @@ export function registerConfig(program: Command): void { const config = alsoKnownAs( program .command('config') - .description('Inspect your agent configs and set which one runs by default'), + .description('Inspect and manage your saved agent configs'), 'configs', ) @@ -255,145 +254,6 @@ export function registerConfig(program: Command): void { }) }) - // ------------------------------- defaults -------------------------------- - // The default-config ladder a bare session start resolves: repo default -> - // account default -> the bare platform config. Rung-addressed, never row - // ids: writes target the ACCOUNT rung unless --repo names (or detects) a - // repository. Reading is context-aware (bare `agent config default` shows - // the effective default where you stand); writes never are — a mutation - // whose target depends on your cwd would be a footgun, so --repo is always - // explicit. - const defaults = apiRoutes( - alsoKnownAs( - config - .command('default') - .description('Show or set which agent config runs when a session names none'), - 'defaults', - ), - 'GET /v1/agents/defaults', - ) - .option('--json', 'output raw JSON') - // Bare `agent config default`: the effective default for the repo you're - // standing in, computed locally from GET /defaults + the origin remote - // (the same ladder session start resolves server-side). - .action(async (opts: { json?: boolean }) => { - await runAction(async () => { - const ladder = await api().agents.defaults.list() - const repo = repoFromCwd(process.cwd()) - const repoRung = repo ? repoDefault(ladder, repo) : undefined - const effective = repoRung ?? ladder.account ?? null - if (opts.json) { - printJson({ repository: repo ?? null, effective }) - return - } - if (!effective) { - console.log( - repo - ? `no default set for ${repo} or the account (sessions start on the bare config)` - : 'no account default set (sessions start on the bare config)', - ) - return - } - const rung = repoRung ? `repo default for ${repo}` : 'account default' - console.log(`using config "${effective}" (${rung})`) - }) - }) - - apiRoutes( - alsoKnownAs( - defaults - .command('list') - .description('List every default that is set, account rung and per-repo rungs'), - 'ls', - ), - 'GET /v1/agents/defaults', - ) - .option('--json', 'output raw JSON') - // The group also defines --json (for the bare view), and commander parses - // parent options even when they follow the subcommand name — so read the - // merged view, not just this command's own opts. - .action(async (_opts: { json?: boolean }, cmd: Command) => { - await runAction(async () => { - const client = api() - const ladder = await client.agents.defaults.list() - if (cmd.optsWithGlobals().json) { - printJson(ladder) - return - } - const rungs: [string, string][] = [ - ...(ladder.account ? ([['account', ladder.account]] as [string, string][]) : []), - ...Object.entries(ladder.repositories), - ] - if (rungs.length === 0) { - console.log('No defaults set. Sessions start on the bare config.') - return - } - // The ladder carries ids only, but a human reads this table — so join - // the account's configs to show each rung's name. - const names = new Map( - (await client.agents.configs.list()).configs.map((c) => [c.id, configName(c)]), - ) - printTable( - ['RUNG', 'CONFIG', 'CONFIG ID'], - rungs.map(([rung, id]) => [rung, names.get(id) ?? id, id]), - ) - }) - }) - - apiRoutes( - defaults - .command('set ') - .description('Set the account default agent config, or a repo default with --repo'), - 'PUT /v1/agents/defaults', - ) - .option( - '-r, --repo [repository]', - 'target a repo rung: "owner/name", or no value for the repo you are standing in', - ) - .option('--json', 'output raw JSON') - .action( - async (configId: string, opts: { repo?: string | boolean; json?: boolean }, cmd: Command) => { - await runAction(async () => { - const repository = resolveRepoFlag(opts.repo) - const ladder = await api().agents.defaults.set({ - config_id: configId, - ...(repository ? { repository } : {}), - }) - if (cmd.optsWithGlobals().json) { - printJson(ladder) - return - } - const rung = repository ? `default for ${repository}` : 'account default' - const id = repository ? repoDefault(ladder, repository) : ladder.account - console.log(`✓ set ${rung} to ${id ?? configId}`) - }) - }, - ) - - apiRoutes( - alsoKnownAs( - defaults - .command('clear') - .description('Clear the account default agent config, or a repo default with --repo'), - 'rm', - 'delete', - ), - 'DELETE /v1/agents/defaults', - ) - .option( - '-r, --repo [repository]', - 'target a repo rung: "owner/name", or no value for the repo you are standing in', - ) - .action(async (opts: { repo?: string | boolean }) => { - await runAction(async () => { - const repository = resolveRepoFlag(opts.repo) - await api().agents.defaults.delete({ repository }) - console.log( - `✓ cleared ${repository ? `default for ${repository}` : 'account default'}`, - ) - }) - }) - apiRoutes( config .command('init [path]') @@ -472,31 +332,6 @@ function configName(c: SavedAgentConfig): string { return c.agent_config.ellipsis.name ?? c.id } -// --repo semantics on defaults mutations: absent -> the account rung; bare -// --repo -> the repo you're standing in (from the origin remote, an error -// when there isn't one); --repo owner/name -> that repo. Shared with -// `agent review default`, whose rungs are addressed identically. -export function resolveRepoFlag(repo: string | boolean | undefined): string | undefined { - if (repo === undefined || repo === false) return undefined - if (repo === true) { - const detected = repoFromCwd(process.cwd()) - if (!detected) { - throw new Error( - 'no git repository detected here; pass --repo owner/name or run inside a clone', - ) - } - return detected - } - return repo -} - -// The repo rung's config id. Rungs are keyed "owner/name" as GitHub spells it, -// so match case-insensitively rather than indexing directly. -function repoDefault(ladder: AgentDefaults, repo: string): string | undefined { - const want = repo.toLowerCase() - return Object.entries(ladder.repositories).find(([r]) => r.toLowerCase() === want)?.[1] -} - // A minimal valid agent config. `claude.system` is the only required field; // everything else has a server-side default. Roots Ellipsis syncs from: // agents/, .agents/, ellipsis/, .ellipsis/ (any depth), as .yaml/.yml. diff --git a/src/commands/environment.ts b/src/commands/environment.ts index 34f9ecb..b4fc80e 100644 --- a/src/commands/environment.ts +++ b/src/commands/environment.ts @@ -3,16 +3,9 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' -import { repoFromCwd } from '../lib/git' import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output' -import { effectiveEnvironmentDefault } from '../lib/sessions' -import { resolveRepoFlag } from './config' import { readConfigFile } from './session' -import type { - EnvironmentConfig, - EnvironmentDefaults, - SavedEnvironment, -} from '../lib/types' +import type { EnvironmentConfig, SavedEnvironment } from '../lib/types' const DEFAULT_ENVIRONMENT_PATH = 'agents/environments/my_environment.yaml' @@ -91,7 +84,7 @@ export function registerEnvironment(program: Command): void { console.log( 'Reference it from agent configs (`environment: ' + e.name + - '`) or make it the default: `agent environment default set ' + + '`) or start a session in it: `agent session start -e ' + e.name + '`.', ) @@ -140,143 +133,6 @@ export function registerEnvironment(program: Command): void { }) }) - // ------------------------------- defaults -------------------------------- - // The default-environment ladder a config-less session resolves: repo - // default -> account default -> the built-in basic sandbox. Same rung - // addressing as `agent config default`. - const defaults = apiRoutes( - alsoKnownAs( - environment - .command('default') - .description('Show or set which environment serves sessions that name none'), - 'defaults', - ), - 'GET /v1/environments/defaults', - ) - .option('--json', 'output raw JSON') - // Bare `agent environment default`: the effective default for the repo - // you're standing in, computed locally from GET /defaults + the origin - // remote (the same ladder session start resolves server-side). - .action(async (opts: { json?: boolean }) => { - await runAction(async () => { - const ladder = await api().environments.defaults.list() - const repo = repoFromCwd(process.cwd()) - const effective = effectiveEnvironmentDefault(ladder, repo ?? null) - if (opts.json) { - printJson({ repository: repo ?? null, effective: effective?.id ?? null }) - return - } - if (!effective) { - console.log( - repo - ? `no default environment for ${repo} or the account (sessions get the basic sandbox)` - : 'no account default environment set (sessions get the basic sandbox)', - ) - return - } - const rung = effective.rung === 'repo' ? `repo default for ${repo}` : 'account default' - console.log(`using environment "${effective.id}" (${rung})`) - }) - }) - - apiRoutes( - alsoKnownAs( - defaults - .command('list') - .description('List every default environment that is set, account rung and per-repo rungs'), - 'ls', - ), - 'GET /v1/environments/defaults', - ) - .option('--json', 'output raw JSON') - .action(async (_opts: { json?: boolean }, cmd: Command) => { - await runAction(async () => { - const client = api() - const ladder = await client.environments.defaults.list() - if (cmd.optsWithGlobals().json) { - printJson(ladder) - return - } - const rungs: [string, string][] = [ - ...(ladder.account ? ([['account', ladder.account]] as [string, string][]) : []), - ...Object.entries(ladder.repositories), - ] - if (rungs.length === 0) { - console.log('No default environments set. Sessions get the basic sandbox.') - return - } - const names = new Map( - (await client.environments.list()).environments.map((e) => [e.id, e.name]), - ) - printTable( - ['RUNG', 'ENVIRONMENT', 'ENVIRONMENT ID'], - rungs.map(([rung, id]) => [rung, names.get(id) ?? id, id]), - ) - }) - }) - - apiRoutes( - defaults - .command('set ') - .description('Set the account default environment, or a repo default with --repo'), - 'PUT /v1/environments/defaults', - ) - .option( - '-r, --repo [repository]', - 'target a repo rung: "owner/name", or no value for the repo you are standing in', - ) - .option('--json', 'output raw JSON') - .action( - async ( - environmentId: string, - opts: { repo?: string | boolean; json?: boolean }, - cmd: Command, - ) => { - await runAction(async () => { - const repository = resolveRepoFlag(opts.repo) - const ladder = await api().environments.defaults.set({ - environment: environmentId, - ...(repository ? { repository } : {}), - }) - if (cmd.optsWithGlobals().json) { - printJson(ladder) - return - } - const rung = repository ? `default for ${repository}` : 'account default' - // Echo the id the ladder now holds for the rung we just wrote, so a - // name argument comes back resolved. Only that rung, never the - // fallback below it. - const set = effectiveEnvironmentDefault(ladder, repository ?? null) - const id = repository ? (set?.rung === 'repo' ? set.id : undefined) : ladder.account - console.log(`✓ set ${rung} to ${id ?? environmentId}`) - }) - }, - ) - - apiRoutes( - alsoKnownAs( - defaults - .command('clear') - .description('Clear the account default environment, or a repo default with --repo'), - 'rm', - 'delete', - ), - 'DELETE /v1/environments/defaults', - ) - .option( - '-r, --repo [repository]', - 'target a repo rung: "owner/name", or no value for the repo you are standing in', - ) - .action(async (opts: { repo?: string | boolean }) => { - await runAction(async () => { - const repository = resolveRepoFlag(opts.repo) - await api().environments.defaults.delete({ repository }) - console.log( - `✓ cleared ${repository ? `default environment for ${repository}` : 'account default environment'}`, - ) - }) - }) - environment .command('init [path]') .description( diff --git a/src/commands/help.ts b/src/commands/help.ts index f2d3aca..29c1741 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -4,8 +4,8 @@ import { api, APIError } from '../lib/api' import { apiRoutes } from '../lib/help' import { runAction } from '../lib/output' import { repoFromCwd } from '../lib/git' -import { startConnect } from './session' -import type { AgentConfig, StartAgentSessionRequest } from '../lib/types' +import { startConnect, startRequestFromConfig, withRepository } from './session' +import type { StartAgentSessionRequest } from '../lib/types' // The template behind `agent help --interactive`. Kebab-case like every other // slug in the registry; it is not served yet, so a 404 here is expected and @@ -66,13 +66,17 @@ export function resolveCommandPath(program: Command, path: string[]): Command | async function startHelperSession(): Promise { const template = await api().agents.templates.get(HELPER_TEMPLATE_SLUG) - const req: StartAgentSessionRequest = { - config: parseYaml(template.yaml) as AgentConfig, - } - // Same as `session start`: send the repo we're standing in so the helper can - // answer questions about this checkout. Ignored server-side if unknown. + const req: StartAgentSessionRequest = startRequestFromConfig( + parseYaml(template.yaml) as Record, + ) + // Same as `session start`: merge the repo we're standing in into the + // sandbox checkout set so the helper can answer questions about this + // checkout. A template whose environment is a name reference can't take the + // merge, so it is skipped there. const contextRepo = repoFromCwd(process.cwd()) - if (contextRepo) req.repository = contextRepo + if (contextRepo && typeof req.environment !== 'string') { + req.environment = withRepository(req.environment, contextRepo) + } // No prompt: the helper opens idle and waits for the question (a promptless // start is idle by definition since #6394). diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 2d0466d..73a6b8c 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -36,12 +36,10 @@ import { recordToItems } from '@ellipsis-dev/sdk/store' import { makeOpenSocket, resolveWsBase } from '../lib/stream' import type { Ellipsis, Session as FrameSession } from '@ellipsis-dev/sdk' import type { - AgentConfig, AgentSession, AgentSessionSource, AgentSessionStatus, GithubAccountSnippet, - ReplayAgentSessionRequest, SessionLogSegment, SessionRecord, SessionSearchResult, @@ -53,7 +51,16 @@ import { openBrowser } from '../lib/auth' import { registerConnect, runConnect } from './connect' import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch' import { formatStepLine, oneLine, recordText } from '../lib/steps' -import { sessionConfigName } from '../lib/sessions' +import { + parseRepo, + sessionConfigName, + startRequestFromConfig, + withRepository, +} from '../lib/sessions' + +// Re-exported for existing importers (help.ts starts the helper template +// through the same mapping). +export { startRequestFromConfig, withRepository } // Poll cadence for the `--watch` REST fallback (used only when live WebSocket // streaming is unavailable). Not user-configurable — the fallback is rare. @@ -88,7 +95,7 @@ export function registerSession(program: Command): void { ) .option( '-c, --config ', - 'start from a saved agent config (default: the resolved default config)', + "start from a saved agent config, by id or the agent's name (default: the bare ad-hoc config)", ) .option( '-f, --config-file ', @@ -100,11 +107,11 @@ export function registerSession(program: Command): void { ) .option( '-e, --environment ', - 'run in a saved environment, by id or name (only without -c/-f: an agent config decides its own environment)', + "run in a saved environment, by id or name (beats the agent config's own reference)", ) .option( '--override ', - 'partial patch (YAML/JSON) on the resolved session config, applied last, e.g. "budget:\\n session: 5"', + 'partial patch (YAML/JSON) of AgentConfig keys, deep-merged onto the base config, e.g. "budget:\\n session: 5"', ) .option( '--override-file ', @@ -213,46 +220,59 @@ export function registerSession(program: Command): void { if (opts.connect && opts.json) { throw new Error('--connect is interactive and cannot be combined with --json') } - const req: StartAgentSessionRequest = { - metadata: opts.metadata, + // The request body doubles as the config patch: every AgentConfig + // key on it deep-merges onto the base (`from_config_id`, or the + // bare ad-hoc config when none is named). + let req: StartAgentSessionRequest = {} + if (opts.config) req.from_config_id = opts.config + if (opts.configFile) { + req = { ...req, ...startRequestFromConfig(readConfigFile(opts.configFile)) } } - if (opts.config) req.config_id = opts.config - if (opts.configFile) req.config = readConfigFile(opts.configFile) as AgentConfig // Templates left the start request (#6394): resolve the slug to its // YAML via GET /v1/agents/templates/{slug} and start inline. if (opts.template) { const template = await api().agents.templates.get(opts.template) - req.config = parseYaml(template.yaml) as AgentConfig + req = { ...req, ...startRequestFromConfig(parseYaml(template.yaml)) } } - // The environment is only a session-level choice when no config - // decides it; the server 400s the combination, so pre-check locally - // for a clearer error. + // 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) + 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. if (opts.environment) { - if (opts.config || opts.configFile) { + if (isPlainObject(req.environment) && Object.keys(req.environment).length > 0) { throw new Error( - '--environment cannot be combined with --config/--config-file: the agent config decides its environment', + '--environment names a saved environment wholesale; it cannot be combined with environment overrides (--repo/--cpu/--memory/--timeout or an override\'s environment block)', ) } req.environment = opts.environment + } else { + // The repo we're standing in (origin remote), merged into the + // sandbox checkout set: an environment OBJECT deep-merges onto the + // resolved one and repositories merge by identity, so this only + // ever adds. Outside a git repo (or with no usable remote), and + // when a saved environment is named, nothing is added. + const contextRepo = repoFromCwd(process.cwd()) + if (contextRepo && typeof req.environment !== 'string') { + req.environment = withRepository(req.environment, contextRepo) + } } - // The repo we're standing in (origin remote), sent unconditionally — - // with no config source it picks the repo rung of the server's - // defaults ladder, and either way the server merges it into the - // sandbox checkout set. Outside a git repo (or with no usable - // remote) nothing is sent. - const contextRepo = repoFromCwd(process.cwd()) - if (contextRepo) req.repository = contextRepo - // Sugar flags (--model, --repo, --cpu, ...) and the raw - // --override are merged into one structured override, applied - // onto the chosen (or default) config and re-validated server-side. - const override = buildStartOverride(opts) - if (override) req.override = override // 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 - // Skip the image cache for the initial provision (wakes cache as - // usual); the fresh build's snapshot becomes the new cache entry. - if (opts.rebuild) req.force_rebuild = true + // Platform housekeeping rides the request's own `ellipsis` block: + // --rebuild skips the image cache for the initial provision (wakes + // cache as usual; the fresh build's snapshot refreshes the cache). + // Partial on the wire (the server defaults what is omitted); the + // generated type marks defaulted fields required, hence the cast. + const ellipsis: Record = {} + if (Object.keys(opts.metadata).length > 0) ellipsis.metadata = opts.metadata + if (opts.rebuild) ellipsis.force_rebuild = true + if (Object.keys(ellipsis).length > 0) { + req.ellipsis = ellipsis as StartAgentSessionRequest['ellipsis'] + } // A promptless start opens idle: no fabricated kickoff message, // Claude Code waits at the prompt like a local `claude` (the // server-side contract since #6394 — nothing extra to send). @@ -651,90 +671,6 @@ export function registerSession(program: Command): void { }) }) - apiRoutes( - session - .command('replay ') - .description("Re-run an existing session's trigger input as a fresh session"), - 'POST /v1/sessions/{id}/replay', - 'WS /v1/sessions/{id}/stream with --watch', - ) - .option( - '-c, --config ', - "run against a different saved agent config instead of the original session's snapshot", - ) - .option( - '--override ', - 'partial patch (YAML/JSON) on the replayed config, e.g. "claude:\\n model: claude-opus-4-8"', - ) - .option( - '--override-file ', - 'read the partial override from a file (.yaml/.yml or .json) instead of inline', - ) - .option( - '-p, --prompt ', - "the session prompt; omit to inherit the original session's prompt, pass '' to clear it", - ) - .option( - '-w, --watch', - 'block until the session reaches a terminal status, streaming live output', - ) - .option('--quiet', 'with --watch, wait without streaming: print only the final result') - .option('--json', 'output raw JSON') - .action( - async ( - sessionId: string, - opts: { - config?: string - override?: string - overrideFile?: string - prompt?: string - watch?: boolean - quiet?: boolean - json?: boolean - }, - ) => { - await runAction(async () => { - if (opts.quiet && !opts.watch) { - throw new Error('--quiet only applies with --watch') - } - const req: ReplayAgentSessionRequest = {} - if (opts.config) req.config_id = opts.config - applyConfigOverride(req, opts) - // Distinguish "flag omitted" (inherit the original prompt) from - // `--prompt ''` (clear it): only set the field when the flag was passed. - if (opts.prompt !== undefined) req.prompt = opts.prompt - - const client = api() - const { session } = await client.sessions.replay(sessionId, req) - - if (opts.watch) { - if (!opts.json) { - console.log(`✓ started replay ${session.id} (from ${sessionId})`) - await printSessionUrl(client, session.id) - } - if (opts.quiet) { - await watchSession(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) - } else { - await watchSessionStreaming( - client, - session.id, - FALLBACK_POLL_INTERVAL_SECONDS, - opts.json, - ) - } - return - } - if (opts.json) { - printJson(session) - return - } - console.log(`✓ started replay ${session.id} (${session.status}, from ${sessionId})`) - await printSessionUrl(client, session.id) - console.log(` follow with: agent session get ${session.id} --watch`) - }) - }, - ) - apiRoutes( session.command('stop ').description('Stop an in-flight session'), 'POST /v1/sessions/{id}/stop', @@ -938,36 +874,13 @@ async function printSessionUrl(client: Ellipsis, sessionId: string): Promise | null - }, - opts: { override?: string; overrideFile?: string }, -): void { - if (opts.override && opts.overrideFile) { - throw new Error('provide only one of --override / --override-file') - } - if (opts.overrideFile) { - req.override = readMappingFile(opts.overrideFile, 'override') - } else if (opts.override) { - const parsed = parseYaml(opts.override) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('--override must be a YAML/JSON mapping of fields to override') - } - req.override = parsed as Record - } -} - -// Build the single structured config override for `session start`. The raw +// Build the structured config patch for `session start`. The raw // --override / --override-file supplies the base mapping (any // field); the sugar flags (--model, --system, --repo, --cpu, --memory, // --timeout, --budget) are assembled into a partial config and deep-merged on // top, so an explicit flag wins over the same field in a raw override. Returns -// undefined when nothing was set (no override sent). The result is applied onto -// the chosen (or default) config and re-validated server-side. +// undefined when nothing was set. The result rides the request body itself, +// which the server deep-merges onto the base config and re-validates. export function buildStartOverride(opts: { override?: string overrideFile?: string @@ -1014,15 +927,6 @@ export function buildStartOverride(opts: { return Object.keys(merged).length ? merged : undefined } -// Parse a --repo value into an environment.repositories entry. "owner/name" sets -// both; a bare "name" omits owner so the server defaults it to the account. -function parseRepo(value: string): { name: string; owner?: string } { - const parts = value.split('/') - if (parts.length === 1 && parts[0]) return { name: parts[0] } - if (parts.length === 2 && parts[0] && parts[1]) return { owner: parts[0], name: parts[1] } - throw new Error(`--repo must be "name" or "owner/name", got "${value}"`) -} - function isPlainObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 6760dd3..9dff780 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -5,7 +5,6 @@ import { theme } from './theme' import type { AgentSession, AgentSessionSource, - EnvironmentDefaults, ListAgentSessionsQuery, ModelManufacturer, ModelRateCard, @@ -263,6 +262,74 @@ export function attentionFlip(prevWord: string | undefined, nextWord: string): b return nextWord === 'waiting' || nextWord === 'sleeping' || nextWord === 'idle' } +// --------------------------- start request shaping ------------------------- +// POST /v1/sessions takes the config patch as the request body itself: the +// base is `from_config_id` (or, omitted, the bare ad-hoc config) and every +// AgentConfig key on the request deep-merges on top. + +// Parse a repository value into an environment.repositories entry. +// "owner/name" sets both; a bare "name" omits owner so the server defaults it +// to the account. +export function parseRepo(value: string): { name: string; owner?: string } { + const parts = value.split('/') + if (parts.length === 1 && parts[0]) return { name: parts[0] } + if (parts.length === 2 && parts[0] && parts[1]) return { owner: parts[0], name: parts[1] } + throw new Error(`a repository must be "name" or "owner/name", got "${value}"`) +} + +// The AgentConfig keys POST /v1/sessions accepts as per-session patches. An +// inline config file's other keys have no request-side equivalent — `trigger` +// and `input` (the contract, not the payload) describe a saved agent, and the +// file's `ellipsis:` block carries a name/enabled the request refuses — so +// they are dropped rather than sent to a 422. +const START_CONFIG_KEYS = [ + 'budget', + 'claude', + 'codex', + 'environment', + 'output', + 'permissions', + 'skills', +] as const + +// An inline agent config (`session start -f/-t`) as a start request: its +// per-session keys, spread onto the request body. +export function startRequestFromConfig( + config: Record, +): StartAgentSessionRequest { + const req: Record = {} + for (const key of START_CONFIG_KEYS) { + if (config[key] !== undefined) req[key] = config[key] + } + return req as StartAgentSessionRequest +} + +// An environment override with `repo` ("owner/name" or a bare name) in its +// repositories, added only when absent. An environment OBJECT deep-merges onto +// the resolved one and repositories merge by identity, so this only ever adds +// a checkout. Bare names compare by name alone, the way the server resolves +// them. +export function withRepository( + environment: StartAgentSessionRequest['environment'], + repo: string, +): NonNullable { + const entry = parseRepo(repo) + const base: Record = + typeof environment === 'object' && environment !== null ? { ...environment } : {} + const repositories = Array.isArray(base.repositories) ? base.repositories : [] + const has = repositories.some( + (r: unknown) => + typeof r === 'object' && + r !== null && + (r as { name?: string }).name === entry.name && + (entry.owner === undefined || + (r as { owner?: string }).owner === undefined || + (r as { owner?: string }).owner === entry.owner), + ) + if (!has) base.repositories = [...repositories, entry] + return base as NonNullable +} + // --------------------------- new-session picker --------------------------- // One row of a composer picker. `group` and `rate` are what the model list @@ -675,20 +742,48 @@ export function environmentSectionCount(input: EnvironmentPaneInput, section: Pa // A closed section row's value: what of the section is in the run, on one // line — the checked names, or the fields holding a value. Empty when nothing // is, so the row reads "REPOSITORIES:" the way an unset field does. +// "N set" for the sections that summarize by count; empty when nothing is. +function setCount(n: number): string { + return n === 0 ? '' : `${n} set` +} + +// A memory value the way humans quote it — "16GB" prints as "16 GiB" (the +// sandbox allocates binary units); anything unparseable prints as written. +function memoryLabel(value: string): string { + const m = /^(\d+(?:\.\d+)?)\s*(gib|gb|mib|mb)$/i.exec(value.trim()) + if (!m) return value + return `${m[1]} ${m[2].toLowerCase().startsWith('g') ? 'GiB' : 'MiB'}` +} + export function environmentSectionSummary( pane: EnvironmentPaneState, section: PaneSection, ): string { + // Bare repo names: the owner is almost always the account itself, so + // repeating it on every entry buys width and no information. A pinned ref + // stays, since it changes what is cloned. if (section === 'repositories') return pane.repositories - .map((r) => (r.ref === null ? r.fullName : `${r.fullName}@${r.ref}`)) + .map((r) => { + const name = r.fullName.slice(r.fullName.indexOf('/') + 1) + return r.ref === null ? name : `${name}@${r.ref}` + }) .join(', ') if (section === 'mcpServers') return pane.mcpServers.map((s) => s.name).join(', ') - if (section === 'variables') return pane.variables.map((v) => v.name).join(', ') - if (section === 'image') return IMAGE_FIELDS.filter((f) => pane.image[f] !== '').join(', ') - if (section === 'hooks') return HOOK_FIELDS.filter((f) => pane.hooks[f] !== '').join(', ') + // Values are secrets and names are noise at a glance, so just how many. + if (section === 'variables') return setCount(pane.variables.length) + if (section === 'image') + return setCount(IMAGE_FIELDS.filter((f) => pane.image[f] !== '').length) + if (section === 'hooks') + return setCount(HOOK_FIELDS.filter((f) => pane.hooks[f] !== '').length) return COMPUTE_FIELDS.filter((f) => pane.compute[f] !== '') - .map((f) => `${f} ${pane.compute[f]}`) + .map((f) => + f === 'cpu' + ? `${pane.compute[f]} vCPU` + : f === 'memory' + ? memoryLabel(pane.compute[f]) + : `${f} ${pane.compute[f]}`, + ) .join(', ') } @@ -715,6 +810,17 @@ export const EMPTY_PANE: EnvironmentPaneState = { hooks: EMPTY_HOOKS, } +// The pane with `repo` ("owner/name") checked, added only when absent — how +// the resting basic-sandbox pane shows the checkout the entry point's base +// request merges in (see withRepository). +export function paneWithRepository( + pane: EnvironmentPaneState, + repo: string | null, +): EnvironmentPaneState { + if (!repo || pane.repositories.some((r) => r.fullName === repo)) return pane + return { ...pane, repositories: [...pane.repositories, { fullName: repo, ref: null }] } +} + // How an MCP server entry names itself, across the shapes the config admits: a // bare string opts into a built-in, an object carries its name. export function mcpServerName(server: unknown): string { @@ -878,11 +984,15 @@ export function repositoryRefLabel( // an override replaces the resolved one, so nothing the pane doesn't show can // reach the run. // -// `pane` null is the third case: nothing about the environment is stated at all, -// so the server's own ladder resolves it. That is what an untouched "Default" -// row means, and the honest thing to send when the environment list never -// loaded — an explicit empty pane there would wipe a default we never saw. +// `pane` null is the third case: nothing about the environment is stated +// beyond what the entry point's base request already says (the detected +// repository) — the right thing to send when the pane was never touched and +// an agent config's own environment should rule. +// +// `agent` is the saved config the session starts from (`from_config_id`, an +// id or the agent's name); null starts on the bare ad-hoc config. export interface ComposerChoices { + agent: string | null environment: string | null model: string | null pane: EnvironmentPaneState | null @@ -908,23 +1018,35 @@ function repositoryEntry(r: CustomRepository): { owner?: string; name: string; r return entry } -// The entry point's base request with the launcher's picks layered on. +// The entry point's base request with the launcher's picks layered on. The +// request body IS the config patch, so the picks land as its own keys. // -// A named environment is the session's own choice and ships alone: the pane -// still matches it, so re-stating its lists would only risk saying it worse. -// Without a name the run is custom, and every list in the override REPLACES the -// resolved one — which is exactly what the pane means. Its lists therefore ship -// unconditionally, empty included: an empty repositories array is how "no repos" -// is said, and omitting it would let the ladder resolve some. +// A named environment ships as the string, which re-picks it wholesale: the +// pane still matches it, so re-stating its lists would only risk saying it +// worse. Without a name the pane ships as the environment object, lists +// included even when empty — over the bare ad-hoc base that object is the +// whole sandbox, which is exactly what the pane means. (Over a picked agent's +// config the server merges the object instead — repositories by identity — so +// there an edited pane adds but cannot subtract; the honest fix is a saved +// environment, which does replace the config's reference.) export function applyComposerChoices( base: StartAgentSessionRequest, choices: ComposerChoices, ): StartAgentSessionRequest { const req: StartAgentSessionRequest = { ...base } - const override: Record = {} - if (choices.model) override.claude = { model: choices.model } - // Never combined with a config source: the launcher sends no config_id, so - // the environment is always the session's to name (the server 400s both). + if (choices.agent) { + req.from_config_id = choices.agent + // With an agent picked and the environment untouched, the config's own + // environment rules — including the base request's detected-repo merge + // would silently grow its checkout set, so it is dropped. (The pane shows + // the config's environment, and checking the repo there is one keystroke.) + if (!choices.environment && !choices.pane) delete req.environment + } + // Only the model: sending any sibling claude field would override the base + // config's own (system especially). + if (choices.model) { + req.claude = { model: choices.model } as StartAgentSessionRequest['claude'] + } if (choices.environment) { req.environment = choices.environment } else if (choices.pane) { @@ -940,9 +1062,8 @@ export function applyComposerChoices( if (Object.keys(image).length > 0) environment.image = image const hooks = fieldsOverride(pane.hooks) if (Object.keys(hooks).length > 0) environment.hooks = hooks - override.environment = environment + req.environment = environment as StartAgentSessionRequest['environment'] } - if (Object.keys(override).length > 0) req.override = override return req } @@ -964,22 +1085,6 @@ export function parseVariableEntry(input: string): CustomVariable | { error: str return { name, value: eq === -1 ? null : text.slice(eq + 1) } } -// Which rungs of the defaults ladder point at one environment: the account -// rung, and every repository rung. An environment can hold several at once, so -// this is a list — "account default, default for acme/api". -export function environmentDefaultRungs( - ladder: EnvironmentDefaults | null, - id: string, -): string[] { - if (!ladder) return [] - return [ - ...(ladder.account === id ? ['account default'] : []), - ...Object.entries(ladder.repositories) - .filter(([, envId]) => envId === id) - .map(([repo]) => `default for ${repo}`), - ] -} - // Where a synced environment's definition lives, for its option row: // "owner/name/path/to/file.yaml @ sha1234". Only what the API already gave us — // no source_details (an API-managed environment) means null, and the repo id @@ -999,54 +1104,34 @@ export function environmentSourceLabel( return `${repo}/${src.path}${sha}` } -// The Environment row's options: every saved environment, each labelled with -// the default rungs it holds and the file it syncs from, then the built-in -// "[empty]". `picked` is the row checked while the row is untouched — the -// environment the ladder resolves for the repo you are standing in, so the -// launcher can SEND what it shows instead of leaving the server to resolve -// something else. -// -// A "Default" row appears only when no rung resolves at all: then there is no -// name to show and the server's own resolution is the honest answer. +// The label of the resting null-id row: what an unnamed start resolves to — +// the built-in basic sandbox (with the detected repository merged into its +// checkout set, which the pane shows checked). +export const BASIC_ENVIRONMENT_LABEL = 'basic sandbox' + +// The leading row the Environment list grows when the picked agent's config +// carries its own environment: checked at rest (so the checkbox agrees with +// the row's "from agent config" value), re-pickable after choosing something +// else, and sending NOTHING on the wire — the config's environment rules. +export const AGENT_ENVIRONMENT_ID = 'agent:builtin' +export const AGENT_ENVIRONMENT_LABEL = 'from agent config' + +// The Environment row's options: the resting "basic sandbox" first, then +// every saved environment (each labelled with the file it syncs from), then +// the built-in "[empty]". Index 0 is the untouched pick. export function environmentOptions( environments: readonly { id: string; name: string }[], - ladder: EnvironmentDefaults | null, - detectedRepo: string | null, sourceLabels: ReadonlyMap = new Map(), -): { options: { id: string | null; label: string }[]; picked: number } { - const resolved = ladder ? effectiveEnvironmentDefault(ladder, detectedRepo)?.id : undefined - const listed = environments.map((e) => { - const notes = [...(sourceLabels.has(e.id) ? [sourceLabels.get(e.id) as string] : []), - ...environmentDefaultRungs(ladder, e.id)] - return { - id: e.id as string | null, - label: notes.length > 0 ? `${e.name} (${notes.join(', ')})` : e.name, - } - }) - const empty = { id: EMPTY_ENVIRONMENT_ID as string | null, label: EMPTY_ENVIRONMENT_LABEL } - const at = resolved ? environments.findIndex((e) => e.id === resolved) : -1 - if (at !== -1) return { options: [...listed, empty], picked: at } - return { - options: [{ id: null as string | null, label: 'Default' }, ...listed, empty], - picked: 0, - } -} - -// The environment a config-less session in `repo` resolves to: the repo rung of -// the defaults ladder, else the account rung, else null (the basic sandbox). -// Repo names compare case-insensitively, the way GitHub treats them. -export function effectiveEnvironmentDefault( - ladder: EnvironmentDefaults, - repo: string | null, -): { id: string; rung: 'repo' | 'account' } | null { - const repoRung = repo - ? Object.entries(ladder.repositories).find( - ([name]) => name.toLowerCase() === repo.toLowerCase(), - )?.[1] - : undefined - if (repoRung) return { id: repoRung, rung: 'repo' } - if (ladder.account) return { id: ladder.account, rung: 'account' } - return null +): { id: string | null; label: string }[] { + const listed = environments.map((e) => ({ + id: e.id as string | null, + label: sourceLabels.has(e.id) ? `${e.name} (${sourceLabels.get(e.id)})` : e.name, + })) + return [ + { id: null, label: BASIC_ENVIRONMENT_LABEL }, + ...listed, + { id: EMPTY_ENVIRONMENT_ID, label: EMPTY_ENVIRONMENT_LABEL }, + ] } // ------------------------------- layout --------------------------------- diff --git a/src/lib/types.ts b/src/lib/types.ts index 34166f6..c8a5a23 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -35,7 +35,6 @@ export type ListSessionRecordsResponse = S['SessionRecordsListResponse'] export type ListAgentSessionsResponse = S['SessionsListResponse'] export type StartAgentSessionRequest = NonNullable[0]> export type SessionResponse = S['SessionResponse'] -export type ReplayAgentSessionRequest = NonNullable[1]> export type SendSessionMessageRequest = S['SendSessionMessageRequest'] export type SessionLogSegment = S['SessionLogSegment'] export type GetSessionLogResponse = S['GetSessionLogResponse'] @@ -48,7 +47,7 @@ export type SessionSearchResult = S['SessionSearchResult'] export type SearchSessionsResponse = S['SessionSearchResponse'] export type GithubAccountSnippet = S['GithubAccountSnippet'] -// -------------------------- configs / defaults ---------------------------- +// ------------------------------- configs ----------------------------------- export type AgentConfig = S['AgentConfig'] export type SavedAgentConfig = S['Config'] @@ -57,15 +56,12 @@ export type CreateAgentConfigRequest = Parameters(null) - // The launcher's picker options, fetched once when it first shows: the saved - // environments (with the defaults ladder, so the untouched row can name the - // one the server would resolve) and the selectable models. A models failure - // (an older server without GET /models) leaves the list empty and the - // launcher falls back to its built-in set. + // The launcher's picker options, fetched once when it first shows: the + // saved agent configs, the saved environments, and the selectable models. A + // models failure (an older server without GET /models) leaves the list + // empty and the launcher falls back to its built-in set. + const [configs, setConfigs] = useState(null) const [environments, setEnvironments] = useState(null) - const [environmentDefaults, setEnvironmentDefaults] = useState(null) const [secretNames, setSecretNames] = useState(null) const [repos, setRepos] = useState(null) const [models, setModels] = useState(null) @@ -324,6 +326,13 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { useEffect(() => { if (mainPane.type !== 'launcher' || pickersLoading.current) return pickersLoading.current = true + void api.agents.configs + .list() + .then((r) => setConfigs(r.configs)) + .catch((err) => { + setConfigs([]) + reportApiError('agent configs', err) + }) void api.environments .list() .then((rows) => setEnvironments(rows.environments)) @@ -331,12 +340,6 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { setEnvironments([]) reportApiError('environments', err) }) - void api.environments.defaults - .list() - .then(setEnvironmentDefaults) - .catch((err) => { - reportApiError('default environments', err) - }) void api.secrets .list() .then((r) => setSecretNames(r.secrets.map((s) => s.name))) @@ -476,8 +479,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { starting={starting} error={startError ?? apiError} armed={armed} + configs={configs} environments={environments} - environmentDefaults={environmentDefaults} secretNames={secretNames} repos={repos} builtInServers={builtInServers} @@ -517,11 +520,12 @@ function EscOnlyInput({ // One row of the launcher's configuration block: a label + its value, opened // in place with →/enter. The two picker rows open an option list and pick one; // a section row opens its slice of the run's sandbox for editing. -type PickerKey = 'environment' | 'model' +type PickerKey = 'agent' | 'environment' | 'model' type LauncherRow = | { kind: 'picker'; key: PickerKey; label: string } | { kind: 'section'; key: PaneSection; label: string } const LAUNCHER_ROWS: readonly LauncherRow[] = [ + { kind: 'picker', key: 'agent', label: 'Agent' }, { kind: 'picker', key: 'environment', label: 'Environment' }, ...PANE_SECTIONS.map( (key): LauncherRow => ({ kind: 'section', key, label: PANE_SECTION_LABELS[key] }), @@ -529,6 +533,14 @@ const LAUNCHER_ROWS: readonly LauncherRow[] = [ { kind: 'picker', key: 'model', label: 'Model' }, ] +// The Agent row's resting pick: no saved config, the bare ad-hoc session. +const NO_AGENT_LABEL = 'none' + +// The environment.* section rows sit indented under ENVIRONMENT, mirroring +// where they land in the POST /v1/sessions body — the launcher IS the request +// body, so its rows nest the way the request's keys do. +const SECTION_INDENT = ' ' + // The prompt row's persistent label, upper-cased in render like every other // row's, and what its empty input says: enter is a real start. const PROMPT_LABEL = 'Prompt' @@ -587,14 +599,16 @@ type ScriptEditor = { // The launcher: one painted box holding everything about the next session — // the configuration rows on top, the prompt row last — with history below — // -// | ▶ ENVIRONMENT: backend-sandbox (account default) -// | REPOSITORIES: acme/api -// | MCP SERVERS: linear -// | VARIABLES: API_TOKEN -// | IMAGE: -// | HOOKS: -// | COMPUTE: cpu 4 +// | ▶ AGENT: none +// | ENVIRONMENT: backend-sandbox +// | REPOSITORIES: acme/api +// | MCP SERVERS: linear +// | VARIABLES: API_TOKEN +// | IMAGE: +// | HOOKS: +// | COMPUTE: cpu 4 // | MODEL: claude-opus-5 +// | // | PROMPT: Enter to start a cloud session... // // Recent sessions: @me in account @@ -609,11 +623,11 @@ type ScriptEditor = { // downward, so nothing swaps out and the session list stays. ↓ walks down into // the list, where enter opens a session. // -// The Environment and Model rows open an option list and pick one — +// The Agent, Environment and Model rows open an option list and pick one — // -// | ENVIRONMENT: backend-sandbox (account default) -// | ▶ [x] backend-sandbox (default for acme/api) -// | [ ] web-e2e (account default) +// | ENVIRONMENT: backend-sandbox +// | ▶ [x] backend-sandbox +// | [ ] web-e2e // | [ ] [empty] // // A section row opens its slice of the sandbox for editing — @@ -645,8 +659,11 @@ type ScriptEditor = { // The rows share one glyph gutter, so the ▶ moves down a single left edge; the // box's accent bar stays lit throughout, marking the block rather than any one // row. Typing anywhere outside an input row returns the cursor to the prompt. -// Agent configs stay a CLI choice (`agent session start -c`), since a config -// decides its own environment and the server refuses both at once. +// +// The Agent row picks a saved config as the run's base (`from_config_id`); +// picking one re-seeds the environment rows from that config's own +// environment, and the other picks patch on top of it — the same deep-merge +// `agent session start -c --model ...` speaks. function Launcher({ width, whoLine, @@ -654,8 +671,8 @@ function Launcher({ starting, error, armed, + configs, environments, - environmentDefaults, secretNames, repos, builtInServers, @@ -676,6 +693,7 @@ function Launcher({ error: string | null armed: boolean // null while loading; [] when the account has none / the fetch failed. + configs: SavedAgentConfig[] | null environments: SavedEnvironment[] | null models: SupportedModel[] | null // The account's stored variable names, checkable in the custom section. @@ -684,11 +702,8 @@ function Launcher({ repos: RepositorySummary[] | null // The built-in MCP server names available on this account. builtInServers: string[] - // The defaults ladder; null until it lands (or its fetch failed), which just - // leaves the untouched Environment row reading "Default". - environmentDefaults: EnvironmentDefaults | null - // The cwd's repo ("owner/name") — picks the repo rung of the ladder above, - // so it decides which environment starts out checked. + // The cwd's repo ("owner/name") — merged into an unnamed start's checkout + // set, so the resting basic-sandbox pane shows it checked. detectedRepo: string | null sessions: readonly AgentSession[] polledOnce: boolean @@ -701,8 +716,10 @@ function Launcher({ const [text, setText] = useState('') const [textCursor, setTextCursor] = useState(0) const [cursor, setCursor] = useState({ kind: 'prompt' }) - // null = the environment row is untouched, so it tracks whichever option the - // defaults ladder resolves (see environmentIdx). The list arrives async, so + // null = the agent row is untouched: no saved config, the bare ad-hoc run. + const [agentPick, setAgentPick] = useState(null) + // null = the environment row is untouched, so it tracks the picked agent's + // own environment (or the resting basic sandbox). The list arrives async, so // there is no index to seed this with at mount. const [environmentPick, setEnvironmentPick] = useState(null) // null = the model row is untouched, so it tracks whichever row carries the @@ -734,15 +751,31 @@ function Launcher({ // The open script field's editor, or null while every script row is collapsed. const [scriptEditor, setScriptEditor] = useState(null) - // Both pickers deal in the same option shape (ComposerModel), so the - // renderer can ask either of them for a group heading or a subtext; only the + // All three pickers deal in the same option shape (ComposerModel), so the + // renderer can ask any of them for a group heading or a subtext; only the // model list fills those in. // - // Every saved environment is listed, each tagged with the default rungs it - // holds ("(account default)", "(default for acme/api)"), and the one the - // ladder resolves for the cwd's repo starts out checked — so an untouched row - // SENDS the environment it shows rather than leaving the server to resolve - // something the row never named. + // The Agent row: "none" (the bare ad-hoc run) first, then every saved + // config, each by its agent name. The id is the saved config's row id, which + // `from_config_id` accepts directly. + const agentOptions = useMemo( + () => [ + { id: null, label: NO_AGENT_LABEL }, + ...(configs ?? []).map((c) => ({ + id: c.id as string | null, + label: c.agent_config.ellipsis.name ?? c.id, + })), + ], + [configs], + ) + const agentIdx = + agentPick !== null ? Math.min(agentPick, agentOptions.length - 1) : 0 + const pickedAgent = + agentIdx > 0 ? (configs ?? []).find((c) => c.id === agentOptions[agentIdx]?.id) : undefined + // The picked agent's own environment: a string names a saved environment + // (the row its id or name matches), an object is inline. Both seed what the + // untouched Environment row shows. + const agentEnvironment = pickedAgent?.agent_config.environment // Each synced environment's option row names the file it came from — only // when the API already gave us the pieces (source_details + the repo list). const environmentSources = useMemo(() => { @@ -754,34 +787,78 @@ function Launcher({ } return labels }, [environments, repos]) - const { options: environmentOptionList, picked: resolvedIdx } = useMemo( - () => - environmentOptions(environments ?? [], environmentDefaults, detectedRepo, environmentSources), - [environments, environmentDefaults, detectedRepo, environmentSources], + const environmentOptionList = useMemo( + () => environmentOptions(environments ?? [], environmentSources), + [environments, environmentSources], ) - // The saved environments plus the built-in [empty]. The `custom` row the list - // grows once the pane diverges is NOT here: it names no environment, so it - // would have nothing to seed the pane from (see environmentRowsWithCustom). + // Which environment row the picked agent's own reference lands on; null for + // an inline environment object (nothing saved to point at) or no agent. + const agentEnvironmentIdx = useMemo(() => { + if (typeof agentEnvironment !== 'string') return null + const at = (environments ?? []).findIndex( + (e) => e.id === agentEnvironment || e.name === agentEnvironment, + ) + return at === -1 ? null : at + 1 // +1: the list leads with "basic sandbox" + }, [agentEnvironment, environments]) + // An agent whose config carries its own INLINE environment grows a leading + // "from agent config" row: the resting check has to sit on what the run + // would actually use, and the row is the way back after picking something + // else. An agent that REFERENCES a saved environment doesn't need it — the + // referenced row itself takes the resting check. + const hasAgentEnvironmentRow = pickedAgent !== undefined && agentEnvironmentIdx === null + // The saved environments between the resting "basic sandbox" and the + // built-in [empty]. The `custom` row the list grows once the pane diverges + // is NOT here: it names no environment, so it would have nothing to seed the + // pane from (see environmentRowsWithCustom). const environmentOptionRows = useMemo( - () => environmentOptionList.map((o) => ({ id: o.id, label: o.label })), - [environmentOptionList], + () => [ + ...(hasAgentEnvironmentRow + ? [{ id: AGENT_ENVIRONMENT_ID as string | null, label: AGENT_ENVIRONMENT_LABEL }] + : []), + ...environmentOptionList.map((o) => ({ id: o.id, label: o.label })), + ], + [environmentOptionList, hasAgentEnvironmentRow], ) const environmentIdx = environmentPick !== null ? Math.min(environmentPick, environmentOptionRows.length - 1) - : resolvedIdx + : hasAgentEnvironmentRow + ? 0 + : (agentEnvironmentIdx ?? 0) const pickedEnvironment = environmentOptionRows[environmentIdx] // What the picked environment resolves to, as pane rows. This is what the pane // shows until it is edited, and what "custom" is measured against. const connectedRepoNames = useMemo(() => (repos ?? []).map((r) => r.full_name), [repos]) const seededPane = useMemo(() => { const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_PANE - return environmentPane( - (environments ?? []).find((e) => e.id === id)?.environment, - connectedRepoNames, - ) - }, [environments, pickedEnvironment, connectedRepoNames]) + if (id === EMPTY_ENVIRONMENT_ID) return EMPTY_PANE + // The resting "from agent config" row: the picked agent's own inline + // environment is what the run would use, so it is what the pane reads. + if (id === AGENT_ENVIRONMENT_ID) { + return typeof agentEnvironment === 'object' && agentEnvironment !== null + ? environmentPane(agentEnvironment, connectedRepoNames) + : EMPTY_PANE + } + if (id) { + return environmentPane( + (environments ?? []).find((e) => e.id === id)?.environment, + connectedRepoNames, + ) + } + // The null-id "basic sandbox" row. Under an agent this is an EXPLICIT + // pick (the resting state is the agent row above), so it reads empty. + // Without an agent the detected repo shows checked, since the base + // request merges it into the checkout set (see applyComposerChoices). + if (pickedAgent) return EMPTY_PANE + return paneWithRepository(EMPTY_PANE, detectedRepo) + }, [ + environments, + pickedEnvironment, + connectedRepoNames, + pickedAgent, + agentEnvironment, + detectedRepo, + ]) // The pane as the run would use it: the edits if there are any, else the // picked environment's own values. const shownPane = pane ?? seededPane @@ -810,22 +887,43 @@ function Launcher({ [environmentOptionRows, isCustom], ) const optionsFor = (key: PickerKey) => - key === 'environment' ? environmentRowsWithCustom : modelOptions + key === 'agent' + ? agentOptions + : key === 'environment' + ? environmentRowsWithCustom + : modelOptions const pickedIdx = (key: PickerKey): number => - key === 'environment' ? (isCustom ? environmentOptionRows.length : environmentIdx) : modelIdx + key === 'agent' + ? agentIdx + : key === 'environment' + ? isCustom + ? environmentOptionRows.length + : environmentIdx + : modelIdx const isPicked = (key: PickerKey, at: number): boolean => at === Math.min(pickedIdx(key), optionsFor(key).length - 1) + // Checking an agent re-seeds the environment rows from that config's own + // environment, so the pane reads what the run would actually use. + const pickAgent = (at: number): void => { + setAgentPick(at) + setEnvironmentPick(null) + setPane(null) + } // Checking an environment drops the pane's edits, since the pane is a reading of // whatever environment is checked. The `custom` row names no environment, so // landing on it keeps the edits it stands for. const pickEnvironment = (at: number): void => { - if (optionsFor('environment')[at]?.id === CUSTOM_ENVIRONMENT_ID) return - setEnvironmentPick(at) + const id = optionsFor('environment')[at]?.id + if (id === CUSTOM_ENVIRONMENT_ID) return + // The leading "from agent config" row IS the resting state: un-pick, so + // the config's own environment rules again. + setEnvironmentPick(id === AGENT_ENVIRONMENT_ID ? null : at) setPane(null) } - // Both picker rows single-pick, so activating an option closes the dropdown. + // Every picker row single-picks, so activating an option closes the dropdown. const activate = (key: PickerKey, at: number): void => { - if (key === 'environment') pickEnvironment(at) + if (key === 'agent') pickAgent(at) + else if (key === 'environment') pickEnvironment(at) else setModelPick(at) setOpenPicker(null) } @@ -963,14 +1061,23 @@ function Launcher({ // // A checked environment ships by name and the pane stays home; once the pane // has diverged (or [empty] is checked, which is the pane emptied) it ships - // instead, and an untouched "Default" row ships neither — the server's own - // ladder is what that row names. + // instead, and the untouched resting row ships neither — the entry point's + // base request (or the picked agent's own config) is what that row shows. const submit = (): void => { const named = !isCustom && pickedEnvironment?.id !== EMPTY_ENVIRONMENT_ID + // The resting "from agent config" row ships NOTHING: no environment name, + // no pane — the config's own environment rules (applyComposerChoices + // drops even the base request's detected-repo merge under an agent). + const restingOnAgent = pickedEnvironment?.id === AGENT_ENVIRONMENT_ID onSubmit(text.trim(), { - environment: named ? (pickedEnvironment?.id ?? null) : null, + agent: agentOptions[agentIdx]?.id ?? null, + environment: + named && !restingOnAgent ? (pickedEnvironment?.id ?? null) : null, model: modelOptions[modelIdx]?.id ?? null, - pane: named && pickedEnvironment?.id === null ? null : shownPane, + pane: + named && (pickedEnvironment?.id === null || restingOnAgent) + ? null + : shownPane, }) } @@ -1379,7 +1486,12 @@ function Launcher({ editor === null && serverEditor === null && scriptEditor === null - const glyph = {hovered ? SELECTION_GLYPH : ' '} + const glyph = ( + + {SECTION_INDENT} + {hovered ? SELECTION_GLYPH : ' '} + + ) if (paneRow.kind === 'repo') { const checked = repositoryFor(paneRow.fullName) !== undefined return ( @@ -1481,6 +1593,7 @@ function Launcher({ return ( + {SECTION_INDENT} {' '} {here ? ( @@ -1500,6 +1613,7 @@ function Launcher({ {hidden > 0 && ( + {SECTION_INDENT} {` … ${hidden} more line${hidden === 1 ? '' : 's'}`} @@ -1563,6 +1677,7 @@ function Launcher({ return ( + {SECTION_INDENT} {here ? SELECTION_GLYPH : ' '}{' '} {/* Aligned under the button text, past its "+ ". */} {` ${field}: `} @@ -1586,6 +1701,7 @@ function Launcher({ {serverEditor.error !== null && ( + {SECTION_INDENT} {' '} {serverEditor.error} @@ -1617,6 +1733,7 @@ function Launcher({ return ( + {SECTION_INDENT} {here ? SELECTION_GLYPH : ' '}{' '} {` ${field}: `} {typed} @@ -1639,6 +1756,7 @@ function Launcher({ {editor.error !== null && ( + {SECTION_INDENT} {' '} {editor.error} @@ -1695,7 +1813,9 @@ function Launcher({ {/* Upper-cased, so the whole configuration block reads with - one kind of label. */} + one kind of label; environment.* sections indent under + the ENVIRONMENT row they belong to. */} + {r.kind === 'section' ? SECTION_INDENT : ''} {r.label.toUpperCase()}: {rowValue(r)} @@ -1756,8 +1876,10 @@ function Launcher({ ) })} - {/* The prompt row, right under MODEL in the same glyph gutter, so every - row reads down one left edge and the ▶ moves between them. */} + {/* One blank row between the config rows and the prompt, so the input + reads as its own thing; same glyph gutter, so every row still reads + down one left edge and the ▶ moves between them. */} + diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 5eeb700..626e1fd 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -6,6 +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 { withRepository } from '../lib/sessions' import { SessionsApp } from './SessionsApp' // Launches the multi-session UI (sidebar + chat) — the shared destination of @@ -33,8 +34,8 @@ export function canHostSessionsUi(): boolean { // The composer-spawned session's start request when the entry point brings // no flags of its own (connect, and the composer inside a prompt-shorthand -// UI): default-config resolution with the detected repository, exactly like -// a bare `agent` start. +// UI): the bare ad-hoc config with the detected repository merged into the +// sandbox checkout set, exactly like a bare `agent` start. // // An empty prompt starts the session idle instead: the sandbox spins up and // Claude Code waits at its prompt, so the first composer message opens turn 0 @@ -45,7 +46,7 @@ export function defaultStartRequest(prompt: string): StartAgentSessionRequest { const req: StartAgentSessionRequest = {} if (prompt) req.prompt = prompt const contextRepo = repoFromCwd(process.cwd()) - if (contextRepo) req.repository = contextRepo + if (contextRepo) req.environment = withRepository(undefined, contextRepo) return req } diff --git a/test/session.test.ts b/test/session.test.ts index 6e6e198..4d96fe9 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - applyConfigOverride, buildStartOverride, fetchLogSegment, readConfigFile, @@ -146,54 +145,6 @@ describe('readConfigFile', () => { }) }) -describe('applyConfigOverride', () => { - const dir = mkdtempSync(join(tmpdir(), 'agent-override-')) - const write = (name: string, body: string): string => { - const path = join(dir, name) - writeFileSync(path, body) - return path - } - - it('passes an inline override through as the YAML/JSON string', () => { - const req: { override?: Record } = {} - applyConfigOverride(req, { override: 'claude:\n model: claude-opus-4-8' }) - expect(req).toEqual({ override: { claude: { model: 'claude-opus-4-8' } } }) - }) - - it('reads and parses a file override into the structured mapping', () => { - const path = write('override.yaml', 'budget:\n session: 5\n') - const req: { override?: Record } = {} - applyConfigOverride(req, { overrideFile: path }) - expect(req).toEqual({ override: { budget: { session: 5 } } }) - }) - - it('rejects passing both inline and file forms', () => { - const path = write('both.yaml', 'enabled: false\n') - expect(() => - applyConfigOverride({}, { override: 'enabled: false', overrideFile: path }), - ).toThrow(/only one of --override \/ --override-file/) - }) - - it('is a no-op when neither form is given', () => { - const req: { override?: Record } = {} - applyConfigOverride(req, {}) - expect(req).toEqual({}) - }) - - it('surfaces an override-specific error when the file is missing', () => { - expect(() => applyConfigOverride({}, { overrideFile: join(dir, 'nope.yaml') })).toThrow( - /could not read override file/, - ) - }) - - it('surfaces an override-specific error for a non-mapping file', () => { - const path = write('list.yaml', '- a\n- b\n') - expect(() => applyConfigOverride({}, { overrideFile: path })).toThrow( - /could not parse YAML override file/, - ) - }) -}) - describe('buildStartOverride', () => { const dir = mkdtempSync(join(tmpdir(), 'agent-start-override-')) const write = (name: string, body: string): string => { @@ -260,7 +211,7 @@ describe('buildStartOverride', () => { }) it('rejects a malformed --repo value', () => { - expect(() => buildStartOverride({ repo: ['a/b/c'] })).toThrow(/--repo must be/) + expect(() => buildStartOverride({ repo: ['a/b/c'] })).toThrow(/must be "name" or "owner\/name"/) }) }) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index e1432d3..5099104 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -9,7 +9,6 @@ import { composerModelOptions, composerPickerRows, connectability, - effectiveEnvironmentDefault, environmentOptions, environmentPane, environmentSectionAt, @@ -26,7 +25,11 @@ import { mcpServerName, oneLine, paneEquals, + paneWithRepository, + parseRepo, resolveRepoFullName, + startRequestFromConfig, + withRepository, scriptRowLines, parseVariableEntry, repositoryRefLabel, @@ -562,32 +565,37 @@ describe('composerPickerRows', () => { }) describe('applyComposerChoices', () => { - // Nothing stated about the environment: no name, and a null pane, so the - // server's own ladder resolves it. - const untouched = { environment: null, model: null, pane: null } + // Nothing picked: no agent, no environment name, a null pane — the base + // request (and, with an agent, its config) is the whole message. + const untouched = { agent: null, environment: null, model: null, pane: null } it('leaves the base request alone when nothing was picked', () => { - expect(applyComposerChoices({ prompt: 'hi', repository: 'acme/api' }, untouched)).toEqual({ + const base = { prompt: 'hi', - repository: 'acme/api', - }) + environment: { repositories: [{ owner: 'acme', name: 'api' }] }, + } + expect(applyComposerChoices(base, untouched)).toEqual(base) }) - it('keeps the context repo, which picks the repo rung of the defaults ladder', () => { + it('keeps the context repo and patches the model as a claude block', () => { const req = applyComposerChoices( - { repository: 'acme/api' }, + { environment: { repositories: [{ owner: 'acme', name: 'api' }] } }, { ...untouched, model: 'claude-opus-5' }, ) - expect(req.override).toEqual({ claude: { model: 'claude-opus-5' } }) - expect(req.repository).toBe('acme/api') + expect(req.claude).toEqual({ model: 'claude-opus-5' }) + expect(req.environment).toEqual({ repositories: [{ owner: 'acme', name: 'api' }] }) }) - // A named environment ships alone: the pane still matches it, so re-stating - // its lists could only say them worse. - it('names a chosen environment on the request, not in the override', () => { + // A named environment ships as the string, which re-picks it wholesale: the + // pane still matches it, so re-stating its lists could only say them worse. + it('names a chosen environment on the request, over the pane', () => { const req = applyComposerChoices( { prompt: 'ship it' }, - { ...untouched, environment: 'env_1', pane: { ...EMPTY_PANE, variables: [{ name: 'A', value: '1' }] } }, + { + ...untouched, + environment: 'env_1', + pane: { ...EMPTY_PANE, variables: [{ name: 'A', value: '1' }] }, + }, ) expect(req).toEqual({ prompt: 'ship it', environment: 'env_1' }) }) @@ -600,26 +608,44 @@ describe('applyComposerChoices', () => { expect(req).toEqual({ prompt: 'ship it', environment: 'env_1', - override: { claude: { model: 'claude-fable-5' } }, + claude: { model: 'claude-fable-5' }, }) }) - // The launcher never sends a config source, so the server can't 400 the - // config + environment combination. - it('never sends a config id', () => { - const req = applyComposerChoices({}, { ...untouched, environment: 'env_1' }) - expect(req).not.toHaveProperty('config_id') + it('starts from a picked agent config', () => { + expect(applyComposerChoices({}, { ...untouched, agent: 'cfg_1' })).toEqual({ + from_config_id: 'cfg_1', + }) + }) + + // With an agent picked and the environment rows untouched, the config's own + // environment rules: keeping the base request's detected-repo merge would + // silently grow the config's checkout set. + it('drops the base environment when an agent is picked and the rows are untouched', () => { + const req = applyComposerChoices( + { environment: { repositories: [{ owner: 'acme', name: 'api' }] } }, + { ...untouched, agent: 'cfg_1' }, + ) + expect(req).toEqual({ from_config_id: 'cfg_1' }) + }) + + it('keeps a named environment (or the pane) alongside a picked agent', () => { + expect( + applyComposerChoices({}, { ...untouched, agent: 'cfg_1', environment: 'env_1' }), + ).toEqual({ from_config_id: 'cfg_1', environment: 'env_1' }) + expect( + applyComposerChoices({}, { ...untouched, agent: 'cfg_1', pane: EMPTY_PANE }).environment, + ).toEqual({ repositories: [], variables: [], mcp_servers: [] }) }) it('does not mutate the request it was given', () => { - const base = { repository: 'acme/api' } + const base = { prompt: 'hi' } applyComposerChoices(base, { ...untouched, environment: 'env_1' }) - expect(base).toEqual({ repository: 'acme/api' }) + expect(base).toEqual({ prompt: 'hi' }) }) - // The whole point of the pane: without an environment name it IS the sandbox, - // and every list in an override replaces the resolved one — so all three ship - // together, empty included, or the ladder would fill the gaps back in. + // The whole point of the pane: without an environment name it IS the + // sandbox, shipped as the request's own environment object. it('ships the whole pane when no environment is named', () => { const req = applyComposerChoices( {}, @@ -634,23 +660,23 @@ describe('applyComposerChoices', () => { }, ) expect(req).toEqual({ - override: { - environment: { - repositories: [{ owner: 'acme', name: 'api', ref: 'main' }], - variables: [{ name: 'NODE_ENV', value: 'production' }], - mcp_servers: [{ name: 'linear' }], - }, + environment: { + repositories: [{ owner: 'acme', name: 'api', ref: 'main' }], + variables: [{ name: 'NODE_ENV', value: 'production' }], + mcp_servers: [{ name: 'linear' }], }, }) }) - // The [empty] pick is the pane emptied, and empty arrays are how "nothing" is - // said: omitting them would let the ladder resolve an environment instead. + // The [empty] pick is the pane emptied, and empty arrays are how "nothing" + // is said over the bare ad-hoc base. it('clears every list for an empty pane', () => { - const req = applyComposerChoices({ repository: 'acme/api' }, { ...untouched, pane: EMPTY_PANE }) + const req = applyComposerChoices( + { environment: { repositories: [{ owner: 'acme', name: 'api' }] } }, + { ...untouched, pane: EMPTY_PANE }, + ) expect(req).toEqual({ - repository: 'acme/api', - override: { environment: { repositories: [], variables: [], mcp_servers: [] } }, + environment: { repositories: [], variables: [], mcp_servers: [] }, }) }) @@ -659,17 +685,17 @@ describe('applyComposerChoices', () => { {}, { ...untouched, pane: { ...EMPTY_PANE, variables: [{ name: 'API_TOKEN', value: null }] } }, ) - expect(req.override?.environment).toMatchObject({ variables: [{ name: 'API_TOKEN' }] }) + expect(req.environment).toMatchObject({ variables: [{ name: 'API_TOKEN' }] }) }) - // Compute, image and hooks are scalars in merging object overrides, so only - // the set fields ship — an unset one keeps whatever the server resolves. + // Compute, image and hooks are scalars in a merging object, so only the set + // fields ship — an unset one keeps whatever the server resolves. it('sends only the set compute fields', () => { const req = applyComposerChoices( {}, { ...untouched, pane: { ...EMPTY_PANE, compute: { cpu: '4', memory: '16GB', timeout: '' } } }, ) - expect(req.override?.environment).toMatchObject({ compute: { cpu: 4, memory: '16GB' } }) + expect(req.environment).toMatchObject({ compute: { cpu: 4, memory: '16GB' } }) }) it('sends only the set image fields', () => { @@ -677,7 +703,7 @@ describe('applyComposerChoices', () => { {}, { ...untouched, pane: { ...EMPTY_PANE, image: { dockerfile_append: '', setup: 'npm install' } } }, ) - expect(req.override?.environment).toMatchObject({ image: { setup: 'npm install' } }) + expect(req.environment).toMatchObject({ image: { setup: 'npm install' } }) }) it('sends only the set hook fields', () => { @@ -685,7 +711,7 @@ describe('applyComposerChoices', () => { {}, { ...untouched, pane: { ...EMPTY_PANE, hooks: { post_start: 'doppler setup', post_clone: '' } } }, ) - expect(req.override?.environment).toMatchObject({ hooks: { post_start: 'doppler setup' } }) + expect(req.environment).toMatchObject({ hooks: { post_start: 'doppler setup' } }) }) // A server the pane was seeded with keeps its own entry, so a definition the @@ -705,7 +731,7 @@ describe('applyComposerChoices', () => { }, }, ) - expect(req.override?.environment).toMatchObject({ + expect(req.environment).toMatchObject({ mcp_servers: [seeded, { name: 'docs', url: 'https://mcp.example.com' }], }) }) @@ -1075,18 +1101,19 @@ describe('environmentSectionSummary', () => { ], variables: [{ name: 'API_TOKEN', value: null }], mcpServers: [{ name: 'linear', command: null, url: null }], - compute: { cpu: '4', memory: '', timeout: '' }, + compute: { cpu: '4', memory: '16GB', timeout: '' }, image: { dockerfile_append: 'RUN true', setup: '' }, } - // What of the section is in the run, on one line — a pinned ref rides its - // repo's name. - it('reads out the checked names and the fields holding a value', () => { - expect(environmentSectionSummary(pane, 'repositories')).toBe('acme/api, acme/web@dev') + // What of the section is in the run, on one line: repos by bare name (a + // pinned ref rides along), variables/image/hooks as counts, compute the way + // humans quote machines. + it('summarizes each section for its collapsed row', () => { + expect(environmentSectionSummary(pane, 'repositories')).toBe('api, web@dev') expect(environmentSectionSummary(pane, 'mcpServers')).toBe('linear') - expect(environmentSectionSummary(pane, 'variables')).toBe('API_TOKEN') - expect(environmentSectionSummary(pane, 'image')).toBe('dockerfile_append') - expect(environmentSectionSummary(pane, 'compute')).toBe('cpu 4') + expect(environmentSectionSummary(pane, 'variables')).toBe('1 set') + expect(environmentSectionSummary(pane, 'image')).toBe('1 set') + expect(environmentSectionSummary(pane, 'compute')).toBe('4 vCPU, 16 GiB') }) it('is empty when the section holds nothing', () => { @@ -1101,58 +1128,26 @@ describe('environmentOptions', () => { { id: 'env_2', name: 'web-e2e' }, ] - // Every rung the environment holds is named on its row, so the list says why - // one of them is checked. - it('tags each environment with the default rungs it holds', () => { - const { options, picked } = environmentOptions( - environments, - { account: 'env_1', repositories: { 'acme/api': 'env_2' } }, - 'acme/api', - ) + // The resting basic sandbox leads (index 0 is the untouched pick), the + // built-in [empty] closes the list. + it('leads with the basic sandbox and ends with [empty]', () => { + const options = environmentOptions(environments) expect(options.map((o) => o.label)).toEqual([ - 'backend (account default)', - 'web-e2e (default for acme/api)', + 'basic sandbox', + 'backend', + 'web-e2e', '[empty]', ]) - // The repo rung wins for the repo we are standing in. - expect(picked).toBe(1) + expect(options[0].id).toBeNull() }) - it('checks the account rung outside a repo with a default', () => { - const { options, picked } = environmentOptions( + // A synced environment's row explains where its definition lives. + it('names the source file a synced environment came from', () => { + const options = environmentOptions( environments, - { account: 'env_1', repositories: {} }, - null, - ) - expect(options[picked].id).toBe('env_1') - }) - - // Only then is there no name to show, so the server's own resolution stands. - it('adds a Default row when no rung resolves', () => { - const { options, picked } = environmentOptions( - environments, - { account: null, repositories: {} }, - 'acme/api', - ) - expect(options[0]).toEqual({ id: null, label: 'Default' }) - expect(picked).toBe(0) - }) - - it('adds the Default row while the ladder has not landed', () => { - const { options, picked } = environmentOptions(environments, null, 'acme/api') - expect(options[picked]).toEqual({ id: null, label: 'Default' }) - }) - - // A synced environment's row explains where its definition lives, ahead of - // the default rungs it holds. - it('names the source file before the default rungs', () => { - const { options } = environmentOptions( - environments, - { account: 'env_1', repositories: {} }, - null, new Map([['env_1', 'acme/api/e.yaml @ abcdef1']]), ) - expect(options[0].label).toBe('backend (acme/api/e.yaml @ abcdef1, account default)') + expect(options[1].label).toBe('backend (acme/api/e.yaml @ abcdef1)') }) }) @@ -1214,28 +1209,58 @@ describe('environmentSourceLabel', () => { }) }) -describe('effectiveEnvironmentDefault', () => { - const ladder = { account: 'env_account', repositories: { 'Acme/API': 'env_repo' } } +describe('start request shaping', () => { + it('parses a repository value into an environment entry', () => { + expect(parseRepo('acme/api')).toEqual({ owner: 'acme', name: 'api' }) + expect(parseRepo('api')).toEqual({ name: 'api' }) + expect(() => parseRepo('a/b/c')).toThrow(/must be "name" or "owner\/name"/) + }) - it('prefers the repo rung over the account rung', () => { - expect(effectiveEnvironmentDefault(ladder, 'acme/api')).toEqual({ - id: 'env_repo', - rung: 'repo', + // Only the keys POST /v1/sessions accepts as per-session patches; a file's + // trigger/input/ellipsis blocks describe a saved agent, not a run. + it('maps an inline config onto the start request keys', () => { + expect( + startRequestFromConfig({ + ellipsis: { name: 'my-agent' }, + claude: { system: 'do it', model: 'claude-opus-5' }, + environment: { repositories: [{ name: 'api' }] }, + budget: { session: 5 }, + trigger: { type: 'cron', schedule: '* * * * *' }, + input: { json_schema: {} }, + }), + ).toEqual({ + claude: { system: 'do it', model: 'claude-opus-5' }, + environment: { repositories: [{ name: 'api' }] }, + budget: { session: 5 }, }) }) - it('falls back to the account rung for another repo, and outside a repo', () => { - expect(effectiveEnvironmentDefault(ladder, 'acme/web')).toEqual({ - id: 'env_account', - rung: 'account', + it('adds the detected repo to an environment override only when absent', () => { + expect(withRepository(undefined, 'acme/api')).toEqual({ + repositories: [{ owner: 'acme', name: 'api' }], }) - expect(effectiveEnvironmentDefault(ladder, null)).toEqual({ - id: 'env_account', - rung: 'account', + expect( + withRepository({ repositories: [{ owner: 'acme', name: 'api' }] }, 'acme/api'), + ).toEqual({ repositories: [{ owner: 'acme', name: 'api' }] }) + // A bare-name entry counts as the same repository. + expect(withRepository({ repositories: [{ name: 'api' }] }, 'acme/api')).toEqual({ + repositories: [{ name: 'api' }], }) }) - it('resolves nothing when no rung is set (the basic sandbox)', () => { - expect(effectiveEnvironmentDefault({ account: null, repositories: {} }, 'acme/api')).toBeNull() + it('leaves the other keys of an environment override in place', () => { + expect(withRepository({ variables: [{ name: 'A' }] }, 'api')).toEqual({ + variables: [{ name: 'A' }], + repositories: [{ name: 'api' }], + }) + }) +}) + +describe('paneWithRepository', () => { + it('checks the repo once, and does nothing without one', () => { + const pane = paneWithRepository(EMPTY_PANE, 'acme/api') + expect(pane.repositories).toEqual([{ fullName: 'acme/api', ref: null }]) + expect(paneWithRepository(pane, 'acme/api')).toBe(pane) + expect(paneWithRepository(pane, null)).toBe(pane) }) })