fix(cli): rewrite noninteractive run and remove the V1 bridge (#61911)

This commit is contained in:
Aiden Cline 2026-11-05 17:58:28 -06:00 • committed by GitHub
parent 0dffb96c4b
commit a950fa2741
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1077 additions and 1147 deletions

View file

@ -13,7 +13,6 @@
],
"exports": {
"./vite-host": "./dev/host.js",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
"scripts": {

View file

@ -12,23 +12,33 @@ export default Runtime.handler(Commands.commands.run, (input) =>
Effect.catch((error) => Effect.sync(() => reportRunError(input, error.message))),
)
if (!server) return
yield* Effect.promise(() =>
runNonInteractive({
server,
message: [...input.message],
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
worktree: Option.getOrUndefined(input.worktree),
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
format: input.format,
file: [...input.file],
title: Option.getOrUndefined(input.title),
archived: input.archived,
thinking: input.thinking,
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
}),
)
// SIGINT and SIGTERM interrupt this fiber. Interruption waits for the run to cancel its Session and
// jobs, so the private server and the process shut down only after that.
yield* Effect.callback<void>((resume) => {
const controller = new AbortController()
const done = runNonInteractive(
{
server,
message: [...input.message],
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
worktree: Option.getOrUndefined(input.worktree),
model: Option.getOrUndefined(input.model),
agent: Option.getOrUndefined(input.agent),
format: input.format,
file: [...input.file],
title: Option.getOrUndefined(input.title),
archived: input.archived,
thinking: input.thinking,
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
controller.signal,
).then(() => resume(Effect.void))
return Effect.promise(() => {
controller.abort()
return done
})
})
}),
)

View file

@ -3,7 +3,7 @@
import { ensurePluginRuntime } from "@opencode/plugin/runtime"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
import { defaultTeardown, getErrorReported } from "effect/Runtime"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode/util/observability"
@ -39,6 +39,8 @@ if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
}
let terminated = false
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
@ -100,6 +102,10 @@ Effect.gen(function* () {
yield* Heap.listen
yield* CpuProfile.listen
yield* ShutdownDeadline.install()
// runMain reports every signal interruption as 130; remember a SIGTERM so the exit code is 143.
process.once("SIGTERM", () => {
terminated = true
})
const runFork = Effect.runForkWith(yield* Effect.context<never>())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
@ -152,7 +158,6 @@ Effect.gen(function* () {
),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
// channel through which a startup failure's reason reaches the user.
@ -163,5 +168,16 @@ Effect.gen(function* () {
process.stderr.write(Cause.pretty(cause) + "\n")
}),
),
NodeRuntime.runMain({ disableErrorReporting: true }),
NodeRuntime.runMain({
disableErrorReporting: true,
teardown: (exit) =>
defaultTeardown(exit, (code) => {
// Commands report failures through process.exitCode.
const status = code === 0 ? (process.exitCode ?? 0) : code === 130 && terminated ? 143 : code
// process.exit drops output still queued for a pipe; ending the streams waits for it.
void Promise.all(
[process.stdout, process.stderr].map((stream) => new Promise<void>((resolve) => stream.end(() => resolve()))),
).then(() => process.exit(status))
}),
}),
)

View file

@ -1,2 +0,0 @@
export { runNonInteractive, type RunCommandInput } from "./run"
export { runV1Bridge, type V1RunCommandInput } from "./v1"

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ import { FSUtil } from "@opencode/util/fs-util"
import { noTimeoutFetch } from "@opencode/util/no-timeout-fetch"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { readStdin, readStdinIfReady } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { createWorktreeLocation, parseSessionTargetModel, resolveSessionTarget } from "../session-target"
import { toolInlineInfo } from "@opencode/tui/mini/tool"
@ -30,27 +30,14 @@ export type RunCommandInput = {
auto?: boolean
}
type FilePart = {
url: string
filename: string
mime: string
}
type FilePart = { text: string } | { attachment: { uri: string; name: string } }
type Prepared = {
directory?: string
directory: string
message: string
files: FilePart[]
}
type ExecutionOptions = {
root?: string
directory?: string
useServerDirectory?: boolean
variant?: string
attached?: boolean
compatibility?: "v1"
}
class RunTargetError extends Error {
constructor(
message: string,
@ -61,31 +48,34 @@ class RunTargetError extends Error {
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
const PIPED_INPUT_GRACE_MS = 1000
export function runNonInteractive(input: RunCommandInput) {
return runNonInteractiveWithOptions(input, {})
/** `signal` aborts on SIGINT or SIGTERM; the run then cancels its Session and background jobs and returns. */
export function runNonInteractive(input: RunCommandInput, signal: AbortSignal) {
return run(input, signal).catch((error) => reportRunError(input, errorMessage(error)))
}
/** @internal Used only by the V1 command boundary. */
export function runNonInteractiveWithOptions(input: RunCommandInput, options: ExecutionOptions) {
return run(input, options).catch((error) => reportRunError(input, errorMessage(error)))
}
async function run(input: RunCommandInput, options: ExecutionOptions) {
async function run(input: RunCommandInput, signal: AbortSignal) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
if (input.worktree !== undefined && (input.continue || input.session))
fail("--worktree starts a new session and cannot be combined with --continue or --session")
const root = options.root ?? process.env.PWD ?? process.cwd()
const local = localDirectory(root)
const directory = options.useServerDirectory ? undefined : (options.directory ?? local)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
const root = process.env.PWD ?? process.cwd()
const directory = localDirectory(root)
const argument = input.message.join(" ") || undefined
// With a message argument, stdin is extra context: a shell pipe is read in full, but the open, silent
// stdin that spawn's default stdio gives a child must not hold the run.
const piped = process.stdin.isTTY
? undefined
: argument
? await readStdinIfReady(PIPED_INPUT_GRACE_MS)
: await readStdin()
const message = mergeInput(argument, piped)
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(input.file.map((file) => prepareFile(file, root, options)))
const prepared = { directory, message, files }
return execute(input, prepared, input.server.endpoint, options)
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
return execute(input, { directory, message, files }, input.server.endpoint, signal)
}
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, signal: AbortSignal) {
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
@ -93,7 +83,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
fetch: noTimeoutFetch,
})
const explicit = parseRunModel(input.model)
const location = prepared.directory ? { directory: prepared.directory } : undefined
const location = { directory: prepared.directory }
const worktree =
input.worktree === undefined
? undefined
@ -115,23 +105,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
agent: input.agent,
environment: input.server.service ? Env.session() : undefined,
prepare: async (next) => {
const selected =
next.model ??
(options.variant
? await client.model
.default({ location: { directory: next.location.directory } })
.then((result) => result.data)
: undefined)
const model = selected
? {
providerID: selected.providerID,
id: selected.id,
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
}
: undefined
if ((options.variant ?? explicit?.variant) && !model)
if (explicit?.variant && !next.model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
return { model, agent: next.agent }
return { model: next.model, agent: next.agent }
},
}).catch((error) => {
if (!(error instanceof RunTargetError)) throw error
@ -139,8 +115,6 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
return undefined
})
if (!target) return
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
const title =
!target.resume && input.title !== undefined
? input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : "")
@ -155,16 +129,15 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
client,
sessionID: target.session.id,
location: target.location,
message: prepared.message,
files: prepared.files,
message: [prepared.message, ...prepared.files.flatMap((file) => ("text" in file ? [file.text] : []))].join("\n\n"),
files: prepared.files.flatMap((file) => ("attachment" in file ? [file.attachment] : [])),
agent: target.agent,
model,
variant,
model: target.model,
thinking: input.thinking ?? false,
format: input.format,
auto: input.auto ?? false,
attached: options.attached ?? true,
compatibility: options.compatibility,
standalone: input.server.standalone ?? false,
signal,
renderTool: (part) => renderTool(part, target.location.directory),
renderToolError: (part) => renderToolError(part, target.location.directory),
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
@ -176,11 +149,6 @@ export function mergeInput(message: string | undefined, piped: string | undefine
return message + "\n" + piped
}
function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined
}
function localDirectory(root: string) {
try {
process.chdir(root)
@ -199,13 +167,11 @@ export function parseRunModel(value?: string) {
}
}
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
async function prepareFile(input: string, directory: string): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
try {
const stat = await handle.stat()
if (options.compatibility === "v1" && options.attached && stat.isDirectory())
fail(`Cannot attach local directory without a shared filesystem: ${input}`)
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
const content = Buffer.alloc(Number(stat.size))
@ -224,11 +190,9 @@ async function prepareFile(input: string, directory: string, options: ExecutionO
: !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes)
? "text/plain"
: detected
return {
url: `data:${mime};base64,${bytes.toString("base64")}`,
filename: path.basename(file),
mime,
}
const name = path.basename(file)
if (mime === "text/plain") return { text: `<file name="${name}">\n${text}\n</file>` }
return { attachment: { uri: `data:${mime};base64,${bytes.toString("base64")}`, name } }
} finally {
await handle.close()
}
@ -261,7 +225,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
/** @internal Used by the V1 command boundary before a Session exists. */
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
process.exitCode = 1
if (input.format === "json") {

View file

@ -1,86 +0,0 @@
import type { Endpoint } from "@opencode/client/effect/service"
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../services/standalone"
import { errorMessage } from "../util/error"
import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
export type V1RunCommandInput = {
message: string[]
continue?: boolean
session?: string
fork?: boolean
model?: string
agent?: string
format: "default" | "json"
file: string[]
title?: string
server?: string
password?: string
username?: string
directory?: string
variant?: string
thinking?: boolean
dangerouslySkipPermissions?: boolean
standaloneCommand?: ReadonlyArray<string>
}
export function runV1Bridge(input: V1RunCommandInput) {
const root = process.env.PWD ?? process.cwd()
const attached = input.server !== undefined
const local = !attached && input.directory ? path.resolve(root, input.directory) : root
try {
process.chdir(local)
} catch {
reportRunError(input, `Failed to change directory to ${local}`)
return Promise.resolve()
}
return Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = attached
? explicitEndpoint(input)
: yield* Standalone.start({ command: input.standaloneCommand })
yield* Effect.promise(() =>
runNonInteractiveWithOptions(nativeInput(input, endpoint), {
root: local,
directory: attached ? input.directory : local,
useServerDirectory: attached && input.directory === undefined,
variant: input.variant,
attached,
compatibility: "v1",
}),
)
}),
),
).catch((error) => reportRunError(input, errorMessage(error)))
}
function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
return {
server: { endpoint },
message: input.message,
continue: input.continue,
session: input.session,
fork: input.fork,
model: input.model,
agent: input.agent,
format: input.format,
file: input.file,
title: input.title,
thinking: input.thinking,
auto: input.dangerouslySkipPermissions,
}
}
function explicitEndpoint(input: V1RunCommandInput): Endpoint {
const url = input.server
if (!url) throw new Error("Missing V1 server URL")
return {
url,
auth: input.password
? { type: "basic", username: input.username ?? "opencode", password: input.password }
: undefined,
}
}

View file

@ -20,6 +20,8 @@ export type Args = {
export type Resolved = {
readonly endpoint: Endpoint
readonly service?: ReturnType<typeof managedService>
/** A private server started for this command alone. */
readonly standalone?: boolean
}
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) {
@ -47,14 +49,14 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
return { endpoint } satisfies Resolved
}
if (standalone || (yield* ServiceConfig.read()).disabled === true) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
return { endpoint: yield* Standalone.start(), standalone: true } satisfies Resolved
}
const mismatch = args.mismatch ?? "ignore"
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
if (args.autostart === false) {
const endpoint = yield* Service.discover({ ...options, version: undefined })
if (!endpoint) return { endpoint: yield* Standalone.start() } satisfies Resolved
if (!endpoint) return { endpoint: yield* Standalone.start(), standalone: true } satisfies Resolved
return { endpoint, service: managedService(options) } satisfies Resolved
}
return {

View file

@ -1,5 +1,24 @@
import { once } from "node:events"
import { fstatSync } from "node:fs"
import { text } from "node:stream/consumers"
export function readStdin() {
return text(process.stdin)
}
/**
* Reads a shell pipe or redirected file in full, however slow its producer. Any other stdin (the socket
* that spawn's default stdio gives a child) is read only if it has data or ends within `grace`
* milliseconds; otherwise it is closed unread.
*/
export async function readStdinIfReady(grace: number) {
const stat = fstatSync(0)
if (stat.isFIFO() || stat.isFile()) return readStdin()
const ready = await once(process.stdin, "readable", { signal: AbortSignal.timeout(grace) }).then(
() => true,
() => false,
)
if (ready) return readStdin()
process.stdin.destroy()
return undefined
}

View file

@ -26,12 +26,10 @@ describe("CLI frontend import boundaries", () => {
})
test("exposes only the intentional package entrypoints", async () => {
const run = await import("@opencode/cli/run")
const mini = await import("@opencode/tui/mini")
const tool = await import("@opencode/tui/mini/tool")
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
expect(Object.keys(tool).sort()).toEqual([
"nonEmptyToolContent",

View file

@ -1,8 +1,11 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import {
ClientError,
OpenCode,
type EventSubscribeOutput,
type ExperimentalJobListOutput,
type SessionActiveOutput,
type SessionInboxListOutput,
type SessionMessageAssistantTool,
type SessionMessageInfo,
} from "@opencode/client/promise"
@ -100,6 +103,50 @@ function executionFailed(message: string): V2Event {
}
}
function idleMessage(outcome: "succeeded" | "failed" | "interrupted", id = "msg_idle") {
return {
id,
type: "idle",
outcome,
...(outcome === "failed" ? { error: { type: "provider.transport", message: "Execution failed" } } : {}),
time: { created: 5 },
} satisfies SessionMessageInfo
}
// A step that failed and was retried, followed by the step that recovered.
function recoveredMessages(outcome: "succeeded" | "failed" | "interrupted") {
return (messageID: string): SessionMessageInfo[] => [
idleMessage(outcome),
{
id: "msg_recovered",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "Recovered answer" }],
finish: "stop",
time: { created: 3, completed: 4 },
},
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "error",
error: { type: "provider.transport", message: "Recovered transport error" },
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
]
}
function jsonLines(stdout: string) {
return stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
}
function failedTool(inboxID: string): V2Event[] {
return [
prompted(inboxID),
@ -210,16 +257,18 @@ function successfulGrep(inboxID: string): V2Event[] {
async function run(input: {
turn: (inboxID: string) => V2Event[]
pendingForms?: FormInfo[]
attached?: boolean
standalone?: boolean
format?: "default" | "json"
compatibility?: "v1"
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
jobs?: () => ExperimentalJobListOutput
inbox?: (sessionID: string) => SessionInboxListOutput
active?: () => SessionActiveOutput
terminalDelay?: number
signal?: AbortSignal
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
@ -256,9 +305,14 @@ async function run(input: {
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.experimental.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.experimental.job, "list").mockImplementation(() => ok(input.jobs?.() ?? []) as never)
spyOn(sdk.session, "active").mockImplementation(() => ok({}) as never)
spyOn(sdk.session.inbox, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.experimental.job, "list").mockImplementation((async () => input.jobs?.() ?? []) as never)
spyOn(sdk.session, "active").mockImplementation((async () => input.active?.() ?? {}) as never)
spyOn(sdk.session.inbox, "list").mockImplementation(
(async (request: { sessionID: string }) => input.inbox?.(request.sessionID) ?? []) as never,
)
spyOn(sdk.session.inbox, "cancel").mockImplementation(() => ok(undefined))
spyOn(sdk.session, "interrupt").mockImplementation(() => ok({ interrupted: true }) as never)
spyOn(sdk.shell, "remove").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session, "get").mockImplementation(
({ sessionID }) => ok({ id: sessionID, parentID: sessionID === "ses_child" ? "ses_1" : undefined }) as never,
)
@ -285,8 +339,8 @@ async function run(input: {
thinking: false,
format: input.format ?? "default",
auto: false,
attached: input.attached ?? false,
compatibility: input.compatibility,
standalone: input.standalone ?? false,
signal: input.signal ?? new AbortController().signal,
renderTool: input.renderTool ?? (() => Promise.resolve()),
renderToolError: input.renderToolError ?? (() => Promise.resolve()),
})
@ -370,6 +424,7 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [prompted(messageID)],
wait: () => idle.promise,
messages: (messageID) => [
idleMessage("succeeded"),
{
id: "msg_assistant",
type: "assistant",
@ -405,6 +460,7 @@ describe("runNonInteractivePrompt", () => {
test("reports usage for a step that ends after the Session goes idle", async () => {
const tokens = { input: 3, output: 5, reasoning: 7, cache: { read: 11, write: 13 } }
const assistant = (messageID: string): SessionMessageInfo[] => [
idleMessage("succeeded"),
{
id: "msg_assistant",
type: "assistant",
@ -483,105 +539,30 @@ describe("runNonInteractivePrompt", () => {
}
})
test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
const sdk = await run({
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
// No prompted event: the execution settles interrupted before promotion,
// which must not leave the consume loop waiting forever.
turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
for (const standalone of [true, false]) {
test(`${standalone ? "a standalone" : "an attached"} run ${standalone ? "cancels" : "leaves"} MCP input requests in its location`, async () => {
const cancelled: { sessionID: string; formID: string }[] = []
const output = await capture({
format: "json",
standalone,
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [
formCreated(form("frm_live", "global")),
formCreated(form("frm_elsewhere", "global"), { ...location, directory: "/elsewhere" }),
prompted(messageID),
settled(),
],
cancel: (request) => {
cancelled.push(request)
return Promise.resolve()
},
})
const global = standalone ? ["frm_live", "frm_pending_global"] : []
expect(cancelled.map((request) => request.formID).sort()).toEqual(["frm_pending", ...global].sort())
expect(output.exitCode).toBe(1)
expect(jsonLines(output.stdout).filter((event) => event.type === "error")).toHaveLength(1)
})
const globalOptions = {
headers: {
"x-opencode-directory": "%2Fwork%20tree",
},
}
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
globalOptions,
)
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
expect(process.exitCode).toBe(1)
})
test("attach mode cancels only session-owned forms", async () => {
const sdk = await run({
attached: true,
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_live" },
expect.anything(),
)
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
expect(process.exitCode).toBe(1)
})
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
const output = await capture({
compatibility: "v1",
format: "json",
turn: (messageID) => [
prompted(messageID),
stepStarted(),
stepFailed("Provider request failed"),
executionFailed("Provider request failed"),
],
})
expect(
output.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({ type: "step_start", part: expect.objectContaining({ type: "step-start" }) }),
expect.objectContaining({
type: "error",
error: { type: "provider.transport", message: "Provider request failed" },
}),
])
expect(output.stderr).toBe("")
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
expect(sdk.experimental.session.wait).not.toHaveBeenCalled()
expect(sdk.message.list).not.toHaveBeenCalled()
})
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
const output = await capture({
compatibility: "v1",
turn: (messageID) => [prompted(messageID), stepStarted(), executionFailed("Execution failed")],
})
expect(output.stdout).toBe("")
expect(output.stderr).toContain("> build · test-model")
expect(output.stderr).toContain("Error: \u001b[0mExecution failed")
expect(output.stderr.indexOf("> build · test-model")).toBeLessThan(output.stderr.indexOf("Execution failed"))
})
test("V1 preserves terminal-finish failure suppression before content", async () => {
const output = await capture({
compatibility: "v1",
format: "json",
turn: (messageID) => [
prompted(messageID),
stepStarted(),
stepFailed("The provider response ended unexpectedly."),
executionFailed("The provider response ended unexpectedly."),
],
})
expect(output).toEqual({ stdout: "", stderr: "", exitCode: 0 })
})
}
test("renders a native terminal failure snapshot when live progress was missed", async () => {
const rendered: SessionMessageAssistantTool[] = []
@ -621,8 +602,8 @@ describe("runNonInteractivePrompt", () => {
])
})
test("keeps failed tool partial output out of the explicit V1 JSON bridge shape", async () => {
const output = await capture({ compatibility: "v1", format: "json", turn: failedTool })
test("keeps failed tool partial output out of the JSON tool_use shape", async () => {
const output = await capture({ format: "json", turn: failedTool })
const events = output.stdout
.split("\n")
.filter(Boolean)
@ -689,11 +670,191 @@ describe("runNonInteractivePrompt", () => {
expect(lists).toEqual([])
})
test("Ctrl-C while waiting on a background subagent exits with code 130", async () => {
const running = run({ turn: (id) => [prompted(id), settled()], jobs: () => [subagentJob] })
test("a recovered step failure exits 0, reports its retry once, and still waits for background work", async () => {
const lists: ExperimentalJobListOutput[] = [[shellJob("sh_train", "python train.py")], []]
const retry = { attempt: 1, error: { type: "provider.transport", message: "Recovered transport error" } }
const output = await capture({
format: "json",
jobs: () => lists.shift() ?? [],
messages: recoveredMessages("succeeded"),
turn: (id) => [
prompted(id),
stepStarted(),
stepFailed("Recovered transport error"),
{
id: "evt_retry",
created: 3,
type: "session.retry.scheduled",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", at: 2003, ...retry },
},
stepStarted(),
settled(),
{
id: "evt_text",
created: 4,
type: "session.text.ended",
durable: { aggregateID: "ses_1", seq: 4, version: 1 },
data: { sessionID: "ses_1", assistantMessageID: "msg_2", ordinal: 0, text: "Training finished." },
},
settled(),
],
})
const events = jsonLines(output.stdout)
expect(output.exitCode).toBe(0)
expect(events.filter((event) => event.type === "error" || event.type === "retry")).toEqual([
{ type: "retry", timestamp: 3, sessionID: "ses_1", messageID: "msg_assistant", nextRetryAt: 2003, ...retry },
])
expect(
events.filter((event) => event.part?.messageID === "msg_assistant" && event.type === "step_start"),
).toHaveLength(1)
expect(events.find((event) => event.type === "background_wait")).toMatchObject({
jobs: [{ id: "sh_train", kind: "shell" }],
})
expect(output.stdout).toContain("Training finished.")
expect(lists).toEqual([])
})
for (const outcome of ["failed", "interrupted"] as const) {
test(`a final ${outcome} outcome exits 1 with one error when the live event is dropped`, async () => {
const output = await capture({
format: "json",
turn: (id) => [prompted(id)],
// Without the failed step, so only the idle marker can report the outcome.
messages: (id) => recoveredMessages(outcome)(id).filter((message) => message.id !== "msg_assistant"),
wait: () => Promise.resolve(),
})
expect(output.exitCode).toBe(1)
expect(jsonLines(output.stdout).filter((event) => event.type === "error")).toHaveLength(1)
})
}
test("only the first idle marker after the prompt decides the outcome", async () => {
const output = await capture({
format: "json",
turn: (id) => [prompted(id)],
wait: () => Promise.resolve(),
messages: (messageID) => [
idleMessage("failed", "msg_later_idle"),
{ id: "msg_other_prompt", type: "user", text: "another client", time: { created: 6 } },
...recoveredMessages("succeeded")(messageID),
idleMessage("failed", "msg_previous_idle"),
],
})
expect(output.exitCode).toBe(0)
expect(output.stdout).not.toContain('"type":"error"')
})
test("a prompt left in the inbox by a failure before promotion does not hang", async () => {
const output = await capture({
format: "json",
turn: () => [executionFailed("instructions unavailable")],
messages: () => [],
inbox: (sessionID) => (sessionID === "ses_1" ? [{ id: "msg_prompt" } as SessionInboxListOutput[number]] : []),
})
expect(output.exitCode).toBe(1)
expect(jsonLines(output.stdout)).toEqual([
expect.objectContaining({
type: "error",
error: expect.objectContaining({ message: "instructions unavailable" }),
}),
])
})
test("keeps waiting when a woken reply moves from the inbox to running between reads", async () => {
// The wake leaves the inbox as it is read, and its reply runs until the next check.
let wake: "queued" | "running" | "done" = "queued"
const output = await capture({
format: "json",
inbox: () => {
if (wake === "queued") wake = "running"
return []
},
active: (): SessionActiveOutput => {
if (wake !== "running") return {}
wake = "done"
return { ses_1: { type: "running" } }
},
turn: (id) => [
prompted(id),
settled(),
{
id: "evt_text",
created: 2,
type: "session.text.ended",
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
data: { sessionID: "ses_1", assistantMessageID: "msg_2", ordinal: 0, text: "Shell finished." },
},
settled(),
],
})
expect(output.stdout).toContain("Shell finished.")
})
test("fails the run when a background state read fails instead of treating it as done", async () => {
await expect(
run({
turn: (id) => [prompted(id), settled()],
active: () => {
throw new Error("state unavailable")
},
}),
).rejects.toThrow("state unavailable")
})
test("treats a server without the job list endpoint as having no jobs", async () => {
const output = await capture({
turn: (id) => [prompted(id), settled()],
jobs: () => {
throw new ClientError("UnsupportedContentType", { detail: "text/plain" })
},
})
expect(output.exitCode).toBe(0)
expect(output.stderr).toBe("")
})
test("does not wait on a job started before this prompt", async () => {
// A dev server left running by an earlier turn, as with --continue.
const output = await capture({
turn: (id) => [prompted(id), settled()],
jobs: () => [{ ...shellJob("sh_dev", "bun dev"), time: { started: 0 } }],
})
expect(output.exitCode).toBe(0)
expect(output.stderr).not.toContain("waiting for")
})
test("cancelling while waiting on background work stops the Session and this prompt's jobs", async () => {
const controller = new AbortController()
const old = { ...shellJob("sh_dev", "bun dev"), time: { started: 0 } }
const all = [old, shellJob("sh_train", "python train.py"), subagentJob]
// After the cancel reads and stops this prompt's jobs, only the older dev server is left.
let reads = 0
const running = run({
signal: controller.signal,
turn: (id) => [prompted(id), settled()],
jobs: () => (controller.signal.aborted && reads++ > 0 ? [old] : all),
})
expect(await Promise.race([running.then(() => "exited"), Bun.sleep(20).then(() => "waiting")])).toBe("waiting")
process.emit("SIGINT")
await running
expect(process.exitCode).toBe(130)
controller.abort()
const sdk = await running
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_1" })
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_child" })
expect(sdk.shell.remove).toHaveBeenCalledWith({ id: "sh_train", location: { directory: "/work tree" } })
expect(sdk.shell.remove).not.toHaveBeenCalledWith(expect.objectContaining({ id: "sh_dev" }))
})
test("cancelling mid-turn interrupts the Session and ignores the transport errors that follow", async () => {
const controller = new AbortController()
const closed = Promise.withResolvers<void>()
const output = capture({
signal: controller.signal,
turn: (id) => [prompted(id)],
// The private server shuts down while the idle wait is open.
wait: () => closed.promise,
})
await Bun.sleep(10)
controller.abort()
closed.reject(new ClientError("Transport", { detail: "ECONNRESET" }))
expect(await output).toMatchObject({ stdout: "", stderr: "", exitCode: 0 })
})
})

View file

@ -0,0 +1,141 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { isolatedEnv } from "../fixture/environment"
import { tmpdir } from "../fixture/tmpdir"
// A server that admits the prompt and then keeps the Session running until interrupted.
async function serve(directory: string) {
const requests: { method: string; path: string; body?: unknown }[] = []
const prompted = Promise.withResolvers<void>()
const interrupted = Promise.withResolvers<void>()
const location = { directory, project: { id: "project", directory, canonical: directory } }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
idleTimeout: 0,
async fetch(request) {
const url = new URL(request.url)
const text = await request.text()
requests.push({ method: request.method, path: url.pathname, body: text ? JSON.parse(text) : undefined })
if (url.pathname === "/api/info")
return Response.json({ version: "test", pid: 0, urls: [], paths: { tmp: directory } })
if (request.method === "GET" && url.pathname === "/api/location") return Response.json(location)
if (request.method === "POST" && url.pathname === "/api/session")
return Response.json({
data: {
id: "ses_1",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
},
})
if (url.pathname === "/api/event")
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`,
)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
if (url.pathname === "/api/session/ses_1/prompt") {
prompted.resolve()
const body = JSON.parse(text)
return Response.json({
data: { id: body.id, sessionID: "ses_1", type: "user", time: { created: 1 }, payload: {}, delivery: "steer" },
})
}
if (url.pathname === "/api/session/ses_1/interrupt") {
interrupted.resolve()
return Response.json({ interrupted: true })
}
if (url.pathname.endsWith("/wait")) return interrupted.promise.then(() => new Response(null, { status: 204 }))
if (request.method === "GET") return Response.json({ data: [] })
return new Response(null, { status: 404 })
},
})
return { server, requests, prompted: prompted.promise }
}
function spawnRun(url: string, directory: string, message: string[], stdin: "pipe" | "ignore") {
return Bun.spawn([process.execPath, "run", "src/index.ts", "run", "--server", url, ...message], {
cwd: path.join(import.meta.dir, "../.."),
env: isolatedEnv(directory, { PWD: directory }),
signal: AbortSignal.timeout(25_000),
stdin,
stdout: "pipe",
stderr: "pipe",
})
}
test("run sends the message as typed", async () => {
await using directory = await tmpdir()
const fake = await serve(directory.path)
const child = spawnRun(fake.server.url.toString(), directory.path, ["say hello world", `it's "quoted"`], "ignore")
try {
await fake.prompted
expect(fake.requests.find((item) => item.path.endsWith("/prompt"))?.body).toMatchObject({
text: `say hello world it's "quoted"`,
})
} finally {
child.kill("SIGKILL")
await child.exited
await fake.server.stop(true)
}
}, 30_000)
const posix = test.skipIf(process.platform === "win32")
// Windows has no SIGTERM delivery: kill() terminates the process outright.
posix(
"run does not wait on spawn's open stdin, and SIGTERM interrupts the Session with 143",
async () => {
await using directory = await tmpdir()
const fake = await serve(directory.path)
// A stdin pipe that stays open and silent, as with spawn's default stdio.
const child = spawnRun(fake.server.url.toString(), directory.path, ["hello"], "pipe")
try {
await fake.prompted
await Bun.sleep(200)
child.kill("SIGTERM")
expect(await child.exited).toBe(143)
expect(fake.requests.some((item) => item.path === "/api/session/ses_1/interrupt")).toBe(true)
} finally {
child.kill("SIGKILL")
await child.exited
await fake.server.stop(true)
}
},
30_000,
)
posix(
"run reads a slow shell pipe in full alongside a message argument",
async () => {
await using directory = await tmpdir()
const fake = await serve(directory.path)
const command = `(sleep 4; echo slow context) | "${process.execPath}" run src/index.ts run --server ${fake.server.url} review`
const child = Bun.spawn(["sh", "-c", command], {
cwd: path.join(import.meta.dir, "../.."),
env: isolatedEnv(directory.path, { PWD: directory.path }),
signal: AbortSignal.timeout(25_000),
stdout: "pipe",
stderr: "pipe",
})
try {
await fake.prompted
expect(fake.requests.find((item) => item.path.endsWith("/prompt"))?.body).toMatchObject({
text: "review\nslow context\n",
})
} finally {
child.kill("SIGKILL")
await child.exited
await fake.server.stop(true)
}
},
30_000,
)