diff --git a/bun.lock b/bun.lock index 480cb52..74b3332 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.6.0", + "@ellipsis-dev/sdk": "^0.8.1", "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.6.0", "", {}, "sha512-FEZVXzM+TJ7YrDnJaQu4G+cxpX/49wFbtQelldB/FowX7uwi9X13rY0Crv6Oxox+uJiise7TeK9AIAyfZbYv0Q=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.8.1", "", {}, "sha512-ZMpg+IXWgTSbsi1JPG82M7O1WktIDTR577yrQ+CM94ZKV0zgUTnziVBGP4PGqlqyGcscOImfj5ydeOUlZjBUew=="], "@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 ddece9f..6236daf 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.6.0", + "@ellipsis-dev/sdk": "^0.8.1", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index c56f5ab..a513c1a 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander' import { InvalidArgumentError } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { collect, parseWhen, toInt } from '../lib/args' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -109,7 +109,7 @@ export function registerAnalytics(program: Command): void { json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().getAnalyticsMetrics({ + const res = await api().analytics.metrics({ ...windowQuery(opts), repo: opts.repo.length > 0 ? opts.repo : undefined, account_type: opts.accountType, @@ -179,7 +179,7 @@ export function registerAnalytics(program: Command): void { : opts.accountType === 'bot' ? ['Bot'] : undefined - const res = await new ApiClient().getAnalyticsPullRequests({ + const res = await api().analytics.pullRequests({ ...windowQuery(opts), account_type: accountTypes, status: opts.status.length > 0 ? opts.status : undefined, @@ -249,7 +249,7 @@ export function registerAnalytics(program: Command): void { json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().getAnalyticsReviews({ + const res = await api().analytics.reviews({ ...windowQuery(opts), repo: opts.repo.length > 0 ? opts.repo : undefined, author: opts.author.length > 0 ? opts.author : undefined, diff --git a/src/commands/config.ts b/src/commands/config.ts index dadd318..1fad4d3 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,7 +1,7 @@ import { type Command } from 'commander' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { basename, dirname, extname } from 'node:path' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { resolveAppBase } from '../lib/config' import { alsoKnownAs, apiRoutes } from '../lib/help' import { repoFromCwd } from '../lib/laptop' @@ -9,6 +9,7 @@ import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/ou import { configUrl } from '../lib/urls' import { readConfigFile } from './session' import type { + AgentConfig, AgentDefaultView, CreateAgentConfigRequest, SavedAgentConfig, @@ -34,7 +35,7 @@ export function registerConfig(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const configs = await new ApiClient().listAgentConfigs() + const { configs } = await api().agents.configs.list() if (opts.json) { printJson(configs) return @@ -64,15 +65,18 @@ export function registerConfig(program: Command): void { .option('--json', 'output raw JSON') .action(async (configId: string, opts: { json?: boolean }) => { await runAction(async () => { - const api = new ApiClient() + const client = api() // --json is the machine-readable mode: emit only the raw config. if (opts.json) { - printJson(await api.getAgentConfig(configId)) + printJson((await client.agents.configs.get(configId)).config) return } // Fetch the config and the login (for the link) together. The link goes // to stderr so the YAML on stdout stays clean for piping/redirecting. - const [c, me] = await Promise.all([api.getAgentConfig(configId), api.whoami()]) + const [{ config: c }, me] = await Promise.all([ + client.agents.configs.get(configId), + client.me(), + ]) printYaml(c) console.error(`\nview: ${configUrl(resolveAppBase(), me.customer_login, configId)}`) }) @@ -119,9 +123,9 @@ export function registerConfig(program: Command): void { repository: opts.repo, path: opts.path, } - if (opts.file) req.config = readConfigFile(opts.file) + if (opts.file) req.config = readConfigFile(opts.file) as AgentConfig if (opts.template) req.template_id = opts.template - const created = await new ApiClient().createAgentConfig(req) + const created = await api().agents.configs.create(req) if (opts.json) { printJson(created) return @@ -156,7 +160,7 @@ export function registerConfig(program: Command): void { // (the same ladder session start resolves server-side). .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const rungs = await new ApiClient().listAgentDefaults() + const { defaults: rungs } = await api().agents.defaults.list() const repo = repoFromCwd(process.cwd()) const repoRung = repo ? rungs.find((d) => d.repository?.toLowerCase() === repo.toLowerCase()) @@ -197,7 +201,7 @@ export function registerConfig(program: Command): void { // merged view, not just this command's own opts. .action(async (_opts: { json?: boolean }, cmd: Command) => { await runAction(async () => { - const rungs = await new ApiClient().listAgentDefaults() + const { defaults: rungs } = await api().agents.defaults.list() if (cmd.optsWithGlobals().json) { printJson(rungs) return @@ -234,7 +238,7 @@ export function registerConfig(program: Command): void { async (configId: string, opts: { repo?: string | boolean; json?: boolean }, cmd: Command) => { await runAction(async () => { const repository = resolveRepoFlag(opts.repo) - const set = await new ApiClient().putAgentDefault({ + const { default: set } = await api().agents.defaults.set({ config_id: configId, ...(repository ? { repository } : {}), }) @@ -265,7 +269,7 @@ export function registerConfig(program: Command): void { .action(async (opts: { repo?: string | boolean }) => { await runAction(async () => { const repository = resolveRepoFlag(opts.repo) - await new ApiClient().deleteAgentDefault(repository) + await api().agents.defaults.delete({ repository }) console.log( `✓ cleared ${repository ? `default for ${repository}` : 'account default'}`, ) @@ -309,7 +313,7 @@ export function registerConfig(program: Command): void { return } await runAction(async () => { - const created = await new ApiClient().createAgentConfig({ + const created = await api().agents.configs.create({ template_id: opts.template, repository: opts.repo!, path: opts.path, diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 80485da..9a23913 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -3,7 +3,7 @@ import React from 'react' import { render } from 'ink' import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { runAction } from '../lib/output' import { sessionUrl } from '../lib/urls' @@ -92,11 +92,14 @@ export async function runConnect( // from the fetched session. Shown in the footer meta line. configName?: string, ): Promise { - const api = new ApiClient() + const client = api() const token = requireToken() const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) - const [session, me] = await Promise.all([api.getAgentSession(sessionId), api.whoami()]) + const [{ session }, me] = await Promise.all([ + client.sessions.get(sessionId), + client.me(), + ]) const c = connectability(session) // --no-input forces watch-only even when the session would accept messages. const canSend = readOnly ? false : c.canSend @@ -106,7 +109,7 @@ export async function runConnect( const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId) // The config identity for the footer meta line: the caller's resolved name // first, then whatever the session itself carries. - const config = configName ?? session.resolved_config_name ?? session.agent_config_id ?? null + const config = configName ?? session.config_id ?? null // No scrollback preamble: the app owns the whole surface, Claude Code-style. // The footer carries the session identity/status; a watch-only reason @@ -118,7 +121,7 @@ export async function runConnect( // cursor instead of replaying history. --no-records skips *rendering* the // seeded history (minRenderFeedSeq), not re-streaming it. const store = new SessionTranscriptStore() - const page = await api.getAgentSessionRecordsPage(sessionId) + const page = (await client.sessions.records(sessionId)).response const ordered = [...page.records].sort((a, b) => a.feed_seq - b.feed_seq) // Seed the session + open inbox as a synthetic snapshot frame (protocol v3: // the store folds the inbox from the snapshot projection and the message_* @@ -146,7 +149,7 @@ export async function runConnect( } const app = render( React.createElement(ConnectApp, { - api, + api: client, sessionId, store, openSocket, diff --git a/src/commands/file.ts b/src/commands/file.ts index fa16300..499e590 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -1,7 +1,7 @@ import { type Command } from 'commander' import { readFileSync, writeFileSync } from 'node:fs' import { basename } from 'node:path' -import { ApiClient, ApiError } from '../lib/api' +import { api, APIError } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { formatTs, printJson, printTable, runAction } from '../lib/output' import type { CreateFileRequest, FileView, GetFileResponse } from '../lib/types' @@ -94,7 +94,7 @@ export function registerFile(program: Command): void { .action(async (path: string, opts: { json?: boolean }) => { await runAction(async () => { const req = buildUploadRequest(path, readFileSync(path)) - const res = await new ApiClient().uploadFile(req) + const res = await api().files.create(req) // The URL is the whole point — keep it the bare primary output so an // agent (or $(...) in a script) can capture it directly. if (opts.json) printJson(res) @@ -111,10 +111,9 @@ export function registerFile(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { session?: string; limit?: number; json?: boolean }) => { await runAction(async () => { - const files = await new ApiClient().listFiles({ - agent_session_id: opts.session, - limit: opts.limit, - }) + const files = ( + await api().files.list({ session_id: opts.session, limit: opts.limit }) + ).items if (opts.json) { printJson(files) return @@ -130,7 +129,7 @@ export function registerFile(program: Command): void { f.filename, formatSize(f.size_bytes), formatTs(f.created_at), - f.agent_session_id ?? '-', + f.session_id ?? '-', ]), ) }) @@ -147,7 +146,7 @@ export function registerFile(program: Command): void { .option('--json', 'output raw JSON (includes the short-lived download_url)') .action(async (fileId: string, opts: { output?: string; json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().getFile(fileId) + const res = await api().files.get(fileId) if (opts.output) { // download_url is a ~60s presigned S3 GET — fetch it immediately, // while it's fresh. The JSON API never carries the bytes itself. @@ -172,18 +171,18 @@ export function registerFile(program: Command): void { .action(async (fileId: string, opts: { json?: boolean }) => { await runAction(async () => { try { - await new ApiClient().deleteFile(fileId) + await api().files.delete(fileId) } catch (err) { // A 404 covers "never existed", "someone else's", and "already // deleted" — all the same "there's nothing here to delete" to the // caller, so give one clear message instead of the raw HTTP error. - if (err instanceof ApiError && err.status === 404) { + if (err instanceof APIError && err.status === 404) { throw new Error(`file not found: ${fileId}`) } // A 403 is a real policy decision (e.g. sandbox tokens can't delete); // surface the server's own explanation rather than masking it. - if (err instanceof ApiError && err.status === 403) { - throw new Error(err.detail) + if (err instanceof APIError && err.status === 403) { + throw new Error(err.message) } throw err } @@ -201,7 +200,7 @@ function renderFile(res: GetFileResponse): void { console.log(`type: ${f.content_type}`) console.log(`size: ${formatSize(f.size_bytes)}`) console.log(`created: ${formatTs(f.created_at)}`) - if (f.agent_session_id) console.log(`session: ${f.agent_session_id}`) + if (f.session_id) console.log(`session: ${f.session_id}`) console.log(`url: ${res.url}`) console.log(`\ndownload the file with: agent file get ${f.id} -o ${f.filename}`) } diff --git a/src/commands/github.ts b/src/commands/github.ts index 3536427..c5b3400 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -23,7 +23,7 @@ export function registerGithub(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().listGithubRepositories() + const res = await api().integrations.github.repos() if (opts.json) { printJson(res) return @@ -58,7 +58,7 @@ export function registerGithub(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().listGithubMembers() + const res = await api().integrations.github.members() if (opts.json) { printJson(res) return diff --git a/src/commands/help.ts b/src/commands/help.ts index 233cbaf..f2b3633 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander' -import { ApiClient, ApiError } from '../lib/api' +import { api, APIError } from '../lib/api' import { apiRoutes } from '../lib/help' import { runAction } from '../lib/output' import { repoFromCwd } from '../lib/laptop' @@ -73,12 +73,11 @@ async function startHelperSession(): Promise { // `agent`, rather than running a workflow against a fabricated kickoff. req.idle_start = true - const api = new ApiClient() try { - const session = await api.startAgentSession(req) + const { session } = await api().sessions.start(req) await startConnect(session, 'Ellipsis help agent') } catch (err) { - if (err instanceof ApiError && err.status === 404) { + if (err instanceof APIError && err.status === 404) { throw new Error( `the help agent is not available on this host yet (template "${HELPER_TEMPLATE_SLUG}" not found). Run \`agent template list\` to see what you can start.`, ) diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index 07c7e3e..2d9982c 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' import type { GetIntegrationsResponse } from '../lib/types' @@ -17,7 +17,7 @@ export function registerIntegration(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const integrations = await new ApiClient().getIntegrations() + const integrations = await api().integrations.list() if (opts.json) { printJson(integrations) return diff --git a/src/commands/linear.ts b/src/commands/linear.ts index 49af586..5ee3c8d 100644 --- a/src/commands/linear.ts +++ b/src/commands/linear.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient, requireConnected } from '../lib/api' +import { api, requireConnected } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -20,7 +20,7 @@ export function registerLinear(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const res = await requireConnected('Linear', new ApiClient().listLinearTeams()) + const res = await requireConnected('Linear', api().integrations.linear.teams()) if (opts.json) { printJson(res) return diff --git a/src/commands/login.ts b/src/commands/login.ts index a5b9819..b29a558 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { activeHostName, clearActiveHostToken, @@ -15,9 +15,8 @@ export function registerLogin(program: Command): void { .description('Authenticate against the active host via the device-code flow') .option('--no-browser', 'do not auto-open the verification URL (for headless or SSH)') .action(async (opts: { browser?: boolean }) => { - const api = new ApiClient() try { - const { token } = await deviceLogin(api, { + const { token } = await deviceLogin(api(), { onPrompt: (start) => { // Build the approval URL from the app base of the host the CLI is // pointed at, NOT the server's verification_uri_complete — the diff --git a/src/commands/me.ts b/src/commands/me.ts index 9b1eb15..e71fd05 100644 --- a/src/commands/me.ts +++ b/src/commands/me.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { requireToken } from '../lib/config' import { apiRoutes } from '../lib/help' import { printJson, runAction } from '../lib/output' @@ -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 new ApiClient().whoami() + const me = await api().me() if (opts.json) { printJson(me) return diff --git a/src/commands/model.ts b/src/commands/model.ts index 87fd2e3..e65b669 100644 --- a/src/commands/model.ts +++ b/src/commands/model.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -23,7 +23,7 @@ export function registerModel(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const models = await new ApiClient().listSupportedModels() + const { models } = await api().models.list() if (opts.json) { printJson(models) return diff --git a/src/commands/ping.ts b/src/commands/ping.ts index bf390f9..7a03448 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander' -import { ApiClient, ApiError } from '../lib/api' +import { api, APIError } from '../lib/api' import { apiRoutes } from '../lib/help' export function registerPing(program: Command): void { @@ -13,15 +13,14 @@ export function registerPing(program: Command): void { // 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. - const api = new ApiClient() try { - const me = await api.whoami() + const me = await api().me() console.log(`ok: ${me.customer_login} (${me.customer_id})`) } catch (err) { - if (err instanceof ApiError && err.status === 401) { + if (err instanceof APIError && err.status === 401) { // Reachable, just not authenticated — point the user at login. console.error('reachable, but not authenticated. Run `agent login` first.') - } else if (err instanceof ApiError) { + } else if (err instanceof APIError) { console.error(`ping failed: ${err.status} ${err.message}`) } else { // Network/DNS/connection error: never got an HTTP response. diff --git a/src/commands/review.ts b/src/commands/review.ts index d62c415..7646eef 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -1,12 +1,19 @@ import { type Command } from 'commander' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { basename, dirname, extname } from 'node:path' -import { ApiClient, ApiError } from '../lib/api' +import { api, APIError } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { repoFromCwd } from '../lib/laptop' import { formatTs, printJson, printTable, relativeAge, runAction, usdFromMillicents } from '../lib/output' import { watchSessionStreaming } from './session' -import type { CreateReviewRequest, Finding, Review, ReviewScope } from '../lib/types' +import type { Ellipsis } from '@ellipsis-dev/sdk' +import type { + CodeReviewRunStatus, + CreateReviewRequest, + Finding, + Review, + ReviewScope, +} from '../lib/types' // `agent review`: ask for a code review now, instead of waiting for a push to // trigger one. @@ -58,9 +65,9 @@ export function registerReview(program: Command): void { .option('--json', 'output raw JSON') .action(async (pullRequest: string, opts: StartOptions) => { await runAction(async () => { - const api = new ApiClient() + const client = api() const request = buildCreateRequest(pullRequest, opts) - const started = await api.createReview(request) + const started = await client.reviews.create(request) // Nothing new since the last review of this PR. Not an error — you // asked, and the honest answer is "already covered". @@ -93,8 +100,8 @@ export function registerReview(program: Command): void { `(${started.id})`, ) } - await watchSessionStreaming(api, started.id, FALLBACK_POLL_INTERVAL_SECONDS, false) - const finished = await api.getReview(started.id) + await watchSessionStreaming(client, started.id, FALLBACK_POLL_INTERVAL_SECONDS, false) + const finished = await client.reviews.get(started.id) if (opts.json) printJson(finished) else renderReview(finished) }) @@ -109,7 +116,7 @@ export function registerReview(program: Command): void { .option('--json', 'output raw JSON') .action(async (reviewId: string, opts: { json?: boolean }) => { await runAction(async () => { - const found = await getReviewOrExplain(new ApiClient(), reviewId) + const found = await getReviewOrExplain(api(), reviewId) if (opts.json) printJson(found) else renderReview(found) }) @@ -133,13 +140,15 @@ export function registerReview(program: Command): void { if (opts.pr !== undefined && repo === undefined) { throw new Error('--pr needs --repo to say which repository') } - const reviews = await new ApiClient().listReviews({ - owner: repo?.owner, - repo: repo?.name, - pull_request_number: opts.pr, - status: opts.status, - limit: opts.limit, - }) + const reviews = ( + await api().reviews.list({ + owner: repo?.owner, + repo: repo?.name, + pull_request_number: opts.pr, + status: opts.status, + limit: opts.limit, + }) + ).items if (opts.json) { printJson(reviews) return @@ -281,7 +290,7 @@ interface StartOptions { interface ListOptions { repo?: string pr?: number - status?: string + status?: CodeReviewRunStatus limit?: number json?: boolean } @@ -312,14 +321,14 @@ function buildScope(opts: StartOptions): ReviewScope { } } -async function getReviewOrExplain(api: ApiClient, reviewId: string): Promise { +async function getReviewOrExplain(client: Ellipsis, reviewId: string): Promise { try { - return await api.getReview(reviewId) + return await client.reviews.get(reviewId) } catch (err) { // The likeliest mistake is handing this a stage session id (or any other // session id) instead of the review's own — indistinguishable from an // unknown id server-side, on purpose. - if (err instanceof ApiError && err.status === 404) { + if (err instanceof APIError && err.status === 404) { throw new Error(`no review with id ${reviewId} (a review id looks like crun_…)`) } throw err diff --git a/src/commands/sentry.ts b/src/commands/sentry.ts index f182269..90f0dd7 100644 --- a/src/commands/sentry.ts +++ b/src/commands/sentry.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -19,7 +19,7 @@ export function registerSentry(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().listSentryOrganizations() + const res = await api().integrations.sentry.organizations() if (opts.json) { printJson(res) return diff --git a/src/commands/session.tsx b/src/commands/session.tsx index c187bbe..c5dc81b 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { extname } from 'node:path' import { gunzipSync, gzipSync } from 'node:zlib' import { parse as parseYaml } from 'yaml' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { formatTs, @@ -34,9 +34,10 @@ import { } from '@ellipsis-dev/sdk/stream' import { recordToItems } from '@ellipsis-dev/sdk/store' import { makeOpenSocket, resolveWsBase } from '../lib/stream' +import type { Ellipsis, Session as FrameSession } from '@ellipsis-dev/sdk' import type { + AgentConfig, AgentSession, - AgentSessionWire, AgentSessionSource, AgentSessionStatus, GithubAccountSnippet, @@ -65,7 +66,7 @@ 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 { ApiError } from '../lib/api' +import { APIError } from '../lib/api' import { resolveToken } from '../lib/config' // Poll cadence for the `--watch` REST fallback (used only when live WebSocket @@ -225,7 +226,7 @@ export function registerSession(program: Command): void { metadata: opts.metadata, } if (opts.config) req.config_id = opts.config - if (opts.configFile) req.config = readConfigFile(opts.configFile) + if (opts.configFile) req.config = readConfigFile(opts.configFile) as AgentConfig if (opts.template) req.template_id = opts.template // The repo we're standing in (origin remote), sent unconditionally — // with no config source it picks the repo rung of the server's @@ -252,8 +253,9 @@ export function registerSession(program: Command): void { // workflow, so this only applies to the interactive open. if (opts.connect && !promptText) req.idle_start = true - const api = new ApiClient() - const session = await api.startAgentSession(req) + const client = api() + const { session, resolved_config_name, resolution_source } = + await client.sessions.start(req) // Say which agent the server picked when it came from the defaults // ladder, so a bare `agent` never silently runs an unexpected config. @@ -261,30 +263,30 @@ export function registerSession(program: Command): void { // printed before the app would land in scrollback); every other // mode prints this note. let configNote: string | undefined - if (session.resolved_config_name) { - if (session.resolution_source === 'repo_default') { - configNote = `using config "${session.resolved_config_name}" (repo default)` - } else if (session.resolution_source === 'account_default') { - configNote = `using config "${session.resolved_config_name}" (account default)` + if (resolved_config_name) { + if (resolution_source === 'repo_default') { + configNote = `using config "${resolved_config_name}" (repo default)` + } else if (resolution_source === 'account_default') { + configNote = `using config "${resolved_config_name}" (account default)` } } if (opts.connect) { // A non-interactive config refuses the stream/messages surface, so // a connect would fail; degrade to watching the output instead. - const ellipsisBlock = (session.agent_config as Record | undefined) - ?.ellipsis as { interactive?: boolean } | undefined - if (ellipsisBlock?.interactive === false) { + // The wire session carries no config blob, so interactivity comes + // from the same projection POST /messages enforces. + if (!session.prompting.enabled) { if (configNote) console.log(configNote) console.log( 'this agent is not interactive; watching output instead of connecting', ) console.log(`✓ started session ${session.id}`) - await printSessionUrl(api, session.id) - await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, false) + await printSessionUrl(client, session.id) + await watchSessionStreaming(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, false) return } - await startConnect(session) + await startConnect(session, undefined, resolved_config_name ?? undefined) return } @@ -293,14 +295,19 @@ export function registerSession(program: Command): void { if (opts.watch) { if (!opts.json) { console.log(`✓ started session ${session.id}`) - await printSessionUrl(api, session.id) + await printSessionUrl(client, session.id) } // --quiet blocks on status only (no live output stream); either way // the terminal status sets the exit code. if (opts.quiet) { - await watchSession(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSession(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) } else { - await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSessionStreaming( + client, + session.id, + FALLBACK_POLL_INTERVAL_SECONDS, + opts.json, + ) } return } @@ -310,7 +317,7 @@ export function registerSession(program: Command): void { return } console.log(`✓ started session ${session.id} (${session.status})`) - await printSessionUrl(api, session.id) + await printSessionUrl(client, session.id) console.log(` follow with: agent session get ${session.id} --watch`) }) }, @@ -356,16 +363,18 @@ export function registerSession(program: Command): void { json?: boolean }) => { await runAction(async () => { - const api = new ApiClient() - const sessions = await api.listAgentSessions({ - config_id: opts.config, - source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, - author_id: opts.author ? await resolveAuthorId(api, opts.author) : undefined, - days: opts.days, - start: opts.since, - end: opts.until, - limit: opts.limit, - }) + const client = api() + const sessions = ( + await client.sessions.list({ + config_id: opts.config, + source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, + author_id: opts.author ? await resolveAuthorId(client, opts.author) : undefined, + days: opts.days, + start: opts.since, + end: opts.until, + limit: opts.limit, + }) + ).items if (opts.json) { printJson(sessions) return @@ -456,14 +465,14 @@ export function registerSession(program: Command): void { }, ) => { await runAction(async () => { - const api = new ApiClient() - const authorId = opts.author ? await resolveAuthorId(api, opts.author) : undefined - const res = await api.searchSessions({ + const client = api() + const authorId = opts.author ? await resolveAuthorId(client, opts.author) : undefined + const res = await client.sessions.search({ q: query, scope: opts.scope as SessionSearchScope, source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, author_id: authorId === undefined ? undefined : [authorId], - agent_config_id: opts.config.length ? opts.config : undefined, + config_id: opts.config.length ? opts.config : undefined, session_ids: opts.session.length ? opts.session : undefined, repo: opts.repo, status: opts.status.length ? (opts.status as AgentSessionStatus[]) : undefined, @@ -503,7 +512,7 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON (full record payloads)') .action(async (sessionId: string, opts: { json?: boolean }) => { await runAction(async () => { - const records = await new ApiClient().getAgentSessionRecords(sessionId) + const records = (await api().sessions.records(sessionId)).items as SessionRecord[] if (opts.json) { printJson(records) return @@ -541,7 +550,7 @@ export function registerSession(program: Command): void { }, ) => { await runAction(async () => { - const manifest = await new ApiClient().getSessionLog(sessionId) + const manifest = await api().sessions.log(sessionId) if (opts.json) { printJson(manifest) return @@ -565,7 +574,7 @@ export function registerSession(program: Command): void { } else { process.stdout.write(data) } - if (!manifest.caught_up) { + if (manifest.has_more) { console.error( `note: the archive trails the live feed (archived through ` + `${manifest.archived_through_feed_seq} of ${manifest.latest_feed_seq}). ` + @@ -592,25 +601,33 @@ export function registerSession(program: Command): void { .action( async (sessionId: string, opts: { watch?: boolean; quiet?: boolean; json?: boolean }) => { await runAction(async () => { - const api = new ApiClient() + const client = api() if (opts.quiet && !opts.watch) { throw new Error('--quiet only applies with --watch') } if (opts.watch) { - if (!opts.json) await printSessionUrl(api, sessionId) + if (!opts.json) await printSessionUrl(client, sessionId) if (opts.quiet) { - await watchSession(api, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSession(client, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) } else { - await watchSessionStreaming(api, sessionId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSessionStreaming( + client, + sessionId, + FALLBACK_POLL_INTERVAL_SECONDS, + opts.json, + ) } return } if (opts.json) { - printJson(await api.getAgentSession(sessionId)) + printJson((await client.sessions.get(sessionId)).session) return } // Fetch the session and the login (for the link) together — no added latency. - const [s, me] = await Promise.all([api.getAgentSession(sessionId), api.whoami()]) + const [{ session: s }, me] = await Promise.all([ + client.sessions.get(sessionId), + client.me(), + ]) printSessionSummary(s) console.log(`url: ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) }) @@ -669,18 +686,23 @@ export function registerSession(program: Command): void { // `--prompt ''` (clear it): only set the field when the flag was passed. if (opts.prompt !== undefined) req.prompt = opts.prompt - const api = new ApiClient() - const session = await api.replayAgentSession(sessionId, req) + 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(api, session.id) + await printSessionUrl(client, session.id) } if (opts.quiet) { - await watchSession(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSession(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) } else { - await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) + await watchSessionStreaming( + client, + session.id, + FALLBACK_POLL_INTERVAL_SECONDS, + opts.json, + ) } return } @@ -689,7 +711,7 @@ export function registerSession(program: Command): void { return } console.log(`✓ started replay ${session.id} (${session.status}, from ${sessionId})`) - await printSessionUrl(api, session.id) + await printSessionUrl(client, session.id) console.log(` follow with: agent session get ${session.id} --watch`) }) }, @@ -733,8 +755,8 @@ export function registerSession(program: Command): void { : `✓ working tree clean, handing off HEAD ${sha.slice(0, 12)} via ${ref}`, ) } - const api = new ApiClient() - const session = await api.startAgentSession({ + const client = api() + const { session } = await client.sessions.start({ handoff: { parent_session_id: opts.parent, repo, sha, ref }, prompt, }) @@ -743,7 +765,7 @@ export function registerSession(program: Command): void { return } console.log(`✓ started handoff session ${session.id} (${session.status})`) - await printSessionUrl(api, session.id) + await printSessionUrl(client, session.id) console.log(` follow with: agent session get ${session.id} --watch`) }) }, @@ -788,8 +810,7 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON') .action(async (sessionId: string, opts: { json?: boolean }) => { await runAction(async () => { - const api = new ApiClient() - const s = await api.stopAgentSession(sessionId) + const { session: s } = await api().sessions.stop(sessionId) if (opts.json) { printJson(s) return @@ -820,7 +841,7 @@ export function registerSession(program: Command): void { .option('--json', 'output raw JSON') .action(async (sessionId: string, opts: { open: boolean; json?: boolean }) => { await runAction(async () => { - const res = await new ApiClient().getSessionIde(sessionId) + const res = await api().sessions.ide(sessionId) if (opts.json) { printJson(res) return @@ -855,7 +876,7 @@ export function registerSession(program: Command): void { if (Number.isNaN(portNumber)) { throw new Error(`port must be a number, got "${port}"`) } - const res = await new ApiClient().getSessionPort(sessionId, portNumber) + const res = await api().sessions.port(sessionId, portNumber) if (opts.json) { printJson(res) return @@ -873,10 +894,14 @@ export function registerSession(program: Command): void { // process) as it happens and reports a terminal status reached before the // sandbox ever ran (a preflight/budget gate), so there is nothing to wait // for out here. -export async function startConnect(session: AgentSession, notice?: string): Promise { - // The start response carries the resolved config identity; hand it to the - // chat for the footer meta line (a later GET may not resolve the name). - const configName = session.resolved_config_name ?? session.agent_config_id ?? undefined +export async function startConnect( + session: AgentSession, + notice?: string, + // The start response's resolved config name, which the session itself does + // not carry; shown in the chat footer's meta line. + resolvedConfigName?: string, +): Promise { + const configName = resolvedConfigName ?? session.config_id ?? undefined if (canHostSessionsUi()) { await runSessionsUi({ initialSessionId: session.id, @@ -894,7 +919,7 @@ export async function startConnect(session: AgentSession, notice?: string): Prom // backend without the endpoint). Identical UX either way — the same flag // covers both. export async function watchSessionStreaming( - api: ApiClient, + client: Ellipsis, sessionId: string, intervalSeconds: number, json?: boolean, @@ -911,7 +936,7 @@ export async function watchSessionStreaming( const onFrame = (frame: StreamFrame) => { if (frame.type === 'session' || frame.type === 'snapshot') { const word = sessionStatusWord( - (frame as unknown as { session: AgentSessionWire }).session, + (frame as unknown as { session: FrameSession }).session, ) if (word === lastStatus) return lastStatus = word @@ -934,7 +959,7 @@ export async function watchSessionStreaming( `live stream unavailable (${err.message}); falling back to status polling`, ) } - await watchSession(api, sessionId, intervalSeconds, json) + await watchSession(client, sessionId, intervalSeconds, json) return } throw err // StreamAuthError and anything unexpected: surfaced by runAction. @@ -995,7 +1020,7 @@ export function exitCodeForStatus(status: string): number { // transition. This is the status-level fallback used when live streaming isn't // available: the public REST API exposes session state, not the step-by-step stream. export async function watchSession( - api: ApiClient, + client: Ellipsis, sessionId: string, intervalSeconds: number, json?: boolean, @@ -1003,7 +1028,7 @@ export async function watchSession( const intervalMs = Math.max(1, intervalSeconds) * 1000 let last: AgentSessionStatus | undefined for (;;) { - const s = await api.getAgentSession(sessionId) + const { session: s } = await client.sessions.get(sessionId) if (s.status !== last) { if (!json) { const reason = s.status_reason ? `: ${s.status_reason}` : '' @@ -1029,7 +1054,7 @@ function printSessionSummary(s: AgentSession): void { console.log(`id: ${s.id}`) console.log(`status: ${s.status}${s.status_reason ? ` (${s.status_reason})` : ''}`) if (s.source) console.log(`source: ${s.source}`) - if (s.agent_config_id) console.log(`config: ${s.agent_config_id}`) + if (s.config_id) console.log(`config: ${s.config_id}`) console.log(`created: ${s.created_at}`) console.log(`updated: ${s.updated_at}`) console.log(`tokens: ${s.tokens_total.toLocaleString()}`) @@ -1047,8 +1072,8 @@ 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. -async function printSessionUrl(api: ApiClient, sessionId: string): Promise { - const me = await api.whoami() +async function printSessionUrl(client: Ellipsis, sessionId: string): Promise { + const me = await client.me() console.log(` ${sessionUrl(resolveAppBase(), me.customer_login, sessionId)}`) } @@ -1057,7 +1082,10 @@ async function printSessionUrl(api: ApiClient, sessionId: string): Promise // config_override_yaml; `--config-override-file` is read and parsed to a mapping // and sent as the structured config_override. Both merge identically server-side. export function applyConfigOverride( - req: { config_override?: Record; config_override_yaml?: string }, + req: { + config_override?: Record | null + config_override_yaml?: string | null + }, opts: { configOverride?: string; configOverrideFile?: string }, ): void { if (opts.configOverride && opts.configOverrideFile) { @@ -1181,8 +1209,8 @@ function readMappingFile(path: string, label: string): Record { // Resolve a --author GitHub login to the account id the API filters by // (author_id on GET /sessions and /sessions/search), via the org roster. // An unknown login fails with the known logins so the user can self-correct. -export async function resolveAuthorId(api: ApiClient, login: string): Promise { - const { members } = await api.listGithubMembers() +export async function resolveAuthorId(client: Ellipsis, login: string): Promise { + const { members } = await client.integrations.github.members() const member = members.find((m) => m.login?.toLowerCase() === login.toLowerCase()) if (member) return member.id const known = members.flatMap((m) => (m.login ? [m.login] : [])).join(', ') @@ -1284,7 +1312,7 @@ async function readHookStdin(): Promise { // A fetch() network failure (DNS, refused, offline) — retriable, so spool. // ApiError >= 500 is treated the same; 4xx is permanent and never spooled. function isRetriable(err: unknown): boolean { - if (err instanceof ApiError) return err.status >= 500 + if (err instanceof APIError) return err.status >= 500 // Anything that never produced an HTTP response (DNS, refused, offline). return true } @@ -1358,9 +1386,9 @@ async function syncTranscript(opts: { git_branch: branchFromCwd(cwd), } - const api = new ApiClient() + const client = api() try { - const res = await api.syncAgentSession(req) + const res = await client.sessions.sync(req) recordSyncOutcome({ outcome: 'synced', cc_session_id: ccSessionId, @@ -1397,7 +1425,7 @@ async function syncTranscript(opts: { continue } try { - await api.syncAgentSession(spooled) + await client.sessions.sync(spooled) dropSpooledSync(file) } catch (err) { if (isRetriable(err)) break // server unhealthy again; retry next time diff --git a/src/commands/slack.ts b/src/commands/slack.ts index 6eb0526..8239f9c 100644 --- a/src/commands/slack.ts +++ b/src/commands/slack.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient, requireConnected } from '../lib/api' +import { api, requireConnected } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -18,7 +18,7 @@ export function registerSlack(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const res = await requireConnected('Slack', new ApiClient().listSlackChannels()) + const res = await requireConnected('Slack', api().integrations.slack.channels()) if (opts.json) { printJson(res) return @@ -51,7 +51,7 @@ export function registerSlack(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const res = await requireConnected('Slack', new ApiClient().listSlackMembers()) + const res = await requireConnected('Slack', api().integrations.slack.members()) if (opts.json) { printJson(res) return diff --git a/src/commands/template.ts b/src/commands/template.ts index b541eb9..6ccad81 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -1,5 +1,5 @@ import { type Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' @@ -21,7 +21,7 @@ export function registerTemplate(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const templates = await new ApiClient().listAgentTemplates() + const { templates } = await api().agents.templates.list() if (opts.json) { printJson(templates) return diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 71627fc..0eb6ca2 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { apiRoutes } from '../lib/help' import { printJson, runAction, usd, usdFromMillicents } from '../lib/output' @@ -11,7 +11,7 @@ export function registerUsage(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const b = await new ApiClient().getBudget() + const b = await api().budget() if (opts.json) { printJson(b) return @@ -33,7 +33,7 @@ export function registerUsage(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const u = await new ApiClient().getUsage() + const u = await api().usage() if (opts.json) { printJson(u) return diff --git a/src/commands/variable.ts b/src/commands/variable.ts index 09de7ee..848cccc 100644 --- a/src/commands/variable.ts +++ b/src/commands/variable.ts @@ -1,6 +1,6 @@ import { type Command } from 'commander' import { readFileSync } from 'node:fs' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' import { formatTs, printJson, printTable, runAction } from '../lib/output' import type { SandboxVariableInput, SandboxVariableSummary } from '../lib/types' @@ -25,8 +25,8 @@ export function registerVariable(program: Command): void { .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const variables = await new ApiClient().listSandboxVariables() - printVariables(variables, opts.json) + const { secrets } = await api().secrets.list() + printVariables(secrets, opts.json) }) }) @@ -41,12 +41,12 @@ export function registerVariable(program: Command): void { .action(async (assignments: string[], opts: { fromFile?: string; json?: boolean }) => { await runAction(async () => { const inputs = collectInputs(assignments, opts.fromFile) - const variables = await new ApiClient().putSandboxVariables(inputs) + const { secrets } = await api().secrets.set({ secrets: inputs }) if (!opts.json) { const names = inputs.map((v) => v.name).join(', ') console.log(`✓ stored ${inputs.length} variable(s) (values hidden): ${names}`) } - printVariables(variables, opts.json) + printVariables(secrets, opts.json) }) }) @@ -57,9 +57,11 @@ export function registerVariable(program: Command): void { .option('--json', 'output raw JSON') .action(async (name: string, opts: { json?: boolean }) => { await runAction(async () => { - const variables = await new ApiClient().deleteSandboxVariable(name) + // The delete answers 204, so showing what's left is a second read. + const client = api() + await client.secrets.delete(name) if (!opts.json) console.log(`✓ deleted ${name}`) - printVariables(variables, opts.json) + printVariables((await client.secrets.list()).secrets, opts.json) }) }) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 9cb3e67..b39f9e8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,494 +1,62 @@ +// The CLI's entry point to @ellipsis-dev/sdk: the SDK owns the whole REST +// surface (every /v1 operation, its request/response types, retries, and error +// mapping), generated from the server's OpenAPI spec. This module owns only +// what is CLI-specific — resolving the credential and base URL through the +// config precedence chain, stamping the CLI's user agent on every request, and +// turning an SDK error into the sentence a terminal user should read. + +import { Ellipsis, APIError } from '@ellipsis-dev/sdk' import { resolveApiBase, resolveToken } from './config' import { USER_AGENT } from './constants' -import type { - AgentDefaultView, - AgentSession, - AgentTemplate, - AnalyticsMetricsQuery, - AnalyticsPullRequestsQuery, - AnalyticsReviewsQuery, - BudgetSummary, - CliAuthPoll, - CliAuthStart, - CreateAgentConfigRequest, - CreateFileRequest, - CreateFileResponse, - CreateReviewRequest, - CreatedAgentConfig, - FileView, - GetFileResponse, - GetAnalyticsMetricsResponse, - GetAnalyticsPullRequestsResponse, - GetAnalyticsReviewsResponse, - GetIntegrationsResponse, - GetSandboxVariablesResponse, - GetSessionIdeResponse, - GetSessionPortResponse, - GetSupportedModelsResponse, - ListAgentConfigsResponse, - ListAgentDefaultsResponse, - ListAgentSessionsQuery, - ListAgentSessionsResponse, - ListAgentTemplatesResponse, - ListFilesQuery, - ListFilesResponse, - ListGithubMembersResponse, - ListGithubRepositoriesResponse, - ListLinearTeamsResponse, - ListReviewsQuery, - ListReviewsResponse, - ListSentryOrganizationsResponse, - ListSessionRecordsResponse, - ListSessionTurnsResponse, - ListSlackChannelsResponse, - ListSlackMembersResponse, - PutAgentDefaultRequest, - ReplayAgentSessionRequest, - Review, - SendSessionMessageRequest, - SandboxVariableInput, - SandboxVariableSummary, - SearchSessionsQuery, - SearchSessionsResponse, - SessionMessage, - SessionRecord, - SyncAgentSessionRequest, - SyncAgentSessionResponse, - SavedAgentConfig, - StartAgentSessionRequest, - SupportedModel, - UsageDashboard, - WhoAmI, - GetSessionLogResponse, -} from './types' -// Thin REST client over the public API. The session-stream surface -// types come from @ellipsis-dev/sdk (generated from the backend's schema, via -// lib/types re-exports); the rest of the typed surface remains a hand-rolled -// mirror of ellipsis/src/public_api/routers/v1/v1_router.py until the SDK's -// OpenAPI surface widens beyond the protocol endpoints. - -export class ApiError extends Error { - constructor( - readonly status: number, - readonly method: string, - readonly path: string, - readonly detail: string, - // Per-request id the server stamps on every response; quote it to us so a - // failure maps to an exact log line. Absent only for pre-request failures. - readonly requestId?: string, - ) { - super( - `${method} ${path} failed: ${status} ${detail}` + - (requestId ? ` (request id: ${requestId})` : ''), - ) - this.name = 'ApiError' - } +export { APIError } + +// The SDK's Transport takes no custom headers, so the user agent rides in on an +// injected fetch — every CLI request stays attributable server-side. +function fetchWithUserAgent( + input: string | URL | Request, + init?: RequestInit, +): Promise { + return globalThis.fetch(input, { + ...init, + headers: { ...(init?.headers as Record), 'user-agent': USER_AGENT }, + }) } -export class ApiClient { - private readonly base: string - private readonly token?: string - - // Both args are optional overrides; when omitted, each is resolved through - // the precedence chain (explicit → env → config → default) in config.ts. - constructor(base?: string, token?: string) { - this.base = resolveApiBase(base) - this.token = resolveToken(token) - } - - async request( - method: string, - path: string, - body?: unknown, - query?: Record, - ): Promise { - const url = this.base + path + buildQuery(query) - const res = await fetch(url, { - method, - headers: { - 'content-type': 'application/json', - 'user-agent': USER_AGENT, - ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }) - if (!res.ok) { - const { detail, requestId } = await parseErrorResponse(res) - throw new ApiError(res.status, method, path, detail, requestId) - } - // Some endpoints (DELETEs, acks) return empty bodies; tolerate that. - const text = await res.text() - return (text ? JSON.parse(text) : undefined) as T - } - - // ------------------------------- identity ------------------------------- - - whoami(): Promise { - return this.request('GET', '/me') - } - - // ----------------------------- usage / budget --------------------------- - - getBudget(): Promise { - return this.request('GET', '/budget') - } - - getUsage(): Promise { - return this.request('GET', '/usage') - } - - // ------------------------------- analytics ------------------------------ - // GitHub PR + review analytics — the same aggregation behind the app's - // /analytics dashboard, scoped to the token's customer. Window: pass - // start/end or days (server default: the last 30 days). - - getAnalyticsMetrics( - query?: AnalyticsMetricsQuery, - ): Promise { - return this.request( - 'GET', - '/analytics/metrics', - undefined, - query as Record | undefined, - ) - } - - getAnalyticsPullRequests( - query?: AnalyticsPullRequestsQuery, - ): Promise { - return this.request( - 'GET', - '/analytics/pull-requests', - undefined, - query as Record | undefined, - ) - } - - getAnalyticsReviews( - query?: AnalyticsReviewsQuery, - ): Promise { - return this.request( - 'GET', - '/analytics/reviews', - undefined, - query as Record | undefined, - ) - } - - // ---------------------------- agent sessions ----------------------------- - - startAgentSession(req: StartAgentSessionRequest): Promise { - return this.request('POST', '/sessions', req) - } - - async listAgentSessions(query?: ListAgentSessionsQuery): Promise { - const res = await this.request( - 'GET', - '/sessions', - undefined, - query as Record | undefined, - ) - return res.sessions - } - - getAgentSession(sessionId: string): Promise { - return this.request('GET', `/sessions/${encodeURIComponent(sessionId)}`) - } - - // Session-grouped search over step text, recap text, created PRs, and - // recap-embedding similarity. Each result says which arms matched. - searchSessions(query: SearchSessionsQuery): Promise { - return this.request( - 'GET', - '/sessions/search', - undefined, - query as unknown as Record, - ) - } - - // The session's full stored transcript as native session_records (transcript - // + lifecycle), ordered by feed_seq. - async getAgentSessionRecords(sessionId: string): Promise { - const res = await this.getAgentSessionRecordsPage(sessionId) - // The OpenAPI response type marks defaulted fields optional; on the wire - // the server always serializes every field (the frames-schema flavor). - return res.records as SessionRecord[] - } - - // The full records response (records + the open inbox slice + - // has_more/earliest_feed_seq), optionally resuming past a feed_seq cursor - // (protocol §4.3) — what the connect UI's REST poll fallback feeds its - // transcript store from. - getAgentSessionRecordsPage( - sessionId: string, - options: { afterSeq?: number } = {}, - ): Promise { - const query = - options.afterSeq != null && options.afterSeq > 0 ? `?after_seq=${options.afterSeq}` : '' - return this.request('GET', `/sessions/${encodeURIComponent(sessionId)}/records${query}`) - } - - // The session's conversation structure — turns and inbox messages, each - // message carrying its pending/delivered status (the server-side "queued" - // truth). Empty lists for single-shot sessions. - getAgentSessionTurns(sessionId: string): Promise { - return this.request('GET', `/sessions/${encodeURIComponent(sessionId)}/turns`) - } - - // The session-log manifest: the complete history archived into seq-ranged - // .jsonl.gz segments, each with a short-lived presigned download URL. Fetch - // the URLs immediately; the JSON API never carries the bytes. - getSessionLog(sessionId: string): Promise { - return this.request('GET', `/sessions/${encodeURIComponent(sessionId)}/log`) - } - - syncAgentSession(req: SyncAgentSessionRequest): Promise { - return this.request('POST', '/sessions/sync', req) - } - - replayAgentSession(sessionId: string, req: ReplayAgentSessionRequest): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sessionId)}/replay`, - req, - ) - } - - stopAgentSession(sessionId: string): Promise { - return this.request('POST', `/sessions/${encodeURIComponent(sessionId)}/stop`) - } - - // Post a human message into a durable (keyed) session's conversation. The - // inbox delivers it to the agent's Claude Code stdin at the next turn - // boundary, or wakes the session when idle. 409 for single-shot / closed - // sessions (no inbox loop to attend it). - // Returns the CREATED SessionMessage (protocol v2 §4.2) so callers key - // their optimistic chip on its id. `idempotencyKey` makes retries safe: - // the server dedupes per (session, key) and returns the original message. - sendSessionMessage( - sessionId: string, - message: string, - idempotencyKey?: string, - ): Promise { - return this.request('POST', `/sessions/${encodeURIComponent(sessionId)}/messages`, { - message, - idempotency_key: idempotencyKey ?? null, - } satisfies SendSessionMessageRequest) - } - - // The session's browser-IDE link: the membership-gated dashboard page for - // the live sandbox (app.ellipsis.dev/sandboxes/{id}) — the page starts - // code-server and does the sandbox-proxy handoff itself, so the URL carries - // no credential and is safe to share with any org member. 409 when the - // sandbox isn't running (send the session a message to wake it first). - getSessionIde(sessionId: string): Promise { - return this.request('GET', `/sessions/${encodeURIComponent(sessionId)}/ide`) - } - - // A preview port's link (a dev server running in the sandbox): the same - // dashboard page, deep-linked to the port. Same gate as the IDE URL. - getSessionPort(sessionId: string, port: number): Promise { - return this.request( - 'GET', - `/sessions/${encodeURIComponent(sessionId)}/ports/${port}`, - ) - } - - // --------------------------------- files --------------------------------- - // Agent file storage: persist a file to the platform and get back an - // org-membership-gated link. v1 is PNG-only with a 10 MiB cap, enforced - // server-side. - - uploadFile(req: CreateFileRequest): Promise { - return this.request('POST', '/files', req) - } - - // Newest-first metadata for the credential's customer's files. Metadata - // only — presigned download URLs are minted per explicit getFile. - async listFiles(query?: ListFilesQuery): Promise { - const res = await this.request( - 'GET', - '/files', - undefined, - query as Record | undefined, - ) - return res.files - } - - // Metadata + the gated URL + a short-lived presigned `download_url`. To pull - // the bytes locally, GET download_url immediately (it expires in ~60s; if it - // lapses, just call this again for a fresh one). - getFile(fileId: string): Promise { - return this.request('GET', `/files/${encodeURIComponent(fileId)}`) - } - - // Delete a file: it disappears from every read path and its gated link - // stops resolving (the server soft-deletes; storage accounting keeps - // charging for everything ever written). The - // server returns 204 with an empty body on success; 404 when the id is - // unknown to the credential's customer, 403 when the token isn't allowed to - // delete (e.g. a sandbox token). - deleteFile(fileId: string): Promise { - return this.request('DELETE', `/files/${encodeURIComponent(fileId)}`) - } - - // -------------------------------- reviews -------------------------------- - // A review IS a code_review agent session over one commit range, and its id - // IS the session id — so the session methods above (get, records, stream, - // stop) all work on a review id unchanged, and only the review-shaped parts - // (scope, findings, posting outcome) need endpoints of their own. - - createReview(request: CreateReviewRequest): Promise { - return this.request('POST', '/reviews', request) - } - - // The findings only exist once the review finalizes (they're collected from - // the sandbox at teardown), so a running review returns findings: [] — hence - // the stream-then-re-GET two-step the command uses. - getReview(reviewId: string): Promise { - return this.request('GET', `/reviews/${encodeURIComponent(reviewId)}`) - } - - // Newest first, findings omitted (counters only). Includes webhook-triggered - // reviews, so this is a PR's whole review history. - async listReviews(query: ListReviewsQuery = {}): Promise { - const res = await this.request( - 'GET', - '/reviews', - undefined, - query, - ) - return res.reviews - } - - // ----------------------------- agent configs ---------------------------- - - async listAgentConfigs(): Promise { - const res = await this.request('GET', '/agents/configs') - return res.configs - } - - // Opens a pull request that adds the config's YAML to the repo's agents/ - // directory; the agent goes live once it merges and syncs. - createAgentConfig(req: CreateAgentConfigRequest): Promise { - return this.request('POST', '/agents/configs', req) - } - - getAgentConfig(configId: string): Promise { - return this.request('GET', `/agents/configs/${encodeURIComponent(configId)}`) - } - - // ------------------------------ defaults -------------------------------- - // The default-config ladder (repo default -> account default -> bare), - // addressed by rung: `repository` is "owner/name" for a repo default and - // null/omitted for the account default — never a row id. Mutations are - // refused for sandbox tokens (403). - - async listAgentDefaults(): Promise { - const res = await this.request('GET', '/agents/defaults') - return res.defaults - } - - putAgentDefault(req: PutAgentDefaultRequest): Promise { - return this.request('PUT', '/agents/defaults', req) - } - - // Clears a rung: the account default when `repository` is omitted, that - // repo's default otherwise. 404 when the rung isn't set. - deleteAgentDefault(repository?: string): Promise { - return this.request('DELETE', '/agents/defaults', undefined, { repository }) - } - - // ------------------------------- variables -------------------------------- - // All three return the full current list (the backend echoes it after every - // mutation), so callers can render the resulting state. - - async listSandboxVariables(): Promise { - const res = await this.request('GET', '/secrets') - return res.variables - } - - async putSandboxVariables( - variables: SandboxVariableInput[], - ): Promise { - const res = await this.request('PUT', '/secrets', { - variables, - }) - return res.variables - } - - async deleteSandboxVariable(name: string): Promise { - const res = await this.request( - 'DELETE', - `/secrets/${encodeURIComponent(name)}`, - ) - return res.variables - } - - // ------------------------------- models --------------------------------- - - // The models a customer may select for their agent (GET /models) — the - // registry behind the dashboard's rate table, most expensive first. - async listSupportedModels(): Promise { - const res = await this.request('GET', '/models') - return res.models - } - - // ---------------------------- agent templates --------------------------- - - async listAgentTemplates(): Promise { - const res = await this.request('GET', '/agents/templates') - return res.templates - } - - getAgentTemplate(slug: string): Promise { - return this.request('GET', `/agents/templates/${encodeURIComponent(slug)}`) - } - - // ------------------------ integration discovery ------------------------- - // Read-only views of what's connected for the account. Slack and Linear - // listings 404 when that integration isn't connected; GitHub always works - // (an Ellipsis account is a GitHub account) and Sentry returns an empty list. - - getIntegrations(): Promise { - return this.request('GET', '/integrations') - } - - listGithubRepositories(): Promise { - return this.request('GET', '/integrations/github/repos') - } - - listGithubMembers(): Promise { - return this.request('GET', '/integrations/github/members') - } - - listSlackChannels(): Promise { - return this.request('GET', '/integrations/slack/channels') - } - - listSlackMembers(): Promise { - return this.request('GET', '/integrations/slack/members') - } - - listLinearTeams(): Promise { - return this.request('GET', '/integrations/linear/teams') - } - - listSentryOrganizations(): Promise { - return this.request('GET', '/integrations/sentry/organizations') - } - - // --------------------------- device-code auth --------------------------- - // Unauthenticated: the CLI has no credential yet — that's what it's obtaining. +// Both args are optional overrides; when omitted, each is resolved through the +// precedence chain (explicit → env → config → default) in config.ts. The token +// may legitimately be absent: the device-code auth routes are unauthenticated, +// and the server answers 401 for anything else, which reads as "run `agent +// login`" via friendlyErrorMessage. +export function api(base?: string, token?: string): Ellipsis { + return new Ellipsis({ + apiKey: resolveToken(token) ?? '', + baseUrl: resolveApiBase(base), + fetch: fetchWithUserAgent, + }) +} - startCliAuth(): Promise { - return this.request('POST', '/auth/cli/start') - } +// The server's own sentence for a failed call. The SDK's APIError.message is +// prefixed with the status and code for library consumers; a terminal user wants +// the sentence alone (a 429's remedy reads badly behind "429 error:"), so read it +// back off the parsed error body and keep the prefixed form as the fallback. +export function errorDetail(err: unknown): string { + if (!(err instanceof APIError)) return (err as Error).message + const body = err.body + if (body !== null && typeof body === 'object') { + const envelope = (body as { error?: { message?: unknown } }).error + if (envelope && typeof envelope.message === 'string') return envelope.message + const detail = (body as { detail?: unknown }).detail + if (typeof detail === 'string') return detail + } + return err.message +} - pollCliAuth(deviceCode: string): Promise { - return this.request('POST', '/auth/cli/poll', { device_code: deviceCode }) - } +// The full one-line account of a failure: the status, the server's message, and +// the request id it stamped, so a user can quote an exact log line to us. +export function describeApiError(err: APIError): string { + const requestId = err.requestId ? ` (request id: ${err.requestId})` : '' + return `${err.status} ${errorDetail(err)}${requestId}` } // Await a provider listing, mapping its 404 (Slack/Linear not connected for @@ -498,7 +66,7 @@ export async function requireConnected(provider: string, call: Promise): P try { return await call } catch (err) { - if (err instanceof ApiError && err.status === 404) { + if (err instanceof APIError && err.status === 404) { throw new Error( `${provider} is not connected. Connect it in the Ellipsis dashboard, then retry.`, ) @@ -506,52 +74,3 @@ export async function requireConnected(provider: string, call: Promise): P throw err } } - -export function buildQuery(query?: Record): string { - if (!query) return '' - const params = new URLSearchParams() - for (const [key, value] of Object.entries(query)) { - if (value === undefined || value === null) continue - // Repeat the key for arrays (FastAPI's `Query()` list convention). - if (Array.isArray(value)) { - for (const item of value) params.append(key, String(item)) - } else { - params.append(key, String(value)) - } - } - const qs = params.toString() - return qs ? `?${qs}` : '' -} - -// Pull the server's message and request id off a non-2xx response. The message -// keeps `agent` error output actionable instead of bare codes; the id comes from -// the `X-Request-ID` header (set on every API response) and falls back to the -// body, so an error carries something we can grep our logs for. -// -// The public API answers with `{"error": {code, message, request_id}}`. FastAPI's -// own validation and auth rejections still use `{"detail": ...}`, so both shapes -// are read: an unrecognized body would otherwise degrade to a bare status line. -export async function parseErrorResponse( - res: Response, -): Promise<{ detail: string; requestId?: string }> { - const headerRequestId = res.headers.get('x-request-id') ?? undefined - try { - const body = (await res.json()) as { - error?: { code?: unknown; message?: unknown; request_id?: unknown } - detail?: unknown - request_id?: unknown - } - const bodyRequestId = body.error?.request_id ?? body.request_id - const requestId = - headerRequestId ?? (typeof bodyRequestId === 'string' ? bodyRequestId : undefined) - if (typeof body.error?.message === 'string') { - return { detail: body.error.message, requestId } - } - if (typeof body.detail === 'string') return { detail: body.detail, requestId } - if (body.detail) return { detail: JSON.stringify(body.detail), requestId } - return { detail: res.statusText, requestId } - } catch { - // not JSON - return { detail: res.statusText, requestId: headerRequestId } - } -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 50ea446..4c66229 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { ApiClient } from './api' +import type { Ellipsis } from '@ellipsis-dev/sdk' import { setActiveHostToken } from './config' import type { CliAuthStart } from './types' @@ -18,10 +18,10 @@ export interface DeviceLoginResult { // Drives the device-code flow end to end (start -> poll -> persist token). // See documents/eng/ELLIPSIS_API_AND_CLI.md §5 in the backend repo. export async function deviceLogin( - api: ApiClient, + client: Ellipsis, handlers: DeviceLoginHandlers, ): Promise { - const start = await api.startCliAuth() + const start = await client.auth.cli.start() handlers.onPrompt(start) const intervalMs = Math.max(1, start.interval) * 1000 @@ -29,7 +29,7 @@ export async function deviceLogin( while (nowMs() < deadline) { await sleep(intervalMs) - const poll = await api.pollCliAuth(start.device_code) + const poll = await client.auth.cli.poll({ device_code: start.device_code }) switch (poll.status) { case 'pending': handlers.onPending?.() diff --git a/src/lib/output.ts b/src/lib/output.ts index c6e4631..e44bf2c 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -2,7 +2,7 @@ // structured output the same way. import { stringify as stringifyYaml } from 'yaml' -import { ApiError } from './api' +import { APIError, describeApiError, errorDetail } from './api' import { envToken } from './config' import { VERSION } from './constants' @@ -76,18 +76,18 @@ export function usd(amount: number): string { // where the token came from: an env token outranks the config file in the // precedence chain, so `agent login` alone can't replace it. export function friendlyErrorMessage(err: unknown): string { - if (err instanceof ApiError && err.status === 401) { + if (err instanceof APIError && err.status === 401) { return envToken() ? 'The server rejected ELLIPSIS_API_TOKEN. Check the token, or unset it and run `agent login`.' : 'Your login is invalid or has expired. Run `agent login` to re-authenticate.' } - // A 429 detail is written for a human to act on (which limit was hit, how to - // get it raised), so print it alone — the `METHOD /path failed: 429` prefix - // buries the remedy. - if (err instanceof ApiError && err.status === 429) return err.detail - if (err instanceof ApiError && !UPGRADE_HINT_EXEMPT.has(err.status)) { - return `${err.message}\n${upgradeHint()}` + // A 429 message is written for a human to act on (which limit was hit, how + // to get it raised), so print it alone — the status prefix buries the remedy. + if (err instanceof APIError && err.status === 429) return errorDetail(err) + if (err instanceof APIError && !UPGRADE_HINT_EXEMPT.has(err.status)) { + return `${describeApiError(err)}\n${upgradeHint()}` } + if (err instanceof APIError) return describeApiError(err) return (err as Error).message } diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 8fca574..426bfe1 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,5 +1,5 @@ import { sessionStatusWord } from '@ellipsis-dev/sdk/stream' -import type { AgentSessionWire } from '@ellipsis-dev/sdk' +import type { Session as FrameSession } from '@ellipsis-dev/sdk' import { theme } from './theme' import type { AgentSession, @@ -28,44 +28,24 @@ export const SELECTION_GLYPH = '▶' // to read well. A session that came from a surface (a Slack/GitHub/Linear // mention) is steered THERE, not here, because that surface is where the // agent's answers post. -// -// Pre-`prompting` servers (older deployments) omit the field: fall back to the -// local keyed/closed read those binaries already shipped with. export function connectability(session: AgentSession): { canSend: boolean reason?: string } { - const prompting = session.prompting - if (prompting) { - if (prompting.enabled) return { canSend: true } - const detail = prompting.detail?.trim() - return { - canSend: false, - reason: detail - ? `${detail} Opening watch-only.` - : 'this session does not accept messages — opening watch-only', - } + if (session.prompting.enabled) return { canSend: true } + const detail = session.prompting.detail?.trim() + return { + canSend: false, + reason: detail + ? `${detail} Opening watch-only.` + : 'this session does not accept messages — opening watch-only', } - if (!session.session_key) { - return { - canSend: false, - reason: 'this session is single-shot (no durable conversation) — opening watch-only', - } - } - if (session.session_state === 'closed') { - return { - canSend: false, - reason: - 'this conversation is closed (a new event on its surface starts a successor) — opening watch-only', - } - } - return { canSend: true } } // The one-word display status for a session row (the SDK's surface-first // projection over the raw status). export function rowStatusWord(session: AgentSession): string { - return sessionStatusWord(session as unknown as AgentSessionWire) + return sessionStatusWord(session as unknown as FrameSession) } // Statuses in which the agent is actively doing something (the sidebar's @@ -146,16 +126,13 @@ function trimZero(s: string): string { return s.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1') } -// The nav row's right-hand metadata: how much work the agent did (turns, -// tokens, spend) and when it last moved. Turns come from tokens_info.num_turns -// — the list row's own counter — and spend is the sum of the four millicent -// cost columns, the same total the chat footer shows. A just-started session -// drops the empty bits rather than showing "0 turns · 0 · $0.00". No source tag: -// the nav lists cloud sessions only, so it would read the same on every row. +// The nav row's right-hand metadata: how much work the agent did (tokens, +// spend) and when it last moved. Spend is the sum of the four millicent cost +// columns, the same total the chat footer shows. A just-started session drops +// the empty bits rather than showing "0 · $0.00". No source tag: the nav lists +// cloud sessions only, so it would read the same on every row. export function rowMeta(session: AgentSession, now: Date = new Date()): string { const bits: string[] = [] - const turns = numberField(session.tokens_info, 'num_turns') - if (turns > 0) bits.push(`${turns} ${turns === 1 ? 'turn' : 'turns'}`) if (session.tokens_total > 0) bits.push(compactTokens(session.tokens_total)) const millicents = session.cost_tokens + session.cost_sandbox_cpu + session.cost_sandbox_memory + session.cost_fee @@ -164,23 +141,10 @@ export function rowMeta(session: AgentSession, now: Date = new Date()): string { return bits.join(' · ') } -// Where the session runs. The list row has no top-level `source` (that's the -// stream's wire shape); it rides `input.source` instead, with laptop syncs the -// only non-cloud kind the nav distinguishes. +// Where the session runs — laptop syncs are the only non-cloud kind the nav +// distinguishes. Every session-returning route carries `source` top-level. export function sessionSource(session: AgentSession): string { - if (session.source === 'laptop') return 'laptop' - const input = session.input - if (input && typeof input === 'object') { - const source = (input as Record).source - if (source === 'laptop') return 'laptop' - } - return 'cloud' -} - -function numberField(container: unknown, key: string): number { - if (!container || typeof container !== 'object') return 0 - const value = (container as Record)[key] - return typeof value === 'number' && isFinite(value) ? value : 0 + return session.source === 'laptop' ? 'laptop' : 'cloud' } // The sidebar's status bands, top to bottom: live conversations, then parked @@ -361,7 +325,7 @@ export function applyComposerChoices( // re-add a repo the user just unchecked. Dropping it also moves default- // config resolution off that repo's rung, which is the honest reading of // "not this one". - if (req.repository !== undefined && !choices.repos.includes(req.repository)) { + if (req.repository != null && !choices.repos.includes(req.repository)) { delete req.repository } } diff --git a/src/lib/stream.ts b/src/lib/stream.ts index cc3057d..2b5dee0 100644 --- a/src/lib/stream.ts +++ b/src/lib/stream.ts @@ -22,7 +22,8 @@ export function resolveWsBase(apiBase?: string): string { } // The bearer door's stream URL: /sessions/{id}/stream plus the SDK's -// handshake query (`protocol=2`, `after_seq` when resuming). +// handshake query (`protocol`, `after_seq` when resuming) — the version comes +// from the SDK, so a protocol bump ships with an SDK bump. export function buildStreamUrl(wsBase: string, sessionId: string, query: string): string { return `${wsBase}/sessions/${encodeURIComponent(sessionId)}/stream?${query}` } diff --git a/src/lib/types.ts b/src/lib/types.ts index 527aaf3..8beee11 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,56 +1,165 @@ -// TypeScript types for the backend public API request/response models. +// The CLI's names for the API's types. // -// The session-stream surface (records, inbox messages, turns, the enriched -// session wire shape, and their request/response DTOs) comes from -// @ellipsis-dev/sdk — generated from the server's schema, never hand-written — -// re-exported below under the CLI's historical names. Everything else (the -// endpoints outside the SDK's REST surface: session list/start, configs, -// sandboxes, integrations, …) remains a hand-rolled mirror of the Pydantic -// models in ellipsis's public API router (v1_router.py) until the SDK's -// OpenAPI surface widens. -// Nested config/input/output payloads are typed loosely (the CLI only -// displays summary fields). +// Every wire shape comes from @ellipsis-dev/sdk — generated from the server's +// OpenAPI spec, never hand-written — and is re-exported here under the name the +// CLI uses so a rename on the server surfaces as a type error rather than a +// field that silently reads `undefined`. Only the CLI's own local shapes +// (query-option bags it assembles before calling, and the loosely-typed GitHub +// user it caches on disk) are declared here. import type { - AgentSessionSource, - AgentSessionStatus, - SessionMessageWire, - SessionPrompting, - SessionRecordWire, - SessionState, - SessionSurface, + components, + Ellipsis, + SessionRecord as SessionRecordFrame, + SessionMessage as SessionMessageFrame, } from '@ellipsis-dev/sdk' -export type { - AgentSessionSource, - AgentSessionStatus, - AgentSessionWire, - // The reviews surface. A review's `id` IS a session id, so everything above - // applies to it unchanged — which is why there is no review-specific - // status, stream, or cost type. - CreateReviewRequest, - Finding, - ListReviewsResponse, - ListSessionRecordsResponse, - ListSessionTurnsResponse, - ResolvedReviewScope, - Review, - ReviewCounters, - ReviewScope, - SendSessionMessageRequest, - SessionPrompting, - SessionState, - SessionSurface, -} from '@ellipsis-dev/sdk' +type S = components['schemas'] + +// --------------------------- sessions & records --------------------------- + +export type AgentSession = S['Session'] +export type AgentSessionSource = S['AgentSessionSource'] +export type AgentSessionStatus = S['AgentSessionStatus'] +export type SessionState = S['SessionState'] +export type SessionSurface = S['SessionSurface'] +export type SessionPrompting = S['SessionPrompting'] +// The frames flavor, not `S['SessionRecord']`: the spec marks defaulted fields +// optional, but on the wire the server always serializes every field, and the +// SDK's transcript store types its inputs this way. Using it here keeps records +// flowing from REST straight into the store without a cast at each call site. +export type SessionRecord = SessionRecordFrame +export type SessionMessage = SessionMessageFrame +export type SessionExecution = S['SessionExecution'] +export type ListSessionRecordsResponse = S['SessionRecordsListResponse'] +export type ListAgentSessionsResponse = S['SessionsListResponse'] +export type StartAgentSessionRequest = NonNullable[0]> +export type StartAgentSessionResponse = S['StartAgentSessionResponse'] +export type ReplayAgentSessionRequest = NonNullable[1]> +export type SendSessionMessageRequest = S['SendSessionMessageRequest'] +export type HandoffAgentSessionParams = S['HandoffAgentSessionParams'] +export type SyncAgentSessionRequest = Parameters[0] +export type SyncAgentSessionResponse = S['SyncAgentSessionResponse'] +export type SessionLogSegment = S['SessionLogSegment'] +export type GetSessionLogResponse = S['GetSessionLogResponse'] +export type GetSessionIdeResponse = S['GetSessionIdeResponse'] +export type GetSessionPortResponse = S['GetSessionPortResponse'] + +// ------------------------------ session search ---------------------------- + +export type SessionSearchScope = S['SessionSearchScope'] +export type RecordSearchHit = S['RecordHit'] +export type SessionSearchResult = S['SessionSearchResult'] +export type SearchSessionsResponse = S['SessionSearchResponse'] +export type GithubAccountSnippet = S['GithubAccountSnippet'] + +// -------------------------- configs / defaults ---------------------------- + +export type AgentConfig = S['AgentConfig'] +export type SavedAgentConfig = S['Config'] +export type ListAgentConfigsResponse = S['AgentConfigsListResponse'] +export type CreateAgentConfigRequest = Parameters[0] +export type CreatedAgentConfig = S['CreateAgentConfigResponse'] +export type AgentDefaultView = S['AgentDefault'] +export type ListAgentDefaultsResponse = S['AgentDefaultsListResponse'] +export type PutAgentDefaultRequest = S['PutAgentDefaultRequest'] + +// -------------------------------- templates ------------------------------- + +export type AgentTemplate = S['AgentTemplate'] +export type ListAgentTemplatesResponse = S['AgentTemplatesListResponse'] + +// --------------------------------- models --------------------------------- + +export type ModelManufacturer = S['ModelManufacturer'] +export type ModelRateCard = S['ModelRateCardApi'] +export type SupportedModel = S['Model'] +export type GetSupportedModelsResponse = S['ModelsListResponse'] + +// -------------------------------- reviews --------------------------------- +// A review's `id` IS a session id, so the session types above apply to it +// unchanged — hence no review-specific status, stream, or cost type. + +export type Review = S['Review'] +export type ReviewScope = S['ReviewScope'] +export type ResolvedReviewScope = S['ResolvedReviewScope'] +export type ReviewCounters = S['ReviewCounters'] +export type Finding = S['ReviewFinding'] +export type CreateReviewRequest = S['CreateReviewRequest'] +export type ListReviewsResponse = S['ReviewsListResponse'] +export type CodeReviewRunStatus = S['CodeReviewRunStatus'] + +// --------------------------------- files ---------------------------------- + +export type FileView = S['File'] +export type CreateFileRequest = Parameters[0] +export type CreateFileResponse = S['CreateFileResponse'] +export type GetFileResponse = S['GetFileResponse'] +export type ListFilesResponse = S['FilesListResponse'] + +// ------------------------------- secrets ---------------------------------- +// Customer-scoped environment variables injected into a sandbox when an agent +// config names them. Values are write-only: the API accepts them but never +// returns them, so the summary carries only the name and timestamps. -// The CLI's historical names for the SDK's wire models. -export type SessionRecord = SessionRecordWire -export type SessionMessage = SessionMessageWire +export type SandboxVariableSummary = S['Secret'] +export type SandboxVariableInput = S['SecretInput'] +export type GetSandboxVariablesResponse = S['SecretsListResponse'] +export type PutSandboxVariablesRequest = S['PutSecretsRequest'] -// ------------------------------- identity ------------------------------- +// ----------------------------- usage / budget ----------------------------- -// The GitHub user behind a user_id, when we have it cached. Loosely typed: the -// CLI only reads `login`; the rest of the GithubUser fields are passed through. +export type BudgetWindow = S['BudgetWindow'] +export type BudgetSummary = S['BudgetSummary'] +export type UsageDailyPoint = S['UsageDailyPoint'] +export type ModelUsageBreakdown = S['ModelUsageBreakdown'] +export type UsageDashboard = S['GetUsageDashboardResponse'] + +// ------------------------------- analytics -------------------------------- + +export type AnalyticsAccountType = 'all' | 'user' | 'bot' +export type AnalyticsMetricsTotals = S['AnalyticsMetricsTotals'] +export type AnalyticsRepoUsage = S['AnalyticsRepoUsage'] +export type ContributorUsage = S['ContributorUsage'] +export type ReviewerUsage = S['ReviewerUsage'] +export type ReviewAuthorFacet = S['ReviewAuthorFacet'] +export type ReviewsDayBucket = S['ReviewsDayBucket'] +export type ReviewsTotals = S['ReviewsTotals'] +export type PullRequestsDayBucket = S['PullRequestsDayBucket'] +export type PullRequestsTotals = S['PullRequestsTotals'] +export type GetAnalyticsMetricsResponse = S['GetAnalyticsMetricsResponse'] +export type GetAnalyticsPullRequestsResponse = S['GetAnalyticsPullRequestsResponse'] +export type GetAnalyticsReviewsResponse = S['GetAnalyticsReviewsResponse'] + +// -------------------------- integration discovery ------------------------- + +export type GetIntegrationsResponse = S['GetIntegrationsResponse'] +export type GithubIntegrationSummary = S['GithubIntegrationSummary'] +export type SlackIntegrationSummary = S['SlackIntegrationSummary'] +export type LinearIntegrationSummary = S['LinearIntegrationSummary'] +export type JiraIntegrationSummary = S['JiraIntegrationSummary'] +export type SentryOrganizationSummary = S['SentryOrganizationSummary'] +export type RepositorySummary = S['GithubRepository'] +export type GithubMemberSummary = S['GithubMember'] +export type SlackMemberSummary = S['SlackMember'] +export type SlackChannelSummary = S['SlackChannel'] +export type LinearTeamSummary = S['LinearTeam'] +export type LinkedSlackIdentity = S['LinkedSlackIdentity'] +export type LinkedGithubIdentity = S['LinkedGithubIdentity'] +export type ListGithubRepositoriesResponse = S['GithubRepositoriesListResponse'] +export type ListGithubMembersResponse = S['GithubMembersListResponse'] +export type ListSlackChannelsResponse = S['SlackChannelsListResponse'] +export type ListSlackMembersResponse = S['SlackMembersListResponse'] +export type ListLinearTeamsResponse = S['LinearTeamsListResponse'] +export type ListSentryOrganizationsResponse = S['SentryOrganizationsListResponse'] + +// -------------------------------- identity -------------------------------- + +export type WhoAmI = S['WhoAmIResponse'] + +// 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 +// binary's copy must stay readable. export interface GhUser { id: number login: string @@ -58,325 +167,21 @@ export interface GhUser { [key: string]: unknown } -export interface WhoAmI { - customer_id: string - customer_login: string - user_id: string | null - gh_user: GhUser | null - api_key_id: string | null - sandbox_id: string | null -} - -// ----------------------------- usage / budget --------------------------- - -export interface BudgetWindow { - start: string | null - end: string | null -} - -export interface BudgetSummary { - period: string - window: BudgetWindow - budget_usd: number - spent_usd: number - remaining_usd: number - fraction_used: number - pause_at_limit: boolean -} - -export interface UsageDailyPoint { - date: string - tokens: number - tokens_input: number - tokens_output: number - tokens_cache_read: number - tokens_cache_creation: number - cost_tokens_millicents: number - cost_sandbox_cpu_millicents: number - cost_sandbox_memory_millicents: number - cost_fee_millicents: number -} - -export interface ModelUsageBreakdown { - model_id: string - tokens: number - cost_tokens_millicents: number - cost_sandbox_cpu_millicents: number - cost_sandbox_memory_millicents: number - cost_fee_millicents: number -} - -export interface UsageDashboard { - period_start: string - period_end: string - total_tokens: number - total_cost_millicents: number - prior_total_tokens: number - prior_total_cost_millicents: number - daily: UsageDailyPoint[] - by_model: ModelUsageBreakdown[] -} - -// ----------------------------- agent sessions ---------------------------- - -// Loosely typed: the CLI reads a handful of summary fields and otherwise treats -// the session as opaque JSON. See AgentSession in the backend for the full shape. -export interface AgentSession { - id: string - customer_id: string - created_at: string - updated_at: string - status: AgentSessionStatus - status_reason: string | null - // Why the session ended, finer than status; null until terminal. - exit_status?: string | null - source?: AgentSessionSource - agent_config_id: string | null - // Durable-conversation identity (stateful sessions): a keyed session runs - // the cloud session loop and accepts /messages; null = single-shot. - session_key?: string | null - session_state?: 'idle' | 'running' | 'closed' | null - // Customer-facing status surface (session_surface.py). `status` is the derived - // single word to display (working/waiting/sleeping/starting/…); `session` + - // `run` are the two raw axes. null for un-keyed (laptop) sessions and on list - // rows that don't populate it. Prefer surface.status over the raw `status`. - surface?: { - session: 'alive' | 'sleeping' | 'closed' | null - run: string | null - status: string | null - } | null - // Whether a human may prompt this session, and the curated reason when they - // may not — the SAME projection POST /messages enforces, so `connectability` - // opens a composer only where a send would succeed. `detail` is server-authored - // copy: render it verbatim rather than switching on `blocked_reason`, so a new - // refusal reason reads correctly without a CLI release. Optional because - // servers predating the field omit it. - prompting?: SessionPrompting | null - cost_tokens: number - cost_sandbox_cpu: number - cost_sandbox_memory: number - cost_fee: number - tokens_total: number - metadata: Record - // Present on the POST /sessions response only (StartAgentSessionResponse): - // which config the session runs under and which rung of the defaults ladder - // chose it (null when an explicit config/template bypassed resolution). - resolved_config_name?: string | null - resolution_source?: 'repo_default' | 'account_default' | 'none' | null - [key: string]: unknown -} - -export interface SavedAgentConfig { - id: string - customer_id: string - created_at: string - updated_at: string - deleted: boolean - last_agent_session_id: string | null - last_agent_session_created_at: string | null - last_synced_commit_sha: string | null - last_sync_error: string | null - agent_config: Record - [key: string]: unknown -} - -// Inline agent config payload accepted by POST /sessions. Opaque to the -// CLI — passed straight through from a user-supplied JSON file. -export type AgentConfig = Record - -// --------------------------- request / response ------------------------- - -// Laptop -> cloud handoff params: start a fresh session on the built-in -// handoff config, chained to the handed-off session (parent_kind=handoff). -// Mutually exclusive with config_id / config / template_id. -export interface HandoffAgentSessionParams { - parent_session_id: string - repo: string - // The WIP commit pushed to refs/ellipsis/handoff/ — the sandbox - // checkout target. - sha: string - ref?: string -} - -export interface StartAgentSessionRequest { - config_id?: string - config?: AgentConfig - template_id?: string - handoff?: HandoffAgentSessionParams - // The "owner/name" repository the CLI is standing in (origin remote). With - // no explicit config source it selects the repo rung of the server's - // default-config ladder (repo default -> account default -> bare config), - // and it is always merged into the sandbox repository set (cloned at the - // default branch), even alongside --config. Unknown/foreign repos are - // ignored server-side. - repository?: string - // No `source`: the server derives a session's provenance from the credential - // (a user token => `cli`), so it can't be spoofed by the request body. - metadata?: Record - // A partial agent config merged onto the chosen config and re-validated - // server-side, e.g. raise just this session's budget. Supply it as a - // structured mapping (config_override) or a YAML/JSON string - // (config_override_yaml) — not both. Only meaningful with config_id/template_id. - config_override?: Record - config_override_yaml?: string - // Per-session instructions appended to the initial user query at build time, - // after the config's shared `claude.system` system prompt. Distinct from the - // system prompt, which is identical for every session of a config. - prompt?: string - // Start with no initial message: the sandbox spins up, Claude Code sits idle - // at the prompt, and the first message sent to the session opens turn 0, - // exactly like a local `claude`. Sent for a promptless --connect start (a - // bare `agent`). Mutually exclusive with prompt; the server ignores it when - // the resolved config is not interactive (that session runs its workflow). - idle_start?: boolean - // Skip the sandbox image cache for this session's initial provision: a - // fresh full build (image layers + clone + image.setup from scratch), - // whose snapshot then refreshes the cache for later runs. Wakes of a - // durable session provision through the cache as usual. The --rebuild flag. - force_rebuild?: boolean -} - -// Replay payload for POST /sessions/{id}/replay. Re-runs an existing -// session's trigger input. Reuses the original session's frozen config -// snapshot unless config_id is given. The override fields behave exactly as on -// StartAgentSessionRequest (mapping or string, not both). `prompt` is omitted -// to inherit the original session's prompt, set to "" to clear it. -export interface ReplayAgentSessionRequest { - config_id?: string - config_override?: Record - config_override_yaml?: string - prompt?: string -} - -// One hook-driven transcript sync from this laptop (POST /sessions/sync). -// The transcript is redacted client-side, gzipped, then base64-encoded. -export interface SyncAgentSessionRequest { - cc_session_id: string - transcript_gzip_b64: string - // Which Claude Code hook fired the sync: Stop (mid-session, once per turn) - // or SessionEnd (the process terminated). - reason: 'stop' | 'session_end' - // The enrolled repository ("owner/name", from the cwd's git remote), the - // cwd, and the checked-out branch — laptop-side context for the session row. - repo?: string - cwd?: string - git_branch?: string -} - -export interface SyncAgentSessionResponse { - session_id: string - process_id: string - event_count: number - // False when the server already stored a snapshot at least this long - // (longest-snapshot-wins) — acknowledged, nothing written. Still success. - accepted: boolean -} - -export interface ListAgentSessionsResponse { - sessions: AgentSession[] -} - -export interface ListAgentConfigsResponse { - configs: SavedAgentConfig[] -} - -// One rung of the default-config ladder (GET /defaults). Rungs are -// addressed by `repository`: "owner/name" for a repo default, null for the -// account-wide default — never by row id. -export interface AgentDefaultView { - id: string - repository: string | null - config_id: string - // The pointed-at config's name; null when the config is gone (see broken). - config_name: string | null - // Why this rung can't serve sessions (config_deleted | config_disabled | - // config_pending_pr | repo_inaccessible); null when healthy. - broken: string | null - updated_at: string -} - -export interface ListAgentDefaultsResponse { - defaults: AgentDefaultView[] -} - -// Body of PUT /defaults: point a rung at a config. `repository` omitted -// sets the account default; "owner/name" sets that repo's default. -export interface PutAgentDefaultRequest { - repository?: string - config_id: string -} - -// Create-config payload for POST /configs. Exactly one of `config` (inline) -// or `template_id` (a gallery template slug). `repository` is a bare repo name -// in the caller's account — the owner is always the account. -export interface CreateAgentConfigRequest { - config?: AgentConfig - template_id?: string - repository: string - // File path within the repo. Omit for the default agents/.yaml; if set - // it must be a location Ellipsis syncs (.yaml/.yml under agents/, .agents/, - // ellipsis/, or .ellipsis/ at any depth). - path?: string -} - -// Result of creating a config: the pending row plus the pull request that adds -// its YAML file. The agent goes live once that PR merges and syncs. -export interface CreatedAgentConfig { - config: SavedAgentConfig - path: string - pull_request_url: string -} - -// A built-in starter template served by GET /templates. `yaml` is the -// schema-valid agent config the CLI writes to disk; the rest is display copy. -export interface AgentTemplate { - slug: string - name: string - description: string - tags: string[] - summary: string - use_case: string - yaml: string -} - -export interface ListAgentTemplatesResponse { - templates: AgentTemplate[] - // The verbatim config of the run-on-demand template behind the dashboard's - // first-run CTA (`recent-work-summary`), served with the gallery so the hero - // can show the exact agent it starts. - first_run_yaml: string -} - -// USD cents per 1M tokens, one field per pricing lane. A provider without -// prompt caching reports 0 for the cache lanes. -export interface ModelRateCard { - input_cents_per_1m_tokens: number - cache_write_5m_cents_per_1m_tokens: number - cache_write_1h_cents_per_1m_tokens: number - cache_read_cents_per_1m_tokens: number - output_cents_per_1m_tokens: number -} +// ------------------------------- cli auth --------------------------------- -// Who built the model, for per-vendor display. Not who serves it: the GPT -// models reach us through Bedrock, so routing would attribute an OpenAI model -// to AWS. A union rather than an enum, so an unrecognized value is a type -// error here instead of silently rendering as a known vendor. -export type ModelManufacturer = 'anthropic' | 'openai' | 'zai' - -// One model a customer may select for their agent, from the registry behind -// the dashboard's rate table. `is_default_agent_model` marks what the server -// resolves "Default" to. -export interface SupportedModel { - id: string - display_name: string - manufacturer: ModelManufacturer - is_default_agent_model: boolean - rate_card: ModelRateCard -} +export type CliAuthStart = S['StartCliAuthResponse'] +export type CliAuthPoll = S['PollCliAuthResponse'] +export type CliAuthPollStatus = + | 'pending' + | 'approved' + | 'denied' + | 'expired' + | 'already_claimed' -export interface GetSupportedModelsResponse { - models: SupportedModel[] -} +// ------------------------- CLI-local query shapes ------------------------- +// The option bags the CLI assembles before calling the SDK. They mirror the +// generated methods' parameter objects; they exist so command modules can name +// and pass around a query without importing the SDK's inline parameter types. export interface ListAgentSessionsQuery { config_id?: string @@ -385,9 +190,8 @@ export interface ListAgentSessionsQuery { start?: string end?: string limit?: number - // A GitHub account id (GET /integrations/github/members); scopes the list to - // sessions attributed to that developer. The CLI resolves it from a --author - // login. + // A GitHub account id (`agent github members`); scopes the list to sessions + // attributed to that developer. The CLI resolves it from a --author login. author_id?: number // "owner/name" or a bare repository name. Sessions that name their // repository only inside their agent config — dashboard starts, cron runs, @@ -399,63 +203,12 @@ export interface ListAgentSessionsQuery { unfinished?: boolean } -// ----------------------------- session records --------------------------- - -// One immutable archived segment of the session log (GET /sessions/{id}/log): -// its feed_seq range plus a short-lived presigned S3 GET. Segments are gzip -// members — download them in order and concatenate for the whole log. -export interface SessionLogSegment { - start_feed_seq: number - end_feed_seq: number - record_count: number - bytes: number - download_url: string - expires_in: number -} - -// The session-log manifest: the complete, ordered, downloadable history of a -// session, archived into seq-ranged .jsonl.gz segments. For a running session -// `latest_feed_seq` may exceed `archived_through_feed_seq`; `caught_up` says -// whether the manifest is the whole story yet. -export interface GetSessionLogResponse { - format: string - session_id: string - // The retention head: the first feed_seq still available (null = nothing - // recorded yet). - earliest_feed_seq: number | null - // The highest feed_seq covered by an archived segment (0 = none yet). - archived_through_feed_seq: number - // The feed head (the last allocated feed_seq). - latest_feed_seq: number - caught_up: boolean - segments: SessionLogSegment[] -} - -// GET /sessions/{id}/ide (`agent session ide`): the live sandbox's -// code-server tunnel URL. Unguessable, customer-scoped at discovery, and dead -// once the sandbox is torn down — fetch it fresh on every open, never store it. -export interface GetSessionIdeResponse { - url: string -} - -// GET /sessions/{id}/ports/{port} (`agent session port`): the tunnel URL -// for one of the sandbox's preview ports (a dev server the agent or the IDE -// user started). Same lifetime/gating as the IDE URL. -export interface GetSessionPortResponse { - url: string - port: number -} - -// ----------------------------- session search ---------------------------- - -export type SessionSearchScope = 'records' | 'recaps' | 'both' - export interface SearchSessionsQuery { q: string scope?: SessionSearchScope source?: AgentSessionSource[] author_id?: number[] - agent_config_id?: string[] + config_id?: string[] session_ids?: string[] repo?: string status?: AgentSessionStatus[] @@ -464,204 +217,21 @@ export interface SearchSessionsQuery { limit?: number } -// One session record matching the search, denormalized with enough session -// context to render a result row (backend LogSearchHit). -export interface RecordSearchHit { - id: string - session_execution_id: string | null - agent_session_id: string - stream_seq: number - record_type: string - created_at: string - snippet: string - [key: string]: unknown -} - -// One search result session. `matched` lists which arms hit: -// "records" | "recap" | "pr" | "similar". -export interface SessionSearchResult { - session: AgentSession - matched: string[] - recap_snippet: string | null - record_hits: RecordSearchHit[] - // Total record hits within the search window; may exceed record_hits.length - // (which the server caps), so "and N more" can render. - record_hit_count: number -} - -// The GITHUB_USER attributions among the results, keyed by attribution_id, so -// the CLI can show author logins without a second lookup. -export interface GithubAccountSnippet { - id: number - login: string - type: string - avatar_url: string -} - -export interface SearchSessionsResponse { - results: SessionSearchResult[] - attributed_users: Record -} - -// -------------------------- integration discovery ------------------------ -// Read-only views of what's connected for the account (GET /integrations -// and the per-provider listings). Responses never include secrets. - -export interface GithubIntegrationSummary { - account_login: string - account_type: string - repository_selection: 'all' | 'selected' - suspended: boolean - repository_count: number -} - -export interface SlackIntegrationSummary { - team_id: string - team_name: string - operations_channel_id: string | null -} - -export interface LinearTeamSummary { - id: string - name: string - key: string | null - // Whether Ellipsis is enabled for this team. - is_enabled: boolean -} - -export interface LinearIntegrationSummary { - organization_id: string - teams: LinearTeamSummary[] -} - -export interface JiraIntegrationSummary { - cloud_id: string -} - -export interface SentryOrganizationSummary { - integration_id: string - organization_slug: string -} - -// A key is null (or an empty list for sentry) when that integration is not -// connected, so the response always shows the full universe of integrations. -export interface GetIntegrationsResponse { - github: GithubIntegrationSummary | null - slack: SlackIntegrationSummary | null - linear: LinearIntegrationSummary | null - jira: JiraIntegrationSummary | null - sentry: SentryOrganizationSummary[] -} - -// A repository connected to the installation: a valid `repository` for -// POST /configs and for repository lists in an agent config. -export interface RepositorySummary { - id: number - name: string - full_name: string - private: boolean - default_branch: string | null - description: string | null -} - -export interface ListGithubRepositoriesResponse { - repositories: RepositorySummary[] -} - -export interface LinkedSlackIdentity { - slack_user_id: string - slack_email: string | null -} - -export interface LinkedGithubIdentity { - id: number - login: string | null -} - -// An org member (or the account itself for a personal customer). `id` is the -// universe of author_id values for session list/search queries. -export interface GithubMemberSummary { - id: number - login: string | null - name: string | null - avatar_url: string | null - // null for a personal (user) account, which has no org roles. - role: string | null - slack: LinkedSlackIdentity | null -} - -export interface ListGithubMembersResponse { - members: GithubMemberSummary[] -} - -export interface SlackMemberSummary { - id: string - name: string | null - real_name: string | null - display_name: string | null - email: string | null - github: LinkedGithubIdentity | null -} - -export interface ListSlackMembersResponse { - team_id: string - team_name: string - members: SlackMemberSummary[] -} - -export interface SlackChannelSummary { - id: string - name: string | null - is_private: boolean | null - is_member: boolean | null -} - -export interface ListSlackChannelsResponse { - team_id: string - team_name: string - channels: SlackChannelSummary[] -} - -export interface ListLinearTeamsResponse { - organization_id: string - teams: LinearTeamSummary[] -} - -export interface ListSentryOrganizationsResponse { - organizations: SentryOrganizationSummary[] -} - -// -------------------------- sandbox variables --------------------------- -// Customer-scoped environment variables injected into a sandbox when an agent -// config names them. Values are write-only: the API accepts them but never -// returns them, so the summary carries only the name and timestamps. - -export interface SandboxVariableSummary { - name: string - created_at: string - updated_at: string -} - -export interface GetSandboxVariablesResponse { - variables: SandboxVariableSummary[] -} - -export interface SandboxVariableInput { - name: string - value: string +export interface ListFilesQuery { + // Scope to one run's uploads. + session_id?: string + limit?: number } -export interface PutSandboxVariablesRequest { - variables: SandboxVariableInput[] +export interface ListReviewsQuery { + owner?: string + repo?: string + pull_request_number?: number + status?: S['CodeReviewRunStatus'] + limit?: number } -// ------------------------------- analytics ------------------------------- -// Mirrors of the /analytics/* responses (analytics_service.py) — the same -// aggregation behind the app's /analytics dashboard, token-authed. The CLI -// renders the leaderboards and totals; feed items and day buckets it only -// passes through to --json are typed loosely. - -// Shared window params: explicit start/end (ISO timestamps) or a `days` +// Shared analytics window: explicit start/end (ISO timestamps) or a `days` // look-back (mutually exclusive with start; server default: last 30 days). export interface AnalyticsWindowQuery { days?: number @@ -669,9 +239,6 @@ export interface AnalyticsWindowQuery { end?: string } -// all = everyone, user = humans only, bot = apps/agents only. -export type AnalyticsAccountType = 'all' | 'user' | 'bot' - export interface AnalyticsMetricsQuery extends AnalyticsWindowQuery { repo?: string[] // "owner/name" author?: string[] // PR-author logins @@ -679,67 +246,6 @@ export interface AnalyticsMetricsQuery extends AnalyticsWindowQuery { status?: string[] // open | draft | merged | closed } -// A person or app that reviewed PRs in the window. -export interface ReviewerUsage { - login: string - avatar_url: string | null - reviews: number - approved: number - changes_requested: number - comments: number - lines_reviewed: number -} - -// An author who merged a PR in the window. -export interface ContributorUsage { - login: string - avatar_url: string | null - prs_merged: number - reviews: number - additions: number - deletions: number - ai_attributed_prs: number -} - -export interface AnalyticsRepoUsage { - repo_full_name: string - prs_merged: number - reviews: number - active_contributors: number - ai_attributed_prs: number - additions: number - deletions: number -} - -export interface AnalyticsMetricsTotals { - prs_opened: number - prs_merged: number - prs_closed: number - reviews: number - prs_reviewed: number - approved: number - changes_requested: number - commented: number - review_comments: number - additions: number - deletions: number - commits: number - active_contributors: number - open_prs: number - median_time_to_merge_hours: number - median_time_to_first_review_hours: number -} - -export interface GetAnalyticsMetricsResponse { - series: Array> - totals: AnalyticsMetricsTotals - repositories: AnalyticsRepoUsage[] - contributors: ContributorUsage[] - reviewers: ReviewerUsage[] - available_repos: Array<{ repo_full_name: string; prs: number }> - available_authors: Array<{ login: string; prs: number }> -} - export interface AnalyticsPullRequestsQuery extends AnalyticsWindowQuery { // Raw GithubAccountType strings ("User", "Bot"), unlike the metrics/reviews // account_type enum — mirrors the backend filter. @@ -749,181 +255,9 @@ export interface AnalyticsPullRequestsQuery extends AnalyticsWindowQuery { status?: string[] } -export interface PullRequestsDayBucket { - date: string - prs: number - prs_human: number - prs_bot: number - merged: number - closed: number - lines: number - lines_human: number - lines_bot: number - commits: number - commits_human: number - commits_bot: number - authors: number - authors_human: number - authors_bot: number - // merge_time percentiles pass through untyped. - [key: string]: unknown -} - -export interface PullRequestsTotals { - prs: number - merged: number - lines: number - commits: number - active_authors: number - merge_time_p50_hours: number -} - -export interface GetAnalyticsPullRequestsResponse { - series: PullRequestsDayBucket[] - totals: PullRequestsTotals - facets: Record - recent: Array> - // True when the window hit the server's PR scan cap and figures undercount. - truncated: boolean -} - export interface AnalyticsReviewsQuery extends AnalyticsWindowQuery { repo?: string[] // bare repo names (matching the review facet values) author?: string[] // reviewer logins account_type?: AnalyticsAccountType review_state?: string[] // APPROVED | CHANGES_REQUESTED | COMMENTED | ... } - -export interface ReviewsDayBucket { - date: string - reviews: number - approved: number - commented: number - changes_requested: number - reviewers_human: number - reviewers_bot: number - reviewers_total: number - comments: number - comments_human: number - comments_bot: number -} - -export interface ReviewsTotals { - reviews: number - reviewers: number - prs: number - comments: number - comments_human: number - comments_bot: number - thumbs_up: number - thumbs_down: number -} - -export interface ReviewAuthorFacet { - login: string - avatar_url: string | null - account_type: string | null - reviews: number -} - -export interface GetAnalyticsReviewsResponse { - reviews: Array> - review_comments: Array> - series: ReviewsDayBucket[] - totals: ReviewsTotals - facets: { - repos: Array<{ repository_name: string; reviews: number }> - authors: ReviewAuthorFacet[] - } -} - -// --------------------------------- files --------------------------------- -// Agent file storage: files an agent persists beyond its sandbox's lifetime — -// v1 is PNG screenshots posted as org-gated links on PRs. Mirrors -// files_service.py. - -// Caller-facing file metadata — no storage internals (S3 key, sha, owner). -export interface FileView { - id: string - filename: string - content_type: string - size_bytes: number - created_at: string - // The originating session, when the upload came from a sandbox. - agent_session_id: string | null -} - -export interface CreateFileRequest { - // Original basename, display only (the S3 key derives from the server-side - // file id, never from this). - filename: string - // v1: must be image/png; the server magic-byte-checks the decoded bytes. - content_type: string - // The raw file bytes, base64-encoded (same JSON-body precedent as session - // transcript sync). - data_b64: string -} - -export interface CreateFileResponse { - file: FileView - // The fully-formed org-gated dashboard URL (app.ellipsis.dev/files/{id}) — - // the link an agent pastes into a PR comment. - url: string -} - -export interface ListFilesQuery { - // Scope to one run's uploads. - agent_session_id?: string - limit?: number -} - -export interface ListFilesResponse { - files: FileView[] -} - -export interface GetFileResponse { - file: FileView - // The gated dashboard URL (same link the upload returned). - url: string - // Short-lived (60s) presigned S3 GET for the actual bytes — fetch it - // immediately; the JSON API never carries the file itself. - download_url: string -} - -// ------------------------------- reviews -------------------------------- -// The Review/CreateReviewRequest shapes come from the SDK (re-exported at the -// top of this file); only the list query is hand-rolled, since query params -// aren't part of the generated response types. - -export interface ListReviewsQuery { - owner?: string - repo?: string - pull_request_number?: number - status?: string - limit?: number - // The client's query builder takes a plain record. - [key: string]: unknown -} - -// ------------------------------ cli auth -------------------------------- - -export interface CliAuthStart { - device_code: string - user_code: string - verification_uri: string - verification_uri_complete: string - interval: number - expires_in: number -} - -export type CliAuthPollStatus = - | 'pending' - | 'approved' - | 'denied' - | 'expired' - | 'already_claimed' - -export interface CliAuthPoll { - status: CliAuthPollStatus - access_token?: string -} diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index fd99715..e9a6d4a 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -28,7 +28,8 @@ import { type SessionTranscriptStore, type TranscriptItem, } from '@ellipsis-dev/sdk/store' -import { ApiClient, ApiError } from '../lib/api' +import { errorDetail } from '../lib/api' +import type { Ellipsis } from '@ellipsis-dev/sdk' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' @@ -81,7 +82,7 @@ import { // either way. export interface ConnectAppProps { - api: ApiClient + api: Ellipsis sessionId: string // The one transcript store, pre-seeded with the fetched records + session. store: SessionTranscriptStore @@ -406,9 +407,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { polling.current = true const tick = async (): Promise => { try { - const [page, session] = await Promise.all([ - api.getAgentSessionRecordsPage(sessionId, { afterSeq: store.cursor }), - api.getAgentSession(sessionId), + // No cursor: the store drops records at or below its own feed_seq, + // so a full re-read is deduped rather than re-rendered. + const [page, { session }] = await Promise.all([ + api.sessions.records(sessionId).then((p) => p.response), + api.sessions.get(sessionId), ]) if (page.records.length) { // Inbox state (message_received/delivered/requeued) rides the record @@ -607,7 +610,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { void (async () => { try { if (text === '/stop') { - const s = await api.stopAgentSession(sessionId) + const { session: s } = await api.sessions.stop(sessionId) setNotice(null) setChatNotes((prev) => [ ...prev, @@ -624,7 +627,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // retires it in favour of the server's own row. setQueued((prev) => [...prev, { text, messageId: null }]) setNotice(null) - const created = await api.sendSessionMessage(sessionId, text) + const { message: created } = await api.sessions.sendMessage(sessionId, { message: text }) setQueued((prev) => { let stamped = false return prev.map((q) => { @@ -642,7 +645,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const j = prev.findIndex((q) => q.text === text && q.messageId === null) return j < 0 ? prev : [...prev.slice(0, j), ...prev.slice(j + 1)] }) - setNotice(`✗ ${err instanceof ApiError ? err.detail : (err as Error).message}`) + setNotice(`✗ ${errorDetail(err)}`) } })() }, diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 17dfc57..915de6d 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -10,9 +10,8 @@ import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink' import type { OpenSocket } from '@ellipsis-dev/sdk/stream' import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' -import type { AgentSessionWire } from '@ellipsis-dev/sdk' -import type { ApiClient } from '../lib/api' -import { ApiError } from '../lib/api' +import type { Ellipsis, Session as FrameSession } from '@ellipsis-dev/sdk' +import { errorDetail } from '../lib/api' import type { AgentSession, SavedAgentConfig, @@ -91,7 +90,7 @@ const NAV_GUTTER = 1 const COMPOSER_PAD_X = 2 export interface SessionsAppProps { - api: ApiClient + api: Ellipsis openSocket: OpenSocket // app.ellipsis.dev base + the customer login, for per-session dashboard links. appBase: string @@ -180,14 +179,16 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // row, which is the one line always on screen. const [apiError, setApiError] = useState(null) const reportApiError = useCallback((label: string, err: unknown): void => { - setApiError(`${label}: ${err instanceof ApiError ? err.detail : (err as Error).message}`) + setApiError(`${label}: ${errorDetail(err)}`) }, []) const poll = useCallback(async (): Promise => { try { - const listed = await api.listAgentSessions( - sessionBarQuery(sessionBar, { authorId, detectedRepo: props.detectedRepo }), - ) + const listed = ( + await api.sessions.list( + sessionBarQuery(sessionBar, { authorId, detectedRepo: props.detectedRepo }), + ) + ).items setAttention((prev) => { const next = new Set(prev) for (const s of listed) { @@ -261,9 +262,9 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { loading.current.add(sessionId) setLoadError(null) try { - const [session, page] = await Promise.all([ - api.getAgentSession(sessionId), - api.getAgentSessionRecordsPage(sessionId), + const [{ session }, page] = await Promise.all([ + api.sessions.get(sessionId), + api.sessions.records(sessionId).then((p) => p.response), ]) const store = new SessionTranscriptStore() const ordered = [...page.records].sort((a, b) => a.feed_seq - b.feed_seq) @@ -281,13 +282,12 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { canSend: c.canSend, notice: [notice, c.reason].filter(Boolean).join(' · ') || null, model: typeof session.tokens_model === 'string' ? session.tokens_model : null, - configName: - configName ?? session.resolved_config_name ?? session.agent_config_id ?? null, + configName: configName ?? session.config_id ?? null, url: sessionUrl(appBase, customerLogin, sessionId), } setEntries((prev) => new Map(prev).set(sessionId, entry)) } catch (err) { - setLoadError(err instanceof ApiError ? err.detail : (err as Error).message) + setLoadError(errorDetail(err)) loading.current.delete(sessionId) return } @@ -324,23 +324,23 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { useEffect(() => { if (mainPane.type !== 'new' || pickersLoading.current) return pickersLoading.current = true - void api - .listAgentConfigs() - .then((rows) => setConfigs(rows.filter((c) => !c.deleted))) + void api.agents.configs + .list() + .then((rows) => setConfigs(rows.configs)) .catch((err) => { setConfigs([]) reportApiError('agent configs', err) }) - void api - .listGithubRepositories() + void api.integrations.github + .repos() .then((r) => setRepos(r.repositories.map((repo) => repo.full_name))) .catch((err) => { setRepos([]) reportApiError('repositories', err) }) - void api - .listSupportedModels() - .then(setModels) + void api.models + .list() + .then((r) => setModels(r.models)) .catch((err) => { setModels([]) reportApiError('models', err) @@ -353,19 +353,16 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { setStartError(null) try { const req = applyComposerChoices(props.buildStartRequest(prompt), choices) - const session = await api.startAgentSession(req) + const { session, resolved_config_name } = await api.sessions.start(req) lastWords.current.set(session.id, rowStatusWord(session)) setLocalSessions((prev) => [session, ...prev]) setSelected(session.id) setMainPane({ type: 'chat', sessionId: session.id }) setFocus('chat') // Seed the entry from the start response's resolved config identity. - void loadEntry( - session.id, - session.resolved_config_name ?? session.agent_config_id ?? undefined, - ) + void loadEntry(session.id, resolved_config_name ?? session.config_id ?? undefined) } catch (err) { - setStartError(err instanceof ApiError ? err.detail : (err as Error).message) + setStartError(errorDetail(err)) } finally { setStarting(false) } @@ -733,7 +730,7 @@ function useHeaderMeta( () => (entry ? entry.store.getSnapshot() : null), ) if (mainPane.type !== 'chat' || !entry) return null - const session = snapshot?.session as AgentSessionWire | undefined | null + const session = snapshot?.session as FrameSession | undefined | null const costUsd = session ? usdNumberFromMillicents( session.cost_tokens + diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 24f3145..33f7599 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -1,6 +1,6 @@ import React from 'react' import { render } from 'ink' -import { ApiClient } from '../lib/api' +import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase, sessionBar } from '../lib/config' import { repoFromCwd } from '../lib/laptop' import { makeOpenSocket, resolveWsBase } from '../lib/stream' @@ -48,10 +48,10 @@ export function defaultStartRequest(prompt: string): StartAgentSessionRequest { } export async function runSessionsUi(options: SessionsUiOptions): Promise { - const api = new ApiClient() + const client = api() const token = requireToken() const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) - const me = await api.whoami() + const me = await client.me() // Start at the top of a fresh window: scroll whatever is on screen into // scrollback, then home the cursor (same dance as the solo connect). @@ -60,7 +60,7 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { } const app = render( React.createElement(SessionsApp, { - api, + api: client, openSocket, appBase: resolveAppBase(), customerLogin: me.customer_login, diff --git a/test/api.test.ts b/test/api.test.ts index bf511f1..e26e1c4 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,447 +1,140 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ApiClient, ApiError, buildQuery, parseErrorResponse } from '../src/lib/api' - -describe('buildQuery', () => { - it('returns empty string for no/empty query', () => { - expect(buildQuery()).toBe('') - expect(buildQuery({})).toBe('') - }) - - it('skips undefined and null values', () => { - expect(buildQuery({ a: 1, b: undefined, c: null })).toBe('?a=1') - }) - - it('repeats the key for array values (FastAPI list convention)', () => { - expect(buildQuery({ source: ['cli', 'api'] })).toBe('?source=cli&source=api') - }) - - it('encodes values', () => { - expect(buildQuery({ start: '2026-01-01T00:00:00+00:00' })).toContain( - 'start=2026-01-01T00%3A00%3A00%2B00%3A00', - ) - }) +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { APIError } from '@ellipsis-dev/sdk' +import { api, describeApiError, errorDetail } from '../src/lib/api' +import { USER_AGENT } from '../src/lib/constants' + +// The REST surface itself is the SDK's, generated from the OpenAPI spec and +// tested there. What belongs to the CLI is this module: how a client gets its +// credential and base URL, that every request is attributable, and how an SDK +// error becomes something a terminal user can act on. + +// A throwaway config dir per test, so resolveToken/resolveApiBase read a known +// (empty) config rather than the developer's real ~/.ellipsis. +let dir: string +const ENV_KEYS = ['ELLIPSIS_API_TOKEN', 'ELLIPSIS_API_BASE_URL', 'ELLIPSIS_API_BASE'] as const + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ellipsis-api-')) + process.env.ELLIPSIS_CONFIG_DIR = dir + for (const k of ENV_KEYS) delete process.env[k] }) -describe('parseErrorResponse', () => { - it('extracts a string detail', async () => { - const res = new Response(JSON.stringify({ detail: 'Invalid credentials' }), { - status: 401, - }) - expect((await parseErrorResponse(res)).detail).toBe('Invalid credentials') - }) - - it('stringifies a structured detail (FastAPI 422)', async () => { - const res = new Response(JSON.stringify({ detail: [{ loc: ['query', 'limit'] }] }), { - status: 422, - }) - expect((await parseErrorResponse(res)).detail).toContain('limit') - }) - - it('falls back to status text for non-JSON bodies', async () => { - const res = new Response('oops', { status: 502, statusText: 'Bad Gateway' }) - expect((await parseErrorResponse(res)).detail).toBe('Bad Gateway') - }) - - it('reads the request id from the X-Request-ID header', async () => { - const res = new Response(JSON.stringify({ detail: 'boom' }), { - status: 500, - headers: { 'x-request-id': 'request_abc123' }, - }) - expect(await parseErrorResponse(res)).toEqual({ - detail: 'boom', - requestId: 'request_abc123', - }) - }) - - it('falls back to the body request_id when the header is absent', async () => { - const res = new Response( - JSON.stringify({ detail: 'Internal Server Error', request_id: 'request_xyz' }), - { status: 500 }, - ) - expect((await parseErrorResponse(res)).requestId).toBe('request_xyz') - }) - - it('still parses a non-JSON body that carries the header', async () => { - const res = new Response('oops', { - status: 502, - statusText: 'Bad Gateway', - headers: { 'x-request-id': 'request_gw' }, - }) - expect(await parseErrorResponse(res)).toEqual({ - detail: 'Bad Gateway', - requestId: 'request_gw', - }) - }) +afterEach(() => { + delete process.env.ELLIPSIS_CONFIG_DIR + for (const k of ENV_KEYS) delete process.env[k] + rmSync(dir, { recursive: true, force: true }) + vi.unstubAllGlobals() }) -describe('ApiClient.request', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('sends the bearer token and parses JSON', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) - vi.stubGlobal('fetch', fetchMock) +function stubOk(body: unknown = { ok: true }): ReturnType { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} - const api = new ApiClient('http://api.test', 'tok_123') - const out = await api.request<{ ok: boolean }>('GET', '/me') +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() - expect(out).toEqual({ ok: true }) - const [url, init] = fetchMock.mock.calls[0] + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] expect(url).toBe('http://api.test/me') - expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer tok_123' }) + expect(init.headers).toMatchObject({ Authorization: 'Bearer tok_123' }) }) - it('appends the query string', async () => { - const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ sessions: [] }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) + it('resolves the token and base from the environment when not passed', async () => { + process.env.ELLIPSIS_API_TOKEN = 'env_tok' + process.env.ELLIPSIS_API_BASE_URL = 'http://env.test' + const fetchMock = stubOk() + await api().budget() - await new ApiClient('http://api.test', 't').listAgentSessions({ limit: 5, source: ['cli'] }) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/sessions?limit=5&source=cli') + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://env.test/budget') + expect(init.headers).toMatchObject({ Authorization: 'Bearer env_tok' }) }) - it('throws ApiError carrying status + server detail on non-2xx', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(JSON.stringify({ detail: 'nope' }), { status: 403 })), - ) - const api = new ApiClient('http://api.test', 't') - await expect(api.whoami()).rejects.toMatchObject({ - name: 'ApiError', - status: 403, - }) - await expect(api.whoami()).rejects.toThrow(/403 nope/) - }) + it('stamps the CLI user agent on every request, so calls stay attributable', async () => { + const fetchMock = stubOk() + await api('http://api.test', 't').usage() - it('surfaces the request id from the response on non-2xx', async () => { - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ detail: 'Internal Server Error' }), { - status: 500, - headers: { 'x-request-id': 'request_deadbeef' }, - }), - ), - ) - const api = new ApiClient('http://api.test', 't') - await expect(api.whoami()).rejects.toMatchObject({ - name: 'ApiError', - status: 500, - requestId: 'request_deadbeef', - }) - await expect(api.whoami()).rejects.toThrow(/request id: request_deadbeef/) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(init.headers).toMatchObject({ 'user-agent': USER_AGENT }) + expect(USER_AGENT).toMatch(/^ellipsis-cli\//) }) - it('tolerates empty response bodies', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))) - const api = new ApiClient('http://api.test', 't') - await expect(api.request('DELETE', '/whatever')).resolves.toBeUndefined() - }) + it('builds a client with no credential at all, for the unauthenticated auth routes', async () => { + const fetchMock = stubOk({ device_code: 'dev_1' }) + await api('http://api.test').auth.cli.start() - it('exposes ApiError as an Error subclass', () => { - const err = new ApiError(500, 'GET', '/x', 'boom') - expect(err).toBeInstanceOf(Error) - expect(err.message).toContain('GET /x failed: 500 boom') + const [url] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://api.test/auth/cli/start') }) }) -describe('ApiClient sandbox variables', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('lists variables and unwraps the response envelope', async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ variables: [{ name: 'A', created_at: '', updated_at: '' }] }), { - status: 200, - }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').listSandboxVariables() - expect(out).toEqual([{ name: 'A', created_at: '', updated_at: '' }]) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/secrets') - expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe('GET') - }) - - it('PUTs the variables batch and returns the echoed list', async () => { - const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ variables: [] }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - - await new ApiClient('http://api.test', 't').putSandboxVariables([{ name: 'TOKEN', value: 'x' }]) - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/secrets') - expect((init as RequestInit).method).toBe('PUT') - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ - variables: [{ name: 'TOKEN', value: 'x' }], +describe('errorDetail', () => { + it("reads the server's sentence out of the error envelope", () => { + const err = new APIError({ + status: 409, + code: 'session_closed', + message: 'Session is closed', + requestId: 'request_1', + body: { error: { message: 'Session is closed', code: 'session_closed' } }, }) + expect(errorDetail(err)).toBe('Session is closed') }) - it('URL-encodes the name on delete', async () => { - const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ variables: [] }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - - await new ApiClient('http://api.test', 't').deleteSandboxVariable('MY/VAR') - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/secrets/MY%2FVAR') - expect((init as RequestInit).method).toBe('DELETE') - }) -}) - -describe('getSessionLog', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('hits the log path and returns the manifest', async () => { - const body = { - format: 'ellipsis_session_log@1', - session_id: 'session_1', - earliest_feed_seq: null, - archived_through_feed_seq: 0, - latest_feed_seq: 0, - caught_up: true, - segments: [], - } - const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').getSessionLog('session_1') - expect(out).toEqual(body) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/sessions/session_1/log') - }) -}) - -describe('replayAgentSession', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('POSTs to the session-scoped replay path (encoded) with the body', async () => { - const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ id: 'session_2' }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').replayAgentSession('session/1', { - config_override: { claude: { model: 'claude-opus-4-8' } }, - }) - expect(out.id).toBe('session_2') - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/sessions/session%2F1/replay') - expect((init as RequestInit).method).toBe('POST') - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ - config_override: { claude: { model: 'claude-opus-4-8' } }, + it("reads FastAPI's own `detail` shape, which auth and validation still use", () => { + const err = new APIError({ + status: 401, + code: null, + message: 'Invalid credentials', + requestId: null, + body: { detail: 'Invalid credentials' }, }) + expect(errorDetail(err)).toBe('Invalid credentials') }) -}) - -describe('stopAgentSession', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('POSTs to the session-scoped stop path (encoded) and returns the session', async () => { - const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ id: 'session_1', status: 'stopped' }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - const out = await new ApiClient('http://api.test', 't').stopAgentSession('session/1') - expect(out).toEqual({ id: 'session_1', status: 'stopped' }) - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/sessions/session%2F1/stop') - expect((init as RequestInit).method).toBe('POST') - }) -}) - -describe('agent templates', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('unwraps the templates array from the list response', async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ templates: [{ slug: 'a' }, { slug: 'b' }] }), { - status: 200, - }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').listAgentTemplates() - expect(out.map((t) => t.slug)).toEqual(['a', 'b']) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/agents/templates') - }) - - it('fetches a single template by slug (encoded)', async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ slug: 'ci-failure-triager', yaml: 'x' }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').getAgentTemplate('ci-failure-triager') - expect(out.yaml).toBe('x') - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/agents/templates/ci-failure-triager') + it("falls back to the SDK's message when the body carries no sentence", () => { + const err = new APIError({ + status: 502, + code: null, + message: 'Bad Gateway', + requestId: null, + body: 'oops', + }) + expect(errorDetail(err)).toBe('502 error: Bad Gateway') }) -}) - -describe('supported models', () => { - afterEach(() => vi.unstubAllGlobals()) - it('unwraps the models array from the list response', async () => { - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ - models: [ - { id: 'claude-opus-5', display_name: 'Claude Opus 5', is_default_agent_model: true }, - ], - }), - { status: 200 }, - ), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').listSupportedModels() - expect(out.map((m) => m.id)).toEqual(['claude-opus-5']) - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/models') + it('passes a plain Error through unchanged', () => { + expect(errorDetail(new Error('boom'))).toBe('boom') }) }) -describe('createAgentConfig', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('POSTs template_id + repository and returns the pull request', async () => { - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ - config: { id: 'cfg_1' }, - path: 'agents/ci-failure-triager.yaml', - pull_request_url: 'https://github.com/octocat/api/pull/7', - }), - { status: 200 }, - ), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').createAgentConfig({ - template_id: 'ci-failure-triager', - repository: 'api', - }) - expect(out.pull_request_url).toBe('https://github.com/octocat/api/pull/7') - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/agents/configs') - expect((init as RequestInit).method).toBe('POST') - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ - template_id: 'ci-failure-triager', - repository: 'api', +describe('describeApiError', () => { + it('quotes the status, the message, and the request id we can grep for', () => { + const err = new APIError({ + status: 500, + code: null, + message: 'Internal Server Error', + requestId: 'request_deadbeef', + body: { error: { message: 'Internal Server Error' } }, }) - }) -}) - -describe('ApiClient files', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('POSTs the upload payload and returns the gated URL', async () => { - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ - file: { id: 'a1', filename: 'shot.png' }, - url: 'https://app.ellipsis.dev/files/a1', - }), - { status: 201 }, - ), + expect(describeApiError(err)).toBe( + '500 Internal Server Error (request id: request_deadbeef)', ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').uploadFile({ - filename: 'shot.png', - content_type: 'image/png', - data_b64: 'aGk=', - }) - expect(out.url).toBe('https://app.ellipsis.dev/files/a1') - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/files') - expect((init as RequestInit).method).toBe('POST') - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ - filename: 'shot.png', - content_type: 'image/png', - data_b64: 'aGk=', - }) }) - it('lists files, unwrapping the envelope and passing filters as query', async () => { - const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ files: [{ id: 'a1' }] }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').listFiles({ - agent_session_id: 'session_1', - limit: 5, + it('omits the request id when the server stamped none', () => { + const err = new APIError({ + status: 404, + code: null, + message: 'nope', + requestId: null, + body: { detail: 'nope' }, }) - expect(out).toEqual([{ id: 'a1' }]) - expect(fetchMock.mock.calls[0][0]).toBe( - 'http://api.test/files?agent_session_id=session_1&limit=5', - ) - }) - - it('URL-encodes the file id on get', async () => { - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ file: { id: 'a/1' }, url: 'u', download_url: 'd' }), - { status: 200 }, - ), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').getFile('a/1') - expect(out.download_url).toBe('d') - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/files/a%2F1') - }) - - it('DELETEs the file id (encoded) and tolerates a 204 empty body', async () => { - const fetchMock = vi.fn(async () => new Response(null, { status: 204 })) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').deleteFile('a/1') - expect(out).toBeUndefined() - const [url, init] = fetchMock.mock.calls[0] - expect(url).toBe('http://api.test/files/a%2F1') - expect((init as RequestInit).method).toBe('DELETE') - }) -}) - -describe('ApiClient session IDE and ports', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('fetches the IDE tunnel URL', async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ url: 'https://ide.modal.host' }), { status: 200 }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').getSessionIde('session_1') - expect(out.url).toBe('https://ide.modal.host') - expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/sessions/session_1/ide') - expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe('GET') - }) - - it('fetches a preview port tunnel URL', async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ url: 'https://p3000.modal.host', port: 3000 }), { - status: 200, - }), - ) - vi.stubGlobal('fetch', fetchMock) - - const out = await new ApiClient('http://api.test', 't').getSessionPort('session_1', 3000) - expect(out).toEqual({ url: 'https://p3000.modal.host', port: 3000 }) - expect(fetchMock.mock.calls[0][0]).toBe( - 'http://api.test/sessions/session_1/ports/3000', - ) + expect(describeApiError(err)).toBe('404 nope') }) }) diff --git a/test/auth.test.ts b/test/auth.test.ts index 8187616..4d36c2b 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { deviceLogin } from '../src/lib/auth' -import type { ApiClient } from '../src/lib/api' +import type { Ellipsis } from '@ellipsis-dev/sdk' import type { CliAuthPoll, CliAuthStart } from '../src/lib/types' const START: CliAuthStart = { @@ -14,15 +14,14 @@ const START: CliAuthStart = { // Minimal fake satisfying the two methods deviceLogin uses. function fakeApi(pollResults: CliAuthPoll[]): { - api: ApiClient + api: Ellipsis poll: ReturnType } { const poll = vi.fn() for (const r of pollResults) poll.mockResolvedValueOnce(r) const api = { - startCliAuth: vi.fn(async () => START), - pollCliAuth: poll, - } as unknown as ApiClient + auth: { cli: { start: vi.fn(async () => START), poll } }, + } as unknown as Ellipsis return { api, poll } } @@ -47,7 +46,7 @@ describe('deviceLogin', () => { expect(onPrompt).toHaveBeenCalledWith(START) expect(onPending).toHaveBeenCalledTimes(1) expect(poll).toHaveBeenCalledTimes(2) - expect(poll).toHaveBeenCalledWith('dev_abc') + expect(poll).toHaveBeenCalledWith({ device_code: 'dev_abc' }) }) it('rejects when the request is denied', async () => { @@ -78,9 +77,8 @@ describe('deviceLogin', () => { // Always pending; expires_in is 10s. const poll = vi.fn().mockResolvedValue({ status: 'pending' } satisfies CliAuthPoll) const api = { - startCliAuth: vi.fn(async () => START), - pollCliAuth: poll, - } as unknown as ApiClient + auth: { cli: { start: vi.fn(async () => START), poll } }, + } as unknown as Ellipsis const promise = deviceLogin(api, { onPrompt: vi.fn() }) const assertion = expect(promise).rejects.toThrow(/Timed out/) diff --git a/test/connect.test.ts b/test/connect.test.ts index aa82459..15e7d5f 100644 --- a/test/connect.test.ts +++ b/test/connect.test.ts @@ -5,17 +5,26 @@ import type { AgentSession } from '../src/lib/types' function session(overrides: Partial): AgentSession { return { id: 'session_1', - customer_id: 'c1', created_at: '2026-07-07T00:00:00Z', updated_at: '2026-07-07T00:00:00Z', status: 'running', status_reason: null, - agent_config_id: null, + config_id: null, + source: 'api', + harness: 'claude_code', + prompting: { enabled: true }, + resolved_budget_cents: 0, + resolved_budget_source: 'system', cost_tokens: 0, cost_sandbox_cpu: 0, cost_sandbox_memory: 0, cost_fee: 0, tokens_total: 0, + tokens_input: 0, + tokens_output: 0, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_model: '', metadata: {}, ...overrides, } @@ -40,29 +49,28 @@ describe('resolveConnectSessionId', () => { }) describe('connectability', () => { - it('durable open sessions can be sent to', () => { - expect( - connectability(session({ session_key: 'api:session_1', session_state: 'idle' })), - ).toEqual({ canSend: true }) + it('sends when the server says prompting is enabled', () => { + expect(connectability(session({ prompting: { enabled: true } }))).toEqual({ canSend: true }) }) - it('single-shot sessions (no key) are watch-only', () => { - const res = connectability(session({ session_key: null })) - expect(res.canSend).toBe(false) - expect(res.reason).toMatch(/single-shot/) - }) - - it('sessions from servers that predate keying are watch-only', () => { - // An older backend omits the field entirely; same treatment as null. - const res = connectability(session({})) + it('is watch-only when the server refuses, quoting its reason', () => { + const res = connectability( + session({ + prompting: { + enabled: false, + blocked_reason: 'non_interactive', + detail: 'This agent runs a workflow and takes no messages.', + }, + }), + ) expect(res.canSend).toBe(false) + expect(res.reason).toContain('This agent runs a workflow') + expect(res.reason).toMatch(/watch-only/) }) - it('closed conversations are watch-only', () => { - const res = connectability( - session({ session_key: 'github_pr:1:2', session_state: 'closed' }), - ) + it('falls back to a generic reason when the server sends no detail', () => { + const res = connectability(session({ prompting: { enabled: false } })) expect(res.canSend).toBe(false) - expect(res.reason).toMatch(/closed/) + expect(res.reason).toMatch(/does not accept messages/) }) }) diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 4b7f6d2..98794b2 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ApiClient, requireConnected } from '../src/lib/api' +import { requireConnected } from '../src/lib/api' +import { Ellipsis } from '@ellipsis-dev/sdk' import { integrationRows } from '../src/commands/integrations' import type { GetIntegrationsResponse } from '../src/lib/types' @@ -13,26 +14,22 @@ describe('integration discovery endpoints', () => { } it('hits the provider-namespaced paths', async () => { - const cases: Array<[(api: ApiClient) => Promise, string, unknown]> = [ - [(api) => api.getIntegrations(), '/integrations', { sentry: [] }], + const cases: Array<[(client: Ellipsis) => Promise, string, unknown]> = [ + [(c) => c.integrations.list(), '/integrations', { sentry: [] }], + [(c) => c.integrations.github.repos(), '/integrations/github/repos', { repositories: [] }], + [(c) => c.integrations.github.members(), '/integrations/github/members', { members: [] }], + [(c) => c.integrations.slack.channels(), '/integrations/slack/channels', { channels: [] }], + [(c) => c.integrations.slack.members(), '/integrations/slack/members', { members: [] }], + [(c) => c.integrations.linear.teams(), '/integrations/linear/teams', { teams: [] }], [ - (api) => api.listGithubRepositories(), - '/integrations/github/repos', - { repositories: [] }, - ], - [(api) => api.listGithubMembers(), '/integrations/github/members', { members: [] }], - [(api) => api.listSlackChannels(), '/integrations/slack/channels', { channels: [] }], - [(api) => api.listSlackMembers(), '/integrations/slack/members', { members: [] }], - [(api) => api.listLinearTeams(), '/integrations/linear/teams', { teams: [] }], - [ - (api) => api.listSentryOrganizations(), + (c) => c.integrations.sentry.organizations(), '/integrations/sentry/organizations', { organizations: [] }, ], ] for (const [call, path, body] of cases) { const fetchMock = stub(body) - await call(new ApiClient('http://api.test', 't')) + await call(new Ellipsis({ apiKey: 't', baseUrl: 'http://api.test' })) expect(fetchMock.mock.calls[0][0]).toBe(`http://api.test${path}`) vi.unstubAllGlobals() } @@ -52,10 +49,10 @@ describe('requireConnected', () => { }), ), ) - const api = new ApiClient('http://api.test', 't') - await expect(requireConnected('Slack', api.listSlackChannels())).rejects.toThrow( - /^Slack is not connected/, - ) + const client = new Ellipsis({ apiKey: 't', baseUrl: 'http://api.test' }) + await expect( + requireConnected('Slack', client.integrations.slack.channels()), + ).rejects.toThrow(/^Slack is not connected/) }) it('propagates other failures unchanged', async () => { @@ -63,11 +60,10 @@ describe('requireConnected', () => { 'fetch', vi.fn(async () => new Response(JSON.stringify({ detail: 'nope' }), { status: 403 })), ) - const api = new ApiClient('http://api.test', 't') - await expect(requireConnected('Slack', api.listSlackChannels())).rejects.toMatchObject({ - name: 'ApiError', - status: 403, - }) + const client = new Ellipsis({ apiKey: 't', baseUrl: 'http://api.test' }) + await expect( + requireConnected('Slack', client.integrations.slack.channels()), + ).rejects.toMatchObject({ status: 403 }) }) it('returns the payload when connected', async () => { @@ -80,10 +76,10 @@ describe('requireConnected', () => { }), ), ) - const api = new ApiClient('http://api.test', 't') - await expect(requireConnected('Slack', api.listSlackChannels())).resolves.toMatchObject({ - team_id: 'T1', - }) + const client = new Ellipsis({ apiKey: 't', baseUrl: 'http://api.test' }) + await expect( + requireConnected('Slack', client.integrations.slack.channels()), + ).resolves.toMatchObject({ team_id: 'T1' }) }) }) diff --git a/test/output.test.ts b/test/output.test.ts index a5b14a0..c3f611d 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { ApiError } from '../src/lib/api' +import { APIError } from '@ellipsis-dev/sdk' import { friendlyErrorMessage, relativeAge, @@ -8,6 +8,18 @@ import { usdNumberFromMillicents, } from '../src/lib/output' +// The SDK's error, as its transport builds one from a parsed error body: the +// body is what carries the server's own sentence. +function apiError(status: number, message: string, requestId: string | null = null): APIError { + return new APIError({ + status, + code: null, + message, + requestId, + body: { error: { message, request_id: requestId } }, + }) +} + describe('usdFromMillicents', () => { it('converts millicents to dollars (1 cent = 1000 millicents)', () => { expect(usdFromMillicents(0)).toBe('$0.00') @@ -58,7 +70,7 @@ describe('friendlyErrorMessage', () => { }) it('maps a 401 to a re-login hint instead of the raw HTTP failure', () => { - const err = new ApiError(401, 'GET', '/me', 'Unauthorized', 'req_1') + const err = apiError(401, 'Unauthorized', 'req_1') expect(friendlyErrorMessage(err)).toBe( 'Your login is invalid or has expired. Run `agent login` to re-authenticate.', ) @@ -66,15 +78,13 @@ describe('friendlyErrorMessage', () => { it('blames ELLIPSIS_API_TOKEN when the rejected credential came from the env', () => { process.env.ELLIPSIS_API_TOKEN = 'stale_tok' - const err = new ApiError(401, 'GET', '/me', 'Unauthorized') + const err = apiError(401, 'Unauthorized') expect(friendlyErrorMessage(err)).toMatch(/ELLIPSIS_API_TOKEN/) }) it('prints a 429 detail bare, so the remedy is the whole message', () => { - const err = new ApiError( + const err = apiError( 429, - 'POST', - '/files', 'Asset limit reached: your organization is storing 50 of 50 assets. ' + 'Delete assets you no longer need, or email team@ellipsis.dev to raise the limit.', ) @@ -84,17 +94,20 @@ describe('friendlyErrorMessage', () => { ) }) - it('passes exempt ApiErrors through with the server detail intact', () => { - const err = new ApiError(409, 'POST', '/sessions/s_1/messages', 'Session is closed') - expect(friendlyErrorMessage(err)).toBe( - 'POST /sessions/s_1/messages failed: 409 Session is closed', - ) + it('passes exempt APIErrors through with the server message intact', () => { + const err = apiError(409, 'Session is closed') + expect(friendlyErrorMessage(err)).toBe('409 Session is closed') + }) + + it('quotes the request id when the server stamped one', () => { + const err = apiError(500, 'boom', 'request_abc') + expect(friendlyErrorMessage(err)).toContain('500 boom (request id: request_abc)') }) it('appends the upgrade hint to statuses that may mean a stale CLI', () => { for (const status of [400, 405, 410, 422, 500]) { - const msg = friendlyErrorMessage(new ApiError(status, 'GET', '/sessions', 'nope')) - expect(msg).toContain(`GET /sessions failed: ${status} nope`) + const msg = friendlyErrorMessage(apiError(status, 'nope')) + expect(msg).toContain(`${status} nope`) expect(msg).toContain("It's possible we shipped a breaking change to our API.") expect(msg).toContain('You are currently on version') expect(msg).toContain('brew upgrade ellipsis-dev/cli/agent') @@ -103,7 +116,7 @@ describe('friendlyErrorMessage', () => { it('suppresses the upgrade hint where updating is the wrong remedy', () => { for (const status of [402, 403, 404, 408, 409, 413, 502, 503, 504]) { - const msg = friendlyErrorMessage(new ApiError(status, 'GET', '/sessions', 'nope')) + const msg = friendlyErrorMessage(apiError(status, 'nope')) expect(msg).not.toContain('brew upgrade') } }) diff --git a/test/search.test.ts b/test/search.test.ts index d5843d5..f32e5d3 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ApiClient } from '../src/lib/api' +import { Ellipsis } from '@ellipsis-dev/sdk' import { formatSearchResult, formatStepLine, @@ -16,17 +16,26 @@ import type { function session(overrides: Partial = {}): AgentSession { return { id: 'session_1', - customer_id: 'c', created_at: '2026-07-03T12:00:00+00:00', updated_at: '2026-07-03T12:00:00+00:00', status: 'completed', status_reason: null, - agent_config_id: null, + config_id: null, + source: 'api', + harness: 'claude_code', + prompting: { enabled: true }, + resolved_budget_cents: 0, + resolved_budget_source: 'system', cost_tokens: 0, cost_sandbox_cpu: 0, cost_sandbox_memory: 0, cost_fee: 0, tokens_total: 0, + tokens_input: 0, + tokens_output: 0, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_model: '', metadata: {}, ...overrides, } @@ -42,7 +51,7 @@ describe('searchSessions', () => { ) vi.stubGlobal('fetch', fetchMock) - await new ApiClient('http://api.test', 't').searchSessions({ + await new Ellipsis({ apiKey: 't', baseUrl: 'http://api.test' }).sessions.search({ q: 'shift trade webhook', scope: 'both', author_id: [5201153], @@ -66,52 +75,62 @@ describe('getAgentSessionRecords', () => { it('unwraps the records array from the session-scoped path (encoded)', async () => { const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ records: [{ id: 'rec_1' }] }), { status: 200 }), + async () => + new Response(JSON.stringify({ records: [{ id: 'rec_1' }], messages: [], has_more: false }), { + status: 200, + }), ) vi.stubGlobal('fetch', fetchMock) - const out = await new ApiClient('http://api.test', 't').getAgentSessionRecords('session/1') - expect(out.map((s) => s.id)).toEqual(['rec_1']) + const page = await new Ellipsis({ + apiKey: 't', + baseUrl: 'http://api.test', + }).sessions.records('session/1') + expect(page.items.map((s) => s.id)).toEqual(['rec_1']) expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/sessions/session%2F1/records') }) }) describe('resolveAuthorId', () => { const members = (logins: Array<[number, string | null]>) => ({ - listGithubMembers: vi.fn(async () => ({ - members: logins.map(([id, login]) => ({ - id, - login, - name: null, - avatar_url: null, - role: null, - slack: null, - })), - })), + integrations: { + github: { + members: vi.fn(async () => ({ + members: logins.map(([id, login]) => ({ + id, + login, + name: null, + avatar_url: null, + role: null, + slack: null, + })), + })), + }, + }, }) it('resolves a login to its account id, case-insensitively', async () => { - const api = members([ + const client = members([ [1, 'octocat'], [2, 'hbrooks'], - ]) as unknown as ApiClient - await expect(resolveAuthorId(api, 'HBrooks')).resolves.toBe(2) + ]) as unknown as Ellipsis + await expect(resolveAuthorId(client, 'HBrooks')).resolves.toBe(2) }) it('rejects an unknown login listing the known ones', async () => { - const api = members([ + const client = members([ [1, 'octocat'], [2, 'hbrooks'], [3, null], // roster rows without a cached login are skipped in the hint - ]) as unknown as ApiClient - await expect(resolveAuthorId(api, 'tony')).rejects.toThrow( + ]) as unknown as Ellipsis + await expect(resolveAuthorId(client, 'tony')).rejects.toThrow( 'no GitHub member with login "tony" (known logins: octocat, hbrooks)', ) }) it('omits the hint when no logins are known', async () => { - const api = members([[3, null]]) as unknown as ApiClient - await expect(resolveAuthorId(api, 'tony')).rejects.toThrow( + const client = members([[3, null]]) as unknown as Ellipsis + await expect(resolveAuthorId(client, 'tony')).rejects.toThrow( /no GitHub member with login "tony"$/, ) }) diff --git a/test/session.test.ts b/test/session.test.ts index 46a601b..472325f 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -10,23 +10,32 @@ import { readConfigFile, watchSession, } from '../src/commands/session' -import type { ApiClient } from '../src/lib/api' +import type { Ellipsis } from '@ellipsis-dev/sdk' import type { AgentSession, AgentSessionStatus, SessionLogSegment } from '../src/lib/types' function session(status: AgentSessionStatus): AgentSession { return { id: 'session_1', - customer_id: 'c', created_at: '2026-06-25T00:00:00+00:00', updated_at: '2026-06-25T00:00:00+00:00', - status, + status: status, status_reason: null, - agent_config_id: null, + config_id: null, + source: 'api', + harness: 'claude_code', + prompting: { enabled: true }, + resolved_budget_cents: 0, + resolved_budget_source: 'system', cost_tokens: 0, cost_sandbox_cpu: 0, cost_sandbox_memory: 0, cost_fee: 0, tokens_total: 0, + tokens_input: 0, + tokens_output: 0, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_model: '', metadata: {}, } } @@ -44,12 +53,12 @@ describe('watchSession', () => { it('polls until a terminal status, then stops', async () => { const get = vi .fn() - .mockResolvedValueOnce(session('running')) - .mockResolvedValueOnce(session('running')) - .mockResolvedValueOnce(session('completed')) - const api = { getAgentSession: get } as unknown as ApiClient + .mockResolvedValueOnce({ session: session('running') }) + .mockResolvedValueOnce({ session: session('running') }) + .mockResolvedValueOnce({ session: session('completed') }) + const client = { sessions: { get } } as unknown as Ellipsis - const promise = watchSession(api, 'session_1', 1, true) + const promise = watchSession(client, 'session_1', 1, true) await vi.advanceTimersByTimeAsync(1000) // 1st poll running -> sleep -> 2nd poll await vi.advanceTimersByTimeAsync(1000) // -> 3rd poll completed -> return await promise @@ -59,36 +68,36 @@ describe('watchSession', () => { }) it('returns immediately when the session is already terminal', async () => { - const get = vi.fn().mockResolvedValueOnce(session('error')) - const api = { getAgentSession: get } as unknown as ApiClient + const get = vi.fn().mockResolvedValueOnce({ session: session('error') }) + const client = { sessions: { get } } as unknown as Ellipsis - await watchSession(api, 'session_1', 5, true) // no timer advance needed + await watchSession(client, 'session_1', 5, true) // no timer advance needed expect(get).toHaveBeenCalledTimes(1) }) it('treats stopped/cancelled as terminal', async () => { for (const status of ['stopped', 'cancelled'] as AgentSessionStatus[]) { - const get = vi.fn().mockResolvedValueOnce(session(status)) - const api = { getAgentSession: get } as unknown as ApiClient - await watchSession(api, 'session_1', 5, true) + const get = vi.fn().mockResolvedValueOnce({ session: session(status) }) + const client = { sessions: { get } } as unknown as Ellipsis + await watchSession(client, 'session_1', 5, true) expect(get).toHaveBeenCalledTimes(1) } }) it('sets a failure exit code on a non-completed terminal status (for --wait)', async () => { process.exitCode = 0 - const get = vi.fn().mockResolvedValueOnce(session('error')) - const api = { getAgentSession: get } as unknown as ApiClient - await watchSession(api, 'session_1', 5, true) + const get = vi.fn().mockResolvedValueOnce({ session: session('error') }) + const client = { sessions: { get } } as unknown as Ellipsis + await watchSession(client, 'session_1', 5, true) expect(process.exitCode).toBe(1) process.exitCode = 0 }) it('leaves the exit code clean on a completed status', async () => { process.exitCode = 0 - const get = vi.fn().mockResolvedValueOnce(session('completed')) - const api = { getAgentSession: get } as unknown as ApiClient - await watchSession(api, 'session_1', 5, true) + const get = vi.fn().mockResolvedValueOnce({ session: session('completed') }) + const client = { sessions: { get } } as unknown as Ellipsis + await watchSession(client, 'session_1', 5, true) expect(process.exitCode).toBe(0) process.exitCode = 0 }) @@ -295,21 +304,23 @@ describe('session start prompt positional', () => { async function startedPrompt(argv: string[]): Promise { const { Command } = await import('commander') const { registerSession } = await import('../src/commands/session') - const { ApiClient } = await import('../src/lib/api') + // Read the prompt off the wire: the SDK client is generated, so the body it + // POSTs is the only place the CLI's own assembly is observable. let seen: string | undefined - const spy = vi - .spyOn(ApiClient.prototype, 'startAgentSession') - .mockImplementation(async (req) => { - seen = req.prompt - return session('queued') - }) + const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { + seen = JSON.parse(init?.body as string).prompt + return new Response(JSON.stringify({ session: session('scheduled') }), { status: 201 }) + }) + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(console, 'log').mockImplementation(() => {}) const program = new Command() program.exitOverride() registerSession(program) try { await program.parseAsync(['node', 'agent', 'session', 'start', ...argv, '--json']) } finally { - spy.mockRestore() + vi.unstubAllGlobals() + vi.restoreAllMocks() } return seen } diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 39d246a..6f7a09f 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -28,46 +28,40 @@ import type { AgentSession } from '../src/lib/types' function session(overrides: Partial): AgentSession { return { id: 'session_1', - customer_id: 'c1', created_at: '2026-07-07T00:00:00Z', updated_at: '2026-07-07T00:00:00Z', status: 'running', status_reason: null, - agent_config_id: null, + config_id: null, + source: 'api', + harness: 'claude_code', + prompting: { enabled: true }, + resolved_budget_cents: 0, + resolved_budget_source: 'system', cost_tokens: 0, cost_sandbox_cpu: 0, cost_sandbox_memory: 0, cost_fee: 0, tokens_total: 0, + tokens_input: 0, + tokens_output: 0, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_model: '', metadata: {}, ...overrides, } } describe('connectability', () => { - it('opens keyed live sessions for sending', () => { - expect(connectability(session({ session_key: 'api:x', session_state: 'running' }))).toEqual({ - canSend: true, - }) - }) - - it('is watch-only for single-shot sessions', () => { - const c = connectability(session({ session_key: null })) - expect(c.canSend).toBe(false) - expect(c.reason).toMatch(/single-shot/) - }) - - it('is watch-only for closed conversations', () => { - const c = connectability(session({ session_key: 'api:x', session_state: 'closed' })) - expect(c.canSend).toBe(false) - expect(c.reason).toMatch(/closed/) + it('sends when the server says prompting is enabled', () => { + expect(connectability(session({ prompting: { enabled: true } }))).toEqual({ canSend: true }) }) - it('honors the server prompting projection over the local keyed read', () => { - // A Slack mention session is keyed and live — the old local rule called it - // sendable — but its answers post back to the Slack thread, so the server - // refuses direct messages and we open watch-only instead of offering a - // composer whose first Enter would 409. + it('honors the server prompting projection', () => { + // A Slack mention session is keyed and live, but its answers post back to + // the Slack thread, so the server refuses direct messages and we open + // watch-only instead of a composer whose first Enter would 409. const c = connectability( session({ session_key: 'slack:D1:1.1', @@ -85,42 +79,6 @@ describe('connectability', () => { expect(c.reason).toContain('This conversation lives on Slack.') expect(c.reason).toMatch(/watch-only/) }) - - it('sends when the server says prompting is enabled', () => { - const c = connectability( - session({ - session_key: 'api:x', - session_state: 'idle', - prompting: { - enabled: true, - blocked_reason: null, - detail: null, - surface_name: null, - }, - }), - ) - expect(c).toEqual({ canSend: true }) - }) - - it('falls back to the local read against servers with no prompting field', () => { - // Older deployments omit `prompting`; a keyed live session must still open - // with a composer rather than silently going watch-only. - expect(connectability(session({ session_key: 'api:x', session_state: 'idle' }))).toEqual({ - canSend: true, - }) - }) - - it('is watch-only with generic copy when the server sends no detail', () => { - const c = connectability( - session({ - session_key: 'api:x', - session_state: 'idle', - prompting: { enabled: false, blocked_reason: 'non_interactive', detail: null }, - }), - ) - expect(c.canSend).toBe(false) - expect(c.reason).toMatch(/does not accept messages/) - }) }) describe('rowStatusWord / rowGlyph', () => { @@ -167,7 +125,8 @@ describe('rowDescription', () => { it('falls back to the prompt, then the source', () => { expect(rowDescription(session({ prompt: 'fix the tests' }))).toBe('fix the tests') expect(rowDescription(session({ source: 'react' }))).toBe('react session') - expect(rowDescription(session({}))).toBe('session') + // Every session carries a source, so that is the floor. + expect(rowDescription(session({}))).toBe('api session') }) it('ignores whitespace-only summaries', () => { @@ -212,9 +171,8 @@ describe('compactTokens', () => { describe('rowMeta', () => { const now = new Date('2026-07-23T12:00:00Z') - it('reads turns, tokens, spend, and age', () => { + it('reads tokens, spend, and age', () => { const s = session({ - tokens_info: { num_turns: 12 }, tokens_total: 84_200, cost_tokens: 30_000, cost_sandbox_cpu: 10_000, @@ -222,15 +180,7 @@ describe('rowMeta', () => { cost_fee: 0, updated_at: '2026-07-23T11:58:00Z', } as never) - expect(rowMeta(s, now)).toBe('12 turns · 84.2k · $0.42 · 2m ago') - }) - - it('says "1 turn", not "1 turns"', () => { - const s = session({ - tokens_info: { num_turns: 1 }, - updated_at: '2026-07-23T11:58:00Z', - } as never) - expect(rowMeta(s, now)).toBe('1 turn · 2m ago') + expect(rowMeta(s, now)).toBe('84.2k · $0.42 · 2m ago') }) it('drops the work bits a fresh session has none of', () => { @@ -240,10 +190,9 @@ describe('rowMeta', () => { }) describe('sessionSource', () => { - it('reads laptop off the input blob, which is where the list row carries it', () => { - expect(sessionSource(session({ input: { source: 'laptop' } } as never))).toBe('laptop') + it('reads laptop off the session\'s top-level source', () => { expect(sessionSource(session({ source: 'laptop' }))).toBe('laptop') - expect(sessionSource(session({ input: { source: 'react' } } as never))).toBe('cloud') + expect(sessionSource(session({ source: 'react' }))).toBe('cloud') expect(sessionSource(session({}))).toBe('cloud') }) })