diff --git a/.changeset/wise-dev-prompts.md b/.changeset/wise-dev-prompts.md new file mode 100644 index 000000000..88ce4658d --- /dev/null +++ b/.changeset/wise-dev-prompts.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Invoke an agent without a TUI using `eve invoke`, which returns pretty JSON at terminal or blocking events. Durable session coordinates support follow-up turns, human input, authorization, interrupted waits, and machine discovery through `--json-schema`. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 56bd176f6..4fee40001 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -183,7 +183,7 @@ Pass a bare URL and the UI connects to that server instead of booting a local on A fresh `eve init` passes `--input /model`. That bare local input starts onboarding: the TUI installs the Vercel CLI if needed, asks you to log in if needed, then opens `/model`. Other input stays editable in the prompt. -For a URL target protected by HTTP Basic auth, put the credentials in the URL. Eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting: +For a URL target protected by HTTP Basic auth, put the credentials in the URL. eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting: ```bash eve dev https://user:pass@your-app.example.com @@ -191,6 +191,30 @@ eve dev https://user:pass@your-app.example.com For bearer tokens or custom schemes, pass explicit headers with `-H`. +### `eve invoke` + +| Option | Type | Default | Description | +| ----------------------- | ------ | ------- | ----------------------------------------------- | +| `[prompt]` | string | none | Prompt, follow-up, or answer to a pending input | +| `-u, --url ` | string | local | Invoke an existing server | +| `-H, --header
` | string | none | Request header for a URL target; repeatable | +| `--resume` | flag | off | Read a previous resumable result from stdin | +| `--scope ` | string | current | Vercel team that owns the URL target | +| `--json-schema` | flag | off | Print the result JSON Schema and exit | + +Use `eve invoke` to submit a turn without opening the TUI. It emits JSON after the invocation completes or reaches a blocking input or authorization event. + +```bash +eve invoke "Summarize station telemetry" +result=$(eve invoke "Deploy the application") +printf '%s' "$result" | eve invoke --resume "approve" +eve invoke --json-schema +``` + +`--resume` reads a complete previous result from stdin. Supply text for a `ready` follow-up or pending input; the agent harness resolves input text against all pending requests. A `ready` result includes the previous turn's completed or failed `outcome`. An `authorization-required` result lists every unresolved challenge in `authorizations`; complete them, then resume without text. Pass explicit headers again for protected remote servers. If the URL belongs to another Vercel team, pass its slug with `--scope`; this does not relink the current directory. Pass the scope again when resuming. Paused invocations exit `3`; failures exit `1`. + +Local callback-based connection authorization requires a persistent server. Run `eve dev`, then use `eve invoke --url ` instead. If a waiting invocation receives `SIGINT` or `SIGTERM` after acceptance, it emits a final resumable `running` result before exiting. + Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead. Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight turns hold a consistent code revision while new turns pick up rebuilds. The terminal REPL keeps its logical session across successful rebuilds, so the next turn continues the conversation on the latest generation; `/new` terminally retires that session before clearing the transcript, and the next prompt starts a fresh session with a new session-scoped sandbox on first sandbox use. After a generation is superseded, `eve dev` retains it for at least 30 minutes and also retains the five most recently superseded generations, regardless of the configured Workflow World. The active generation is never pruned. Old runtime snapshots and local sandbox templates are pruned in the background. For manual cleanup, stop `eve dev` before deleting `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`. A turn that remains unfinished beyond the automatic retention window can no longer resume after its generation is pruned. diff --git a/packages/eve/src/cli/dev/tui/remote-connection-probe.ts b/packages/eve/src/cli/dev/tui/remote-connection-probe.ts index 05d6ee1ff..a6e10b752 100644 --- a/packages/eve/src/cli/dev/tui/remote-connection-probe.ts +++ b/packages/eve/src/cli/dev/tui/remote-connection-probe.ts @@ -7,7 +7,7 @@ import { import { toErrorMessage } from "#shared/errors.js"; import { isObject } from "#shared/guards.js"; -import { probeAgentInfo } from "./agent-info-probe.js"; +import { probeAgentInfo } from "#services/dev-client/agent-info-probe.js"; import type { RemoteConnectionState } from "./remote-connection-types.js"; export type RemoteProbeResult = Extract< diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index 851402668..ec49bfdb3 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -46,7 +46,7 @@ import { } from "./errors.js"; import { pickAgentHeaderTip } from "./agent-header.js"; -import { probeAgentInfo } from "./agent-info-probe.js"; +import { probeAgentInfo } from "#services/dev-client/agent-info-probe.js"; import { parseLogDisplayMode } from "./log-display-mode.js"; import { formatPromptCommandHelp, diff --git a/packages/eve/src/cli/dev/tui/target.ts b/packages/eve/src/cli/dev/tui/target.ts index 7d16af687..5bc879e27 100644 --- a/packages/eve/src/cli/dev/tui/target.ts +++ b/packages/eve/src/cli/dev/tui/target.ts @@ -1,23 +1,15 @@ import { basename } from "node:path"; -interface DevelopmentTargetBase { - readonly serverUrl: string; - /** Local workspace root for app files and the fallback Vercel project link. */ - readonly workspaceRoot: string; -} - -/** A development TUI session backed by the local `eve dev` server. */ -export interface LocalDevelopmentTarget extends DevelopmentTargetBase { - readonly kind: "local"; -} +import type { + DevelopmentTarget, + LocalDevelopmentTarget, + RemoteDevelopmentTarget, +} from "#services/dev-client/target.js"; -/** A development TUI session connected to an existing remote server. */ -export interface RemoteDevelopmentTarget extends DevelopmentTargetBase { - readonly kind: "remote"; -} +export type { LocalDevelopmentTarget, RemoteDevelopmentTarget }; /** Local or remote server backing one development TUI session. */ -export type DevelopmentTuiTarget = LocalDevelopmentTarget | RemoteDevelopmentTarget; +export type DevelopmentTuiTarget = DevelopmentTarget; /** Resolves the explicit name, remote host, or humanized local folder shown by the TUI. */ export function resolveTuiTitle(input: { diff --git a/packages/eve/src/cli/invoke/command.ts b/packages/eve/src/cli/invoke/command.ts new file mode 100644 index 000000000..4a51cda34 --- /dev/null +++ b/packages/eve/src/cli/invoke/command.ts @@ -0,0 +1,234 @@ +import { type Command, InvalidArgumentError } from "#compiled/commander/index.js"; +import { + parseDevelopmentHeaderOption, + resolveDevelopmentUrlTarget, + type DevelopmentRequestHeaders, +} from "#cli/dev/url-target.js"; +import { parseDevelopmentServerUrl } from "#cli/dev/url.js"; +import type { DevelopmentServer, DevelopmentServerOptions } from "#internal/nitro/host/types.js"; +import type { DevelopmentTarget } from "#services/dev-client/target.js"; + +import { resolveInvokeOperation, type RunInvokeInput } from "./invoke.js"; +import { invokeResultJsonSchema, parseInvokeResumeInput, type InvokeResult } from "./result.js"; + +interface InvokeCliOptions { + header?: DevelopmentRequestHeaders; + jsonSchema?: boolean; + resume?: boolean; + scope?: string; + url?: string; +} + +export interface InvokeCommandDependencies { + readonly loadEnvironment: (appRoot: string) => void | Promise; + readonly runInvoke: (input: RunInvokeInput) => Promise; + readonly startHost: (appRoot: string) => DevelopmentServer | Promise; +} + +/** Runtime overrides used when wiring `eve invoke` into the root CLI. */ +export interface InvokeCliRuntimeDependencies { + readonly runInvoke: (input: RunInvokeInput) => Promise; + readonly startHost: (appRoot: string, options?: DevelopmentServerOptions) => DevelopmentServer; +} + +interface InvokeCommandLogger { + log(message: string): void; +} + +/** Registers the invoke command with lazily loaded production dependencies. */ +export function registerRuntimeInvokeCommand(input: { + readonly appRoot: string; + readonly logger: InvokeCommandLogger; + readonly program: Command; + readonly runtime: Partial; +}): void { + registerInvokeCommand({ + ...input, + deps: { + loadEnvironment: async (root) => + (await import("#cli/dev/environment.js")).loadDevelopmentEnvironmentFiles(root), + runInvoke: async (invokeInput) => + await (input.runtime.runInvoke ?? (await import("./invoke.js")).runInvoke)(invokeInput), + startHost: async (root) => + ( + input.runtime.startHost ?? + (await import("#internal/nitro/host.js")).createDevelopmentServer + )(root, { existing: "reject" }), + }, + }); +} + +/** Registers the non-interactive invoke command. */ +export function registerInvokeCommand(input: { + readonly appRoot: string; + readonly deps: InvokeCommandDependencies; + readonly logger: InvokeCommandLogger; + readonly program: Command; +}): void { + input.program + .command("invoke") + .description("Invoke an eve agent without a terminal UI.") + .argument("[prompt]", "Prompt, follow-up message, or answer to a pending input") + .option("-u, --url ", "Invoke an existing server URL", parseDevelopmentServerUrl) + .option( + "-H, --header
", + 'Request header for a URL target, in "Name: value" form (repeatable)', + parseDevelopmentHeaderOption, + ) + .option("--resume", "Read a previous resumable result from stdin") + .option("--scope ", "Vercel team that owns the URL target") + .option("--json-schema", "Print the invoke result JSON Schema and exit") + .action((prompt: string | undefined, options: InvokeCliOptions) => + runInvokeCommand({ ...input, options, prompt }), + ); +} + +async function runInvokeCommand(input: { + readonly appRoot: string; + readonly deps: InvokeCommandDependencies; + readonly logger: InvokeCommandLogger; + readonly options: InvokeCliOptions; + readonly prompt?: string; +}): Promise { + const { options } = input; + if (options.jsonSchema === true) { + assertSchemaOnly(input.prompt, options); + input.logger.log(JSON.stringify(invokeResultJsonSchema, null, 2)); + return; + } + const previous = + options.resume === true ? parseInvokeResumeInput(await readJsonFromStdin()) : undefined; + const resumedTarget = previous?.resume.target; + if (resumedTarget?.kind === "local" && options.url !== undefined) { + throw new InvalidArgumentError("A local invocation cannot be resumed against --url."); + } + + const effectiveUrl = + options.url ?? (resumedTarget?.kind === "remote" ? resumedTarget.serverUrl : undefined); + const remoteTarget = resolveDevelopmentUrlTarget( + { header: options.header, url: effectiveUrl }, + undefined, + ); + if (options.scope !== undefined && remoteTarget === undefined) { + throw new InvalidArgumentError("The --scope option requires a URL target."); + } + if ( + resumedTarget?.kind === "remote" && + options.url !== undefined && + remoteTarget !== undefined && + remoteTarget.serverUrl !== resumedTarget.serverUrl + ) { + throw new InvalidArgumentError( + `Session target ${resumedTarget.serverUrl} does not match ${remoteTarget.serverUrl}.`, + ); + } + const operation = resolveInvokeOperation({ previous, prompt: input.prompt }); + + await input.deps.loadEnvironment(input.appRoot); + if (remoteTarget !== undefined) { + await executeWithSignals( + input, + { + kind: "remote", + serverUrl: remoteTarget.serverUrl, + workspaceRoot: input.appRoot, + }, + remoteTarget.headers, + operation, + options.scope, + ); + return; + } + + const server = await input.deps.startHost(input.appRoot); + try { + const handle = await server.start(); + await executeWithSignals( + input, + { kind: "local", serverUrl: handle.url, workspaceRoot: handle.appRoot }, + undefined, + operation, + ); + } finally { + await server.close(); + } +} + +async function executeWithSignals( + input: { + readonly deps: InvokeCommandDependencies; + readonly logger: InvokeCommandLogger; + readonly options: InvokeCliOptions; + }, + target: DevelopmentTarget, + headers: DevelopmentRequestHeaders | undefined, + operation: RunInvokeInput["operation"], + vercelScope?: string, +): Promise { + const controller = new AbortController(); + let signalExitCode: number | undefined; + const handleSigint = () => { + signalExitCode = 130; + controller.abort(); + }; + const handleSigterm = () => { + signalExitCode = 143; + controller.abort(); + }; + process.once("SIGINT", handleSigint); + process.once("SIGTERM", handleSigterm); + try { + const invokeInput = + headers === undefined + ? { operation, signal: controller.signal, target } + : { headers, operation, signal: controller.signal, target }; + const scopedInvokeInput = + vercelScope === undefined ? invokeInput : { ...invokeInput, vercelScope }; + const result = await input.deps.runInvoke(scopedInvokeInput); + input.logger.log(JSON.stringify(result, null, 2)); + process.exitCode = signalExitCode ?? invokeExitCode(result); + } finally { + process.off("SIGINT", handleSigint); + process.off("SIGTERM", handleSigterm); + } +} + +function assertSchemaOnly(prompt: string | undefined, options: InvokeCliOptions): void { + if ( + prompt !== undefined || + options.resume === true || + options.scope !== undefined || + options.url !== undefined || + options.header !== undefined + ) { + throw new InvalidArgumentError( + "--json-schema cannot be combined with invoke options or a prompt.", + ); + } +} + +async function readJsonFromStdin(): Promise { + let text = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) text += chunk; + if (text.trim().length === 0) { + throw new InvalidArgumentError("--resume expected a resumable eve invoke result on stdin."); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new InvalidArgumentError("--resume received invalid JSON on stdin."); + } +} + +function invokeExitCode(result: InvokeResult): number { + if ( + result.status === "failed" || + result.status === "authentication-required" || + (result.status === "ready" && result.outcome.status === "failed") + ) { + return 1; + } + if (result.status === "input-required" || result.status === "authorization-required") return 3; + return 0; +} diff --git a/packages/eve/src/cli/invoke/invoke.test.ts b/packages/eve/src/cli/invoke/invoke.test.ts new file mode 100644 index 000000000..48e0531c6 --- /dev/null +++ b/packages/eve/src/cli/invoke/invoke.test.ts @@ -0,0 +1,318 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resolveInvokeOperation, runInvoke, type RunInvokeInput } from "./invoke.js"; +import { parseInvokeResumeInput } from "./result.js"; + +const cursor = { + continuationToken: "eve:test", + sessionId: "ses_1", + streamIndex: 3, +}; +const target = { kind: "remote" as const, serverUrl: "https://example.com/" }; +const localTarget = { + kind: "local" as const, + serverUrl: "https://example.com/", + workspaceRoot: "/repo", +}; +const resume = { session: cursor, target }; +const request = { + action: { callId: "call-1", input: {}, kind: "tool-call" as const, toolName: "bash" }, + display: "confirmation" as const, + options: [ + { id: "approve", label: "Approve" }, + { id: "deny", label: "Deny" }, + ], + prompt: "Approve?", + requestId: "approval-1", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("parseInvokeResumeInput", () => { + it("accepts a complete result containing durable session coordinates", () => { + const result = { status: "running" as const, resume }; + expect(parseInvokeResumeInput(result)).toEqual(result); + }); + + it("rejects standalone capsules, malformed results, and non-resumable results", () => { + expect(() => parseInvokeResumeInput(resume)).toThrow("valid resumable eve invoke result"); + expect(() => + parseInvokeResumeInput({ status: "running", resume: { ...resume, session: {} } }), + ).toThrow("valid resumable eve invoke result"); + expect(() => parseInvokeResumeInput({ status: "failed", message: "boom" })).toThrow( + "valid resumable eve invoke result", + ); + }); +}); + +describe("runInvoke", () => { + it("preserves an accepted session when its stream later rejects authorization", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + Response.json({ continuationToken: "eve:test", sessionId: "ses_1" }, { status: 202 }), + ) + .mockResolvedValueOnce( + Response.json( + { + ok: false, + code: "unauthorized", + error: "Authorization is required for this route.", + }, + { status: 401 }, + ), + ); + + await expect( + runInvoke({ + headers: { authorization: "Bearer explicit" }, + operation: { kind: "send", payload: { message: "do foo" } }, + target: { ...target, workspaceRoot: "/repo" }, + }), + ).resolves.toMatchObject({ + status: "authentication-required", + resume: { session: { sessionId: "ses_1" } }, + }); + }); + + it("reduces a blocking input event into one resumable result", async () => { + const result = await runStreamedInvocation([ + { type: "input.requested", data: { requests: [request] } }, + { + type: "session.waiting", + data: { continuationToken: "eve:rekeyed", wait: "next-user-message" }, + }, + ]); + + expect(result).toMatchObject({ + status: "input-required", + requests: [ + { + options: request.options, + prompt: request.prompt, + requestId: request.requestId, + }, + ], + resume: { + session: { continuationToken: "eve:rekeyed", sessionId: "ses_1", streamIndex: 2 }, + }, + }); + if (result.status !== "input-required") throw new Error("Expected input-required result."); + expect(result.requests[0]).not.toHaveProperty("action"); + expect(result.requests[0]).not.toHaveProperty("display"); + }); + + it("returns a ready snapshot when a recoverable turn parks the session", async () => { + await expect( + runStreamedInvocation([ + { + type: "turn.failed", + data: { code: "provider_error", message: "Model unavailable" }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:retry", wait: "next-user-message" }, + }, + ]), + ).resolves.toMatchObject({ + status: "ready", + outcome: { status: "failed", message: "Model unavailable" }, + resume: { + session: { continuationToken: "eve:retry", sessionId: "ses_1", streamIndex: 2 }, + }, + }); + }); + + it("parks remote authorization at its durable boundary before returning", async () => { + await expect( + runStreamedInvocation( + [ + { + type: "authorization.required", + data: { description: "Sign in", name: "linear", webhookUrl: "https://auth.test" }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:authorized", wait: "next-user-message" }, + }, + ], + { + target: { ...target, workspaceRoot: "/repo" }, + headers: { authorization: "Bearer explicit" }, + }, + ), + ).resolves.toMatchObject({ + status: "authorization-required", + authorizations: [{ name: "linear" }], + resume: { session: { continuationToken: "eve:authorized", streamIndex: 2 } }, + }); + }); + + it("returns every pending remote authorization", async () => { + await expect( + runStreamedInvocation( + [ + { + type: "authorization.required", + data: { description: "Sign in", name: "linear", webhookUrl: "https://linear.test" }, + }, + { + type: "authorization.required", + data: { description: "Sign in", name: "github", webhookUrl: "https://github.test" }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:authorized", wait: "next-user-message" }, + }, + ], + { + target: { ...target, workspaceRoot: "/repo" }, + headers: { authorization: "Bearer explicit" }, + }, + ), + ).resolves.toMatchObject({ + status: "authorization-required", + authorizations: [{ name: "linear" }, { name: "github" }], + }); + }); + + it("rejects authorization that depends on a temporary local callback server", async () => { + await expect( + runStreamedInvocation([ + { + type: "authorization.required", + data: { + description: "Sign in", + name: "linear", + webhookUrl: "http://127.0.0.1:2000/eve/v1/connections/linear/callback/hook", + }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:authorized", wait: "next-user-message" }, + }, + ]), + ).resolves.toEqual({ + status: "failed", + message: + "Local eve invoke cannot pause for connection authorization because its temporary server must remain available for the callback. Run eve dev, then invoke its URL with --url.", + }); + }); + + it("does not preserve authorization after its completion event", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + streamResponse([ + { type: "authorization.completed", data: { name: "linear", outcome: "authorized" } }, + { + type: "message.completed", + data: { finishReason: "stop", message: "done" }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:next", wait: "next-user-message" }, + }, + ]), + ); + + await expect( + runInvoke({ + operation: { kind: "follow", resume }, + target: { ...target, workspaceRoot: "/repo" }, + headers: { authorization: "Bearer explicit" }, + }), + ).resolves.toMatchObject({ + status: "ready", + outcome: { status: "completed", message: "done" }, + }); + }); +}); + +describe("resolveInvokeOperation", () => { + it("starts an invocation from a prompt", () => { + expect(resolveInvokeOperation({ prompt: "do foo" })).toEqual({ + kind: "send", + payload: { message: "do foo" }, + }); + }); + + it("forwards input response text for the harness to resolve against pending requests", () => { + const previous = parseInvokeResumeInput({ + status: "input-required", + requests: [ + { options: request.options, prompt: request.prompt, requestId: request.requestId }, + { options: request.options, prompt: request.prompt, requestId: "approval-2" }, + ], + resume, + }); + expect(resolveInvokeOperation({ previous, prompt: "Approve" })).toEqual({ + kind: "send", + resume, + payload: { message: "Approve" }, + }); + }); + + it("sends a follow-up to a ready invocation", () => { + const previous = parseInvokeResumeInput({ + status: "ready", + outcome: { status: "completed", message: "done" }, + resume, + }); + expect(resolveInvokeOperation({ previous, prompt: "follow up" })).toEqual({ + kind: "send", + payload: { message: "follow up" }, + resume, + }); + }); + + it("rejects follow-up prompts for terminal invocation snapshots", () => { + expect(() => + resolveInvokeOperation({ + previous: { + status: "failed", + message: "terminal", + resume, + }, + prompt: "retry", + }), + ).toThrow("terminal failed invocation cannot be resumed"); + }); + + it("follows authorization without posting another turn", () => { + const previous = parseInvokeResumeInput({ + status: "authorization-required", + authorizations: [{ description: "Sign in", name: "linear" }], + resume, + }); + expect(resolveInvokeOperation({ previous })).toEqual({ kind: "follow", resume }); + }); +}); + +async function runStreamedInvocation( + events: readonly unknown[], + overrides: Partial = {}, +): Promise>> { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + Response.json({ continuationToken: "eve:test", sessionId: "ses_1" }, { status: 202 }), + ) + .mockResolvedValueOnce(streamResponse(events)); + return runInvoke({ + operation: { kind: "send", payload: { message: "do foo" } }, + target: localTarget, + ...overrides, + }); +} + +function streamResponse(events: readonly unknown[]): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (const event of events) + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + controller.close(); + }, + }), + ); +} diff --git a/packages/eve/src/cli/invoke/invoke.ts b/packages/eve/src/cli/invoke/invoke.ts new file mode 100644 index 000000000..609a32cd2 --- /dev/null +++ b/packages/eve/src/cli/invoke/invoke.ts @@ -0,0 +1,267 @@ +import { + Client, + ClientError, + type ClientSession, + type HandleMessageStreamEvent, + type SendTurnPayload, + type SessionState, +} from "#client/index.js"; +import { collectTurnEvents, summarizeTurnEvents } from "#client/session-utils.js"; +import { resolveLocalDevelopmentClientOptions } from "#services/dev-client/client-options.js"; +import { resolveLinkedDevelopmentOidcToken } from "#services/dev-client/request-headers.js"; +import type { DevelopmentTarget } from "#services/dev-client/target.js"; +import { + formatVercelTrustedSourcesFailure, + isVercelAuthChallenge, + vercelTrustedSourcesErrorCode, +} from "#services/dev-client/vercel-auth-error.js"; +import type { VercelDeploymentResolution } from "#setup/vercel-deployment.js"; +import { resolveVerifiedRemoteDevelopmentClient } from "#setup/verified-remote-client.js"; + +import { + type InvokeAuthenticationFailure, + type InvokeResult, + type InvokeResume, + projectInvocationInputRequest, +} from "./result.js"; + +export type InvokeOperation = + | { readonly kind: "send"; readonly payload: SendTurnPayload; readonly resume?: InvokeResume } + | { readonly kind: "follow"; readonly resume: InvokeResume }; + +export interface RunInvokeInput { + readonly headers?: Readonly>; + readonly operation: InvokeOperation; + readonly signal?: AbortSignal; + readonly target: DevelopmentTarget; + readonly vercelScope?: string; +} + +/** Runs one non-interactive eve invocation. */ +export async function runInvoke(input: RunInvokeInput): Promise { + const { client, deploymentResolution } = await createInvokeClient(input); + const resume = input.operation.resume; + const session = client.session(resume?.session); + + if (input.operation.kind === "follow") { + return observeSafely( + input, + session, + session.stream({ signal: input.signal }), + deploymentResolution, + ); + } + + let response: Awaited>; + try { + response = await session.send({ ...input.operation.payload, signal: input.signal }); + } catch (error) { + const authentication = authenticationFailure(error, undefined, deploymentResolution); + if (authentication !== undefined) return authentication; + throw error; + } + + return observeSafely(input, session, response, deploymentResolution); +} + +/** Converts a prompt and optional previous result into one valid invoke operation. */ +export function resolveInvokeOperation(input: { + readonly prompt?: string; + readonly previous?: InvokeResult & { resume: InvokeResume }; +}): InvokeOperation { + const prompt = input.prompt?.trim(); + const previous = input.previous; + if (previous === undefined) { + if (!prompt) throw new Error("eve invoke requires a prompt unless --resume is provided."); + return { kind: "send", payload: { message: prompt } }; + } + + if (previous.status === "input-required") { + if (!prompt) throw new Error("This invocation is waiting for an input response."); + return { kind: "send", payload: { message: prompt }, resume: previous.resume }; + } + + if ( + previous.status === "running" || + previous.status === "authorization-required" || + previous.status === "authentication-required" + ) { + if (prompt) { + throw new Error( + previous.status === "running" + ? "A running invocation cannot accept a follow-up prompt." + : "Complete the requested authorization, then resume without a prompt.", + ); + } + return { kind: "follow", resume: previous.resume }; + } + + if (previous.status !== "ready") { + throw new Error(`A terminal ${previous.status} invocation cannot be resumed.`); + } + if (!prompt) throw new Error("A ready invocation requires a follow-up prompt."); + return { kind: "send", payload: { message: prompt }, resume: previous.resume }; +} + +async function createInvokeClient(input: RunInvokeInput): Promise<{ + readonly client: Client; + readonly deploymentResolution?: VercelDeploymentResolution; +}> { + if (input.target.kind === "local") { + return { + client: new Client({ + ...resolveLocalDevelopmentClientOptions({ + headers: input.headers, + serverUrl: input.target.serverUrl, + token: () => resolveLinkedDevelopmentOidcToken(input.target.workspaceRoot), + }), + preserveCompletedSessions: true, + }), + }; + } + + const { deploymentResolution, options } = await resolveVerifiedRemoteDevelopmentClient({ + headers: input.headers, + serverUrl: input.target.serverUrl, + signal: input.signal, + vercelScope: input.vercelScope, + workspaceRoot: input.target.workspaceRoot, + }); + return { + client: new Client({ ...options, preserveCompletedSessions: true }), + deploymentResolution, + }; +} + +async function observeSafely( + input: RunInvokeInput, + session: ClientSession, + response: AsyncIterable, + deploymentResolution?: VercelDeploymentResolution, +): Promise { + try { + return await observeInvocation(input.target, session, response); + } catch (error) { + if (input.signal?.aborted === true) return runningResult(input.target, session.state); + const authentication = authenticationFailure( + error, + createResume(input.target, session.state), + deploymentResolution, + ); + if (authentication !== undefined) return authentication; + throw error; + } +} + +async function observeInvocation( + target: DevelopmentTarget, + session: ClientSession, + response: AsyncIterable, +): Promise { + const summary = summarizeTurnEvents(await collectTurnEvents(response)); + if (summary.boundary === undefined) return runningResult(target, session.state); + if (summary.boundary.type === "session.failed") { + return { status: "failed", message: summary.boundary.data.message }; + } + + const resume = createResume(target, session.state); + if (summary.failure?.type === "turn.failed") { + return { + status: "ready", + outcome: { status: "failed", message: summary.failure.data.message }, + resume, + }; + } + if (summary.inputRequests.length > 0) { + return { + status: "input-required", + requests: summary.inputRequests.map(projectInvocationInputRequest), + resume, + }; + } + + const authorizations = summary.pendingAuthorizations; + if (authorizations.length > 0) { + if ( + target.kind === "local" && + authorizations.some((authorization) => authorization.webhookUrl !== undefined) + ) { + return { + status: "failed", + message: + "Local eve invoke cannot pause for connection authorization because its temporary server must remain available for the callback. Run eve dev, then invoke its URL with --url.", + }; + } + return { status: "authorization-required", authorizations, resume }; + } + + const outcome = + summary.message === undefined + ? ({ status: "completed" } as const) + : ({ status: "completed", message: summary.message } as const); + return { status: "ready", outcome, resume }; +} + +function runningResult(target: DevelopmentTarget, state: SessionState): InvokeResult { + return { status: "running", resume: createResume(target, state) }; +} + +function createResume(target: DevelopmentTarget, session: SessionState): InvokeResume { + if (session.sessionId === undefined) throw new Error("Invocation has no resumable session ID."); + const cursor = + session.continuationToken === undefined + ? { sessionId: session.sessionId, streamIndex: session.streamIndex } + : { + continuationToken: session.continuationToken, + sessionId: session.sessionId, + streamIndex: session.streamIndex, + }; + return { + session: cursor, + target: + target.kind === "local" ? { kind: "local" } : { kind: "remote", serverUrl: target.serverUrl }, + }; +} + +function authenticationFailure( + error: unknown, + resume?: InvokeResume, + deploymentResolution?: VercelDeploymentResolution, +): InvokeAuthenticationFailure | undefined { + let message: string | undefined; + let code: string | undefined; + if (deploymentResolution?.kind === "not-found") { + message = + "Vercel could not resolve this deployment in the selected scope. Retry with the owning team's slug via --scope , or provide an explicit Authorization header with -H."; + } else if (isVercelAuthChallenge(error)) { + message = + "Vercel Deployment Protection rejected the available credentials. Configure Trusted Sources or set VERCEL_AUTOMATION_BYPASS_SECRET, then retry."; + } else if (error instanceof ClientError) { + code = vercelTrustedSourcesErrorCode(error.message); + if (error.status === 403 && code === "TRUSTED_SOURCES_ENVIRONMENT_MISMATCH") { + message = formatVercelTrustedSourcesFailure(error.message); + } else if (isEveAuthorizationError(error)) { + message = + "The deployment rejected the available credentials. Confirm its eve channel authorization and that your account can access the owning project."; + } + } + if (message === undefined) return undefined; + if (resume === undefined) { + return code === undefined + ? { status: "authentication-required", message } + : { status: "authentication-required", code, message }; + } + return code === undefined + ? { status: "authentication-required", message, resume } + : { status: "authentication-required", code, message, resume }; +} + +function isEveAuthorizationError(error: ClientError): boolean { + if (error.status !== 401) return false; + try { + const payload = JSON.parse(error.body) as Record; + return payload.ok === false && payload.code === "unauthorized"; + } catch { + return false; + } +} diff --git a/packages/eve/src/cli/invoke/result.ts b/packages/eve/src/cli/invoke/result.ts new file mode 100644 index 000000000..f90aa50de --- /dev/null +++ b/packages/eve/src/cli/invoke/result.ts @@ -0,0 +1,113 @@ +import { z } from "#compiled/zod/index.js"; +import { inputOptionSchema, inputRequestSchema, type InputRequest } from "#client/index.js"; +import type { ConnectionAuthorizationChallenge } from "#public/connections/errors.js"; + +const sessionCursorSchema = z + .object({ + continuationToken: z.string().optional(), + sessionId: z.string().min(1), + streamIndex: z.number().int().nonnegative(), + }) + .strict(); +const targetSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("local") }).strict(), + z.object({ kind: z.literal("remote"), serverUrl: z.url() }).strict(), +]); +const invokeResumeSchema = z + .object({ session: sessionCursorSchema, target: targetSchema }) + .strict(); +const invocationInputRequestSchema = inputRequestSchema + .omit({ action: true, display: true, options: true }) + .extend({ options: z.array(inputOptionSchema.omit({ style: true })).optional() }); +const authorizationChallengeSchema: z.ZodType = z + .object({ + displayName: z.string().optional(), + expiresAt: z.string().optional(), + instructions: z.string().optional(), + url: z.string().optional(), + userCode: z.string().optional(), + }) + .strict(); +const authorizationInterruptSchema = z + .object({ + authorization: authorizationChallengeSchema.optional(), + description: z.string(), + name: z.string(), + webhookUrl: z.string().optional(), + }) + .strict(); +const turnOutcomeSchema = z.discriminatedUnion("status", [ + z.object({ message: z.string().optional(), status: z.literal("completed") }).strict(), + z.object({ message: z.string(), status: z.literal("failed") }).strict(), +]); + +const invokeResultSchema = z.discriminatedUnion("status", [ + z.object({ resume: invokeResumeSchema, status: z.literal("running") }).strict(), + z + .object({ + requests: z.array(invocationInputRequestSchema).readonly(), + resume: invokeResumeSchema, + status: z.literal("input-required"), + }) + .strict(), + z + .object({ + authorizations: z.array(authorizationInterruptSchema).min(1).readonly(), + resume: invokeResumeSchema, + status: z.literal("authorization-required"), + }) + .strict(), + z + .object({ outcome: turnOutcomeSchema, resume: invokeResumeSchema, status: z.literal("ready") }) + .strict(), + z.object({ message: z.string(), status: z.literal("failed") }).strict(), + z + .object({ + code: z.string().optional(), + message: z.string(), + resume: invokeResumeSchema.optional(), + status: z.literal("authentication-required"), + }) + .strict(), +]); + +/** Durable session coordinates emitted by `eve invoke`. Credentials are deliberately excluded. */ +export type InvokeResume = z.infer; +export type InvocationInputRequest = z.infer; +/** Result emitted by one non-interactive agent invocation. */ +export type InvokeResult = z.infer; +export type InvokeAuthenticationFailure = Extract< + InvokeResult, + { status: "authentication-required" } +>; + +/** Projects a runtime input request to the stable invocation-facing contract. */ +export function projectInvocationInputRequest(request: InputRequest): InvocationInputRequest { + const { allowFreeform, options, prompt, requestId } = request; + return { + allowFreeform, + options: options?.map(({ description, id, label }) => ({ description, id, label })), + prompt, + requestId, + }; +} + +/** Parses a complete, resumable result from a previous `eve invoke` command. */ +export function parseInvokeResumeInput(value: unknown): InvokeResult & { resume: InvokeResume } { + const result = invokeResultSchema.safeParse(value); + if (result.success && "resume" in result.data && result.data.resume !== undefined) { + return result.data as InvokeResult & { resume: InvokeResume }; + } + throw new Error("Resume JSON is not a valid resumable eve invoke result."); +} + +/** JSON Schema generated from the canonical invoke result runtime schema. */ +export const invokeResultJsonSchema = { + ...z.toJSONSchema(invokeResultSchema, { + io: "input", + target: "draft-2020-12", + unrepresentable: "any", + }), + $id: "https://eve.dev/schemas/invoke-result.json", + title: "eve invoke result", +}; diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index b8e5db59a..19c9c2261 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -204,6 +204,113 @@ describe("eve dev --input", () => { }); }); +describe("eve invoke", () => { + it("runs a fresh remote task without starting the TUI", async () => { + const runInvoke = vi.fn(async () => ({ + status: "ready" as const, + outcome: { status: "completed" as const, message: "done" }, + resume: { + session: { sessionId: "ses_1", streamIndex: 1 }, + target: { kind: "remote" as const, serverUrl: "https://example.com/" }, + }, + })); + const output: string[] = []; + + await runCli( + ["invoke", "--url", "https://example.com", "--scope", "target-team", "do foo"], + { error: () => {}, log: (message) => output.push(message) }, + { runInvoke }, + ); + + expect(runInvoke).toHaveBeenCalledWith({ + operation: { kind: "send", payload: { message: "do foo" } }, + signal: expect.any(AbortSignal), + target: { + kind: "remote", + serverUrl: "https://example.com/", + workspaceRoot: process.cwd(), + }, + vercelScope: "target-team", + }); + expect(JSON.parse(output.at(-1)!)).toMatchObject({ + status: "ready", + outcome: { status: "completed", message: "done" }, + }); + }); + + it("rejects a Vercel scope without a URL target", async () => { + await expect( + runCli(["invoke", "--scope", "target-team", "do foo"], { + error: () => {}, + log: () => {}, + }), + ).rejects.toThrow("--scope option requires a URL target"); + }); + + it("accepts an explicitly supplied URL equivalent to the stored resume target", async () => { + const runInvoke = vi.fn(async () => ({ + status: "running" as const, + resume: { + session: { sessionId: "ses_1", streamIndex: 0 }, + target: { kind: "remote" as const, serverUrl: "https://example.com/" }, + }, + })); + const resumeResult = JSON.stringify({ + status: "ready", + outcome: { status: "completed", message: "done" }, + resume: { + session: { sessionId: "ses_1", streamIndex: 0 }, + target: { kind: "remote", serverUrl: "https://example.com/" }, + }, + }); + const stdin = vi + .spyOn(process.stdin, Symbol.asyncIterator) + .mockImplementation(async function* () { + yield resumeResult; + return undefined; + }); + + try { + await runCli( + ["invoke", "--resume", "--url", "https://example.com", "follow up"], + { error: () => {}, log: () => {} }, + { runInvoke }, + ); + } finally { + stdin.mockRestore(); + } + + expect(runInvoke).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ serverUrl: "https://example.com/" }), + }), + ); + }); + + it("prints the JSON schema without invoking an agent", async () => { + const runInvoke = vi.fn(); + const output: string[] = []; + + await runCli( + ["invoke", "--json-schema"], + { error: () => {}, log: (message) => output.push(message) }, + { runInvoke }, + ); + + expect(runInvoke).not.toHaveBeenCalled(); + expect(JSON.parse(output[0]!)).toMatchObject({ title: "eve invoke result" }); + }); + + it("requires a prompt for a fresh invocation", async () => { + await expect( + runCli(["invoke", "--url", "https://example.com"], { + error: () => {}, + log: () => {}, + }), + ).rejects.toThrow("requires a prompt"); + }); +}); + describe("eve dev --url protocol", () => { it("preserves query parameters on the remote target URL", async () => { const runDevelopmentTui = await runInteractiveDev([ diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 87d0035af..6985dddae 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -22,6 +22,10 @@ import { type DevelopmentRequestHeaders, } from "#cli/dev/url-target.js"; import type { RunDevelopmentTuiInput } from "#cli/dev/tui/tui.js"; +import { + registerRuntimeInvokeCommand, + type InvokeCliRuntimeDependencies, +} from "#cli/invoke/command.js"; import { LOG_DISPLAY_MODES, parseLogDisplayMode } from "#cli/dev/tui/log-display-mode.js"; import { resolveTuiTitle, type DevelopmentTuiTarget } from "#cli/dev/tui/target.js"; import { parseDevelopmentServerUrl } from "#cli/dev/url.js"; @@ -81,6 +85,7 @@ interface CliRuntimeDependencies { options?: { json?: boolean }, ): Promise; runDevelopmentTui(input: RunDevelopmentTuiInput): Promise; + runInvoke: InvokeCliRuntimeDependencies["runInvoke"]; runEvalCommand( evalIds: readonly string[], options: EvalCliOptions, @@ -159,14 +164,6 @@ async function loadStartProductionHost(): Promise { - if (shouldPrintCliBootBanner(actionCommand)) { + if (["info", "dev", "init"].includes(actionCommand.name())) { logger.log(eveCliBanner()); } }) @@ -371,6 +368,8 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm await waitForShutdownSignal({ close: () => server.close(), wait: () => server.wait() }); }); + registerRuntimeInvokeCommand({ appRoot, logger, program, runtime }); + program .command("dev") .description("Start the eve development server or connect to an existing URL.") diff --git a/packages/eve/src/client/message-response.ts b/packages/eve/src/client/message-response.ts index f3fe1573e..544a76fbb 100644 --- a/packages/eve/src/client/message-response.ts +++ b/packages/eve/src/client/message-response.ts @@ -1,10 +1,6 @@ import type { HandleMessageStreamEvent } from "#protocol/message.js"; import { extractCompletedResult } from "#client/output-schema.js"; -import { - deriveResultStatus, - extractCompletedMessage, - extractInputRequests, -} from "#client/session-utils.js"; +import { summarizeTurnEvents } from "#client/session-utils.js"; import type { MessageResult } from "#client/types.js"; /** @@ -55,13 +51,14 @@ export class MessageResponse implements AsyncIterable(events), events, - inputRequests: extractInputRequests(events), - message: extractCompletedMessage(events), + inputRequests: summary.inputRequests, + message: summary.message, sessionId: this.sessionId, - status: deriveResultStatus(events), + status: summary.status, }; } diff --git a/packages/eve/src/client/session-utils.test.ts b/packages/eve/src/client/session-utils.test.ts new file mode 100644 index 000000000..a7fe1283c --- /dev/null +++ b/packages/eve/src/client/session-utils.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import type { HandleMessageStreamEvent } from "#protocol/message.js"; + +import { collectTurnEvents, summarizeTurnEvents } from "./session-utils.js"; + +const eventData = { sequence: 1, stepIndex: 0, turnId: "turn_1" }; + +describe("summarizeTurnEvents", () => { + it("projects the complete waiting-turn lifecycle in one pass", () => { + const request = { + action: { callId: "call_1", input: {}, kind: "tool-call" as const, toolName: "bash" }, + display: "confirmation" as const, + options: [{ id: "approve", label: "Approve" }], + prompt: "Approve?", + requestId: "request_1", + }; + const events = [ + { + type: "message.completed", + data: { + finishReason: "stop", + message: "Working on it", + sequence: 1, + stepIndex: 0, + turnId: "turn_1", + }, + }, + { type: "input.requested", data: { ...eventData, requests: [request] } }, + { + type: "authorization.required", + data: { ...eventData, description: "Sign in", name: "linear", webhookUrl: "https://auth" }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:next", wait: "next-user-message" }, + }, + ] satisfies HandleMessageStreamEvent[]; + + expect(summarizeTurnEvents(events)).toMatchObject({ + boundary: { type: "session.waiting" }, + inputRequests: [request], + message: "Working on it", + pendingAuthorizations: [ + { description: "Sign in", name: "linear", webhookUrl: "https://auth" }, + ], + status: "waiting", + }); + }); + + it("removes completed authorizations and retains the final turn failure", () => { + const events = [ + { + type: "authorization.required", + data: { ...eventData, description: "Sign in", name: "linear" }, + }, + { + type: "authorization.completed", + data: { ...eventData, name: "linear", outcome: "authorized" }, + }, + { + type: "turn.failed", + data: { code: "provider_error", message: "Unavailable", sequence: 2, turnId: "turn_1" }, + }, + { + type: "session.waiting", + data: { continuationToken: "eve:next", wait: "next-user-message" }, + }, + ] satisfies HandleMessageStreamEvent[]; + + expect(summarizeTurnEvents(events)).toMatchObject({ + failure: { type: "turn.failed", data: { message: "Unavailable" } }, + pendingAuthorizations: [], + status: "waiting", + }); + }); +}); + +describe("collectTurnEvents", () => { + it("stops at the current-turn boundary", async () => { + async function* stream(): AsyncGenerator { + yield { + type: "session.waiting", + data: { continuationToken: "eve:next", wait: "next-user-message" }, + }; + yield { type: "session.completed" }; + } + + await expect(collectTurnEvents(stream())).resolves.toEqual([ + { + type: "session.waiting", + data: { continuationToken: "eve:next", wait: "next-user-message" }, + }, + ]); + }); +}); diff --git a/packages/eve/src/client/session-utils.ts b/packages/eve/src/client/session-utils.ts index 757373b25..ce87cd625 100644 --- a/packages/eve/src/client/session-utils.ts +++ b/packages/eve/src/client/session-utils.ts @@ -1,5 +1,10 @@ -import type { HandleMessageStreamEvent, MessageCompletedStreamEvent } from "#protocol/message.js"; -import { isCurrentTurnBoundaryEvent } from "#protocol/message.js"; +import type { + AuthorizationRequiredStreamEvent, + HandleMessageStreamEvent, + MessageCompletedStreamEvent, + TurnFailureStreamEvent, +} from "#protocol/message.js"; +import { isCurrentTurnBoundaryEvent, isTurnFailureEvent } from "#protocol/message.js"; import type { SessionState } from "#client/types.js"; import type { InputRequest } from "#runtime/input/types.js"; @@ -53,53 +58,70 @@ export function advanceSession(input: { return createInitialSessionState(); } -/** - * Extracts the final completed assistant message text from a turn's events. - * - * Only considers terminal messages (finish reason is not `"tool-calls"`). - */ -export function extractCompletedMessage( - events: readonly HandleMessageStreamEvent[], -): string | undefined { - let lastMessage: string | undefined; - - for (const event of events) { - if (isFinalMessageCompleted(event)) { - lastMessage = event.data.message ?? undefined; - } - } - - return lastMessage; +/** A connection authorization challenge that remains unresolved at a turn boundary. */ +export interface PendingAuthorization { + readonly authorization?: AuthorizationRequiredStreamEvent["data"]["authorization"]; + readonly description: string; + readonly name: string; + readonly webhookUrl?: string; } -/** - * Derives the result status from a turn's boundary event. - */ -export function deriveResultStatus( - events: readonly HandleMessageStreamEvent[], -): "completed" | "failed" | "waiting" { - const boundary = findBoundaryEvent(events); - - if (boundary?.type === "session.waiting") return "waiting"; - if (boundary?.type === "session.failed") return "failed"; - return "completed"; +/** Canonical projection of the lifecycle state represented by one turn's events. */ +export interface TurnEventSummary { + readonly boundary: HandleMessageStreamEvent | undefined; + readonly failure: TurnFailureStreamEvent | undefined; + readonly inputRequests: readonly InputRequest[]; + readonly message: string | undefined; + readonly pendingAuthorizations: readonly PendingAuthorization[]; + readonly status: "completed" | "failed" | "waiting"; } -/** - * Collects HITL input requests emitted during one consumed turn. - */ -export function extractInputRequests( - events: readonly HandleMessageStreamEvent[], -): readonly InputRequest[] { - const requests: InputRequest[] = []; +/** Reduces one turn's protocol events into their client-facing lifecycle state. */ +export function summarizeTurnEvents(events: readonly HandleMessageStreamEvent[]): TurnEventSummary { + let boundary: HandleMessageStreamEvent | undefined; + let failure: TurnFailureStreamEvent | undefined; + let message: string | undefined; + const inputRequests: InputRequest[] = []; + const pendingAuthorizations = new Map(); for (const event of events) { - if (event.type === "input.requested") { - requests.push(...event.data.requests); + if (isCurrentTurnBoundaryEvent(event)) boundary = event; + if (isTurnFailureEvent(event)) failure = event; + if (isFinalMessageCompleted(event)) message = event.data.message ?? undefined; + if (event.type === "input.requested") inputRequests.push(...event.data.requests); + if (event.type === "authorization.required") { + pendingAuthorizations.set(event.data.name, event.data); + } + if (event.type === "authorization.completed") { + pendingAuthorizations.delete(event.data.name); } } - return requests; + return { + boundary, + failure, + inputRequests, + message, + pendingAuthorizations: [...pendingAuthorizations.values()], + status: + boundary?.type === "session.waiting" + ? "waiting" + : boundary?.type === "session.failed" + ? "failed" + : "completed", + }; +} + +/** Collects one segment of an event stream through its current-turn boundary. */ +export async function collectTurnEvents( + stream: AsyncIterable, +): Promise { + const events: HandleMessageStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + if (isCurrentTurnBoundaryEvent(event)) break; + } + return events; } function findBoundaryEvent( diff --git a/packages/eve/src/evals/session.ts b/packages/eve/src/evals/session.ts index 766a52915..76f1a025f 100644 --- a/packages/eve/src/evals/session.ts +++ b/packages/eve/src/evals/session.ts @@ -12,11 +12,7 @@ import type { } from "#client/types.js"; import type { HandleMessageStreamEvent, TurnFailureStreamEvent } from "#protocol/message.js"; import { isCurrentTurnBoundaryEvent, isTurnFailureEvent } from "#protocol/message.js"; -import { - deriveResultStatus, - extractCompletedMessage, - extractInputRequests, -} from "#client/session-utils.js"; +import { summarizeTurnEvents } from "#client/session-utils.js"; import { extractCompletedResult } from "#client/output-schema.js"; import type { InputRequest, InputResponse } from "#runtime/input/types.js"; import { deriveRunFacts } from "#evals/runner/derive-run-facts.js"; @@ -246,13 +242,14 @@ export class EvalSessionDriver implements EveEvalSession { } #recordObservedTurn(sessionId: string, events: readonly HandleMessageStreamEvent[]): EveEvalTurn { + const summary = summarizeTurnEvents(events); return this.#recordTurn({ data: extractCompletedResult(events), events, - inputRequests: extractInputRequests(events), - message: extractCompletedMessage(events), + inputRequests: summary.inputRequests, + message: summary.message, sessionId, - status: deriveResultStatus(events), + status: summary.status, }); } diff --git a/packages/eve/src/cli/dev/tui/agent-info-probe.test.ts b/packages/eve/src/services/dev-client/agent-info-probe.test.ts similarity index 100% rename from packages/eve/src/cli/dev/tui/agent-info-probe.test.ts rename to packages/eve/src/services/dev-client/agent-info-probe.test.ts diff --git a/packages/eve/src/cli/dev/tui/agent-info-probe.ts b/packages/eve/src/services/dev-client/agent-info-probe.ts similarity index 100% rename from packages/eve/src/cli/dev/tui/agent-info-probe.ts rename to packages/eve/src/services/dev-client/agent-info-probe.ts diff --git a/packages/eve/src/services/dev-client/client-options.ts b/packages/eve/src/services/dev-client/client-options.ts index 2f283de1e..290270978 100644 --- a/packages/eve/src/services/dev-client/client-options.ts +++ b/packages/eve/src/services/dev-client/client-options.ts @@ -5,7 +5,8 @@ import type { DevelopmentCredentialGate } from "./credential-gate.js"; type DevelopmentClientHeaders = Readonly>; -function hasAuthorizationHeader( +/** Returns whether development request headers explicitly own HTTP authorization. */ +export function hasDevelopmentAuthorizationHeader( headers: DevelopmentClientHeaders | undefined, ): headers is DevelopmentClientHeaders { return ( @@ -61,7 +62,7 @@ export function resolveLocalDevelopmentClientOptions(input: { redirect: "manual", } satisfies ClientOptions; - if (hasAuthorizationHeader(input.headers)) { + if (hasDevelopmentAuthorizationHeader(input.headers)) { return { ...options, headers: input.headers }; } @@ -88,7 +89,7 @@ export function resolveRemoteDevelopmentClientOptions(input: { `Credential gate origin ${input.credentials.serverOrigin} does not match client origin ${serverOrigin}.`, ); } - if (hasAuthorizationHeader(input.headers)) { + if (hasDevelopmentAuthorizationHeader(input.headers)) { return { headers: () => resolveRemoteHeaders({ diff --git a/packages/eve/src/services/dev-client/target.ts b/packages/eve/src/services/dev-client/target.ts new file mode 100644 index 000000000..e57c81150 --- /dev/null +++ b/packages/eve/src/services/dev-client/target.ts @@ -0,0 +1,19 @@ +/** Server target used by development clients. */ +interface DevelopmentTargetBase { + readonly serverUrl: string; + /** Local workspace root for app files and the fallback Vercel project link. */ + readonly workspaceRoot: string; +} + +/** A development client backed by a local `eve dev` server. */ +export interface LocalDevelopmentTarget extends DevelopmentTargetBase { + readonly kind: "local"; +} + +/** A development client connected to an existing remote server. */ +export interface RemoteDevelopmentTarget extends DevelopmentTargetBase { + readonly kind: "remote"; +} + +/** Local or remote server backing a development client. */ +export type DevelopmentTarget = LocalDevelopmentTarget | RemoteDevelopmentTarget; diff --git a/packages/eve/src/setup/verified-remote-client.test.ts b/packages/eve/src/setup/verified-remote-client.test.ts index 1f7bca453..2f57a02f7 100644 --- a/packages/eve/src/setup/verified-remote-client.test.ts +++ b/packages/eve/src/setup/verified-remote-client.test.ts @@ -15,15 +15,23 @@ describe("resolveVerifiedRemoteDevelopmentClient", () => { kind: "resolved" as const, token: " fresh-token ", })); + const resolveVercelDeployment = vi.fn(async () => ({ kind: "resolved" as const, target })); const { options } = await resolveVerifiedRemoteDevelopmentClient({ serverUrl: "https://example.vercel.app/path", + vercelScope: "target-team", workspaceRoot: "/workspace", deps: { - resolveVercelDeployment: async () => ({ kind: "resolved", target }), + resolveVercelDeployment, resolveDevelopmentOidcToken, }, }); + expect(resolveVercelDeployment).toHaveBeenCalledWith({ + host: "example.vercel.app", + scope: "target-team", + signal: undefined, + workspaceRoot: "/workspace", + }); expect(options.redirect).toBe("manual"); // The OIDC token rides the higher-level vercelOidc auth; the client expands // it into the Authorization + trusted-OIDC headers (covered in client.test.ts). @@ -60,6 +68,59 @@ describe("resolveVerifiedRemoteDevelopmentClient", () => { expect(lastOidcTokenFailure()).toEqual(failure); }); + it("preserves explicit non-authorization headers with verified ambient credentials", async () => { + const { options } = await resolveVerifiedRemoteDevelopmentClient({ + headers: { "x-tenant": "acme" }, + serverUrl: "https://example.vercel.app/path", + workspaceRoot: "/workspace", + deps: { + resolveVercelDeployment: async () => ({ kind: "resolved", target }), + resolveDevelopmentOidcToken: async () => ({ + kind: "resolved" as const, + token: "ambient-token", + }), + }, + }); + + expect(options.auth).toEqual({ vercelOidc: { token: expect.any(Function) } }); + if (typeof options.headers !== "function") throw new Error("Expected dynamic headers."); + await expect(options.headers()).resolves.toEqual({ "x-tenant": "acme" }); + }); + + it("lets explicit authorization bypass ambient credential discovery", async () => { + const resolveVercelDeployment = vi.fn(async () => ({ kind: "resolved" as const, target })); + const { options } = await resolveVerifiedRemoteDevelopmentClient({ + headers: { authorization: "Bearer explicit", "x-tenant": "acme" }, + serverUrl: "https://example.vercel.app/path", + workspaceRoot: "/workspace", + deps: { + resolveVercelDeployment, + resolveDevelopmentOidcToken: vi.fn(), + }, + }); + + expect(resolveVercelDeployment).not.toHaveBeenCalled(); + expect(options.auth).toBeUndefined(); + if (typeof options.headers !== "function") throw new Error("Expected dynamic headers."); + await expect(options.headers()).resolves.toEqual({ + authorization: "Bearer explicit", + "x-tenant": "acme", + }); + }); + + it("exposes a missed deployment lookup to callers", async () => { + const result = await resolveVerifiedRemoteDevelopmentClient({ + serverUrl: "https://arbitrary.example.com", + workspaceRoot: "/workspace", + deps: { + resolveVercelDeployment: async () => ({ kind: "not-found" }), + resolveDevelopmentOidcToken: vi.fn(), + }, + }); + + expect(result.deploymentResolution).toEqual({ kind: "not-found" }); + }); + it("keeps an unverified remote anonymous", async () => { const resolveDevelopmentOidcToken = vi.fn(async () => ({ kind: "resolved" as const, diff --git a/packages/eve/src/setup/verified-remote-client.ts b/packages/eve/src/setup/verified-remote-client.ts index 4f0e8197d..b8fd0e3e5 100644 --- a/packages/eve/src/setup/verified-remote-client.ts +++ b/packages/eve/src/setup/verified-remote-client.ts @@ -1,12 +1,15 @@ import type { ClientOptions } from "#client/index.js"; -import { resolveRemoteDevelopmentClientOptions } from "#services/dev-client/client-options.js"; +import { + hasDevelopmentAuthorizationHeader, + resolveRemoteDevelopmentClientOptions, +} from "#services/dev-client/client-options.js"; import { createDevelopmentCredentialGate } from "#services/dev-client/credential-gate.js"; import { type DevelopmentOidcTokenFailure, resolveDevelopmentOidcToken, } from "#services/dev-client/request-headers.js"; -import { resolveVercelDeployment } from "./vercel-deployment.js"; +import { resolveVercelDeployment, type VercelDeploymentResolution } from "./vercel-deployment.js"; /** Dependencies for verifying one remote client (injectable for tests). */ export interface VerifiedRemoteDevelopmentClientDeps { @@ -16,6 +19,7 @@ export interface VerifiedRemoteDevelopmentClientDeps { /** A verified remote client's options plus a reader for its latest OIDC failure. */ export interface VerifiedRemoteDevelopmentClient { + readonly deploymentResolution?: VercelDeploymentResolution; readonly options: ClientOptions; /** OIDC failure from the most recent request, or `undefined` while healthy. */ readonly lastOidcTokenFailure: () => DevelopmentOidcTokenFailure | undefined; @@ -31,27 +35,41 @@ const defaultDeps: VerifiedRemoteDevelopmentClientDeps = { * exact origin proof, plus a reader for the latest OIDC token failure. */ export async function resolveVerifiedRemoteDevelopmentClient(input: { + readonly headers?: Readonly>; readonly serverUrl: string; + readonly signal?: AbortSignal; + readonly vercelScope?: string; readonly workspaceRoot: string; readonly deps?: Partial; }): Promise { const deps = { ...defaultDeps, ...input.deps }; const credentials = createDevelopmentCredentialGate(input.serverUrl); - const resolution = await deps.resolveVercelDeployment({ - workspaceRoot: input.workspaceRoot, - host: new URL(input.serverUrl).host, - }); - - if (resolution.kind === "resolved") { - const { ownerId, projectId } = resolution.target.deployment; - credentials.authorize({ - target: resolution.target, - resolveToken: () => deps.resolveDevelopmentOidcToken({ ownerId, projectId }), + let deploymentResolution: VercelDeploymentResolution | undefined; + + if (!hasDevelopmentAuthorizationHeader(input.headers)) { + deploymentResolution = await deps.resolveVercelDeployment({ + workspaceRoot: input.workspaceRoot, + host: new URL(input.serverUrl).host, + scope: input.vercelScope, + signal: input.signal, }); + + if (deploymentResolution.kind === "resolved") { + const { ownerId, projectId } = deploymentResolution.target.deployment; + credentials.authorize({ + target: deploymentResolution.target, + resolveToken: () => deps.resolveDevelopmentOidcToken({ ownerId, projectId }), + }); + } } return { - options: resolveRemoteDevelopmentClientOptions({ serverUrl: input.serverUrl, credentials }), + deploymentResolution, + options: resolveRemoteDevelopmentClientOptions({ + credentials, + headers: input.headers, + serverUrl: input.serverUrl, + }), lastOidcTokenFailure: credentials.lastTokenFailure, }; }