Compare commits

...
Author SHA1 Message Date
Aiden Cline 69c475f053 fix(cli): harden noninteractive runs 2026-09-19 20:19:21 -05:00
2 changed files with 230 additions and 24 deletions
+65 -21
View File
@@ -60,7 +60,17 @@ type ToolState = StartedPart & {
}
type V2Event = EventSubscribeOutput
type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
type FormRequest = {
id: string
sessionID: string
metadata?: Readonly<Record<string, unknown>>
fields: ReadonlyArray<{
key: string
type: string
default?: unknown
options?: ReadonlyArray<{ value: string }>
}>
}
// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
// session. An exclusive local process may treat them as this run's blockers; an
@@ -79,6 +89,7 @@ export async function runNonInteractivePrompt(input: Input) {
const renderedText = new Map<string, string>()
const renderedReasoning = new Map<string, string>()
const renderedTools = new Set<string>()
const sessions = new Set([input.sessionID])
let submitted = false
let promoted = false
let emittedError = false
@@ -132,7 +143,12 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
const replyPermission = async (request: {
id: string
sessionID: string
action: string
resources: ReadonlyArray<string>
}) => {
if (!input.auto) {
permissionRejected = true
UI.println(
@@ -143,13 +159,13 @@ export async function runNonInteractivePrompt(input: Input) {
}
await input.client.permission
.reply({
sessionID: input.sessionID,
sessionID: request.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await input.client.session.interrupt({ sessionID: request.sessionID }).catch(() => {})
}
}
@@ -165,6 +181,23 @@ export async function runNonInteractivePrompt(input: Input) {
formCancelled = true
}
const settleForm = async (request: FormRequest) => {
const field =
request.metadata?.kind === "websearch.provider"
? request.fields.find((field) => field.type === "string" && field.options?.length)
: undefined
const value = typeof field?.default === "string" ? field.default : field?.options?.[0]?.value
if (!field || value === undefined) return cancelForm(request)
try {
await input.client.session.form.reply(
{ sessionID: request.sessionID, formID: request.id, answer: { [field.key]: value } },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
}
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next().catch((error) => {
@@ -177,19 +210,23 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "session.created" && event.data.parentID && sessions.has(event.data.parentID)) {
sessions.add(event.data.sessionID)
continue
}
if (event.type === "permission.asked" && submitted && sessions.has(event.data.sessionID)) {
await replyPermission(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
(sessions.has(event.data.form.sessionID) ||
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
) {
await cancelForm(event.data.form)
await settleForm(event.data.form)
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
@@ -476,28 +513,31 @@ export async function runNonInteractivePrompt(input: Input) {
if (interrupted || permissionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.execution.failed") {
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
flushStep()
if (!emittedError && !formCancelled) {
emittedError = true
if (!formCancelled) {
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
if (!emittedError) {
emittedError = true
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
}
}
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
if (event.data.reason !== "user") {
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
if (!emittedError) {
emittedError = true
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
}
}
return
}
@@ -525,9 +565,11 @@ export async function runNonInteractivePrompt(input: Input) {
const reconcile = async () => {
const projected = await projectedMessages()
let projectedError: { error: { message: string; [key: string]: unknown }; timestamp: number } | undefined
for (const message of projected.messages) {
if (message.type !== "assistant") continue
const timestamp = message.time.completed ?? message.time.created
projectedError = message.error ? { error: message.error, timestamp } : undefined
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
@@ -619,11 +661,13 @@ export async function runNonInteractivePrompt(input: Input) {
await input.renderToolError(item)
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
}
if (projectedError && !interrupted && !permissionRejected && !formCancelled) {
process.exitCode = 1
if (!emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
if (!emit("error", projectedError.timestamp, { error: projectedError.error }))
UI.error(projectedError.error.message)
}
}
return {
@@ -706,9 +750,9 @@ export async function runNonInteractivePrompt(input: Input) {
])
await Promise.all([
...(permissions ?? []).map(replyPermission),
...(forms ?? []).map(cancelForm),
...(forms ?? []).map(settleForm),
...(globals && sameLocation(globals.location, input.location)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(settleForm)
: []),
])
if (input.compatibility === "v1") {
+165 -3
View File
@@ -24,6 +24,27 @@ function form(id: string, sessionID: string): FormInfo {
}
}
function webSearchForm(id: string, sessionID: string): FormInfo {
return {
id,
sessionID,
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [
{
key: "choice",
type: "string",
required: true,
custom: false,
options: [
{ value: "allow", label: "Allow search" },
{ value: "disable", label: "Disable search" },
],
},
],
}
}
function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
}
@@ -38,6 +59,37 @@ function prompted(inboxID: string): V2Event {
}
}
function childCreated(): V2Event {
return {
id: "evt_child_created",
created: 0,
type: "session.created",
durable: { aggregateID: "ses_child", seq: 0, version: 1 },
data: {
sessionID: "ses_child",
projectID: "proj_1",
location,
parentID: "ses_1",
slug: "child",
version: "test",
},
}
}
function permissionAsked(sessionID: string): V2Event {
return {
id: "evt_permission",
created: 1,
type: "permission.asked",
data: {
id: "per_1",
sessionID,
action: "shell",
resources: ["rm file"],
},
}
}
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
if (outcome === "interrupted")
return {
@@ -210,9 +262,11 @@ async function run(input: {
turn: (inboxID: string) => V2Event[]
pendingForms?: FormInfo[]
attached?: boolean
auto?: boolean
format?: "default" | "json"
compatibility?: "v1"
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
reply?: (input: { sessionID: string; formID: string; answer: Record<string, unknown> }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
@@ -241,6 +295,7 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.permission, "reply").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
@@ -252,6 +307,8 @@ async function run(input: {
}) as never,
)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session.form, "reply").mockImplementation((request) => (input.reply?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session, "interrupt").mockImplementation(() => ok(undefined) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -276,7 +333,7 @@ async function run(input: {
files: [],
thinking: false,
format: input.format ?? "default",
auto: false,
auto: input.auto ?? false,
attached: input.attached ?? false,
compatibility: input.compatibility,
renderTool: input.renderTool ?? (() => Promise.resolve()),
@@ -312,6 +369,105 @@ afterEach(() => {
})
describe("runNonInteractivePrompt", () => {
test("keeps exit zero when a failed step is recovered", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), stepStarted(), stepFailed("socket closed"), settled()],
})
expect(output.exitCode ?? 0).toBe(0)
expect(output.stdout).toContain('"type":"error"')
expect(output.stdout).toContain("socket closed")
})
test("keeps terminal execution failures fatal after a failed step", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), stepFailed("socket closed"), executionFailed("retries exhausted")],
})
expect(output.exitCode).toBe(1)
})
test("does not infer failure from a recovered projected step", async () => {
const output = await capture({
format: "json",
turn: (messageID) => [prompted(messageID), settled()],
messages: (messageID) => [
{
id: "msg_success",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [{ type: "text", text: "recovered" }],
finish: "stop",
time: { created: 4, completed: 5 },
},
{
id: "msg_failed",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
content: [],
finish: "error",
error: { type: "provider.transport", message: "socket closed" },
time: { created: 2, completed: 3 },
},
{ id: messageID, type: "user", text: "hello", time: { created: 1 } },
],
})
expect(output.exitCode).toBe(0)
expect(output.stdout).toContain("recovered")
})
test("selects the default web search option instead of cancelling", async () => {
const sdk = await run({
turn: (messageID) => [formCreated(webSearchForm("frm_search", "ses_1")), prompted(messageID), settled()],
})
expect(sdk.session.form.reply).toHaveBeenCalledWith({
sessionID: "ses_1",
formID: "frm_search",
answer: { choice: "allow" },
})
expect(sdk.session.form.cancel).not.toHaveBeenCalled()
})
test("rejects blockers owned by child sessions", async () => {
const sdk = await run({
turn: (messageID) => [
prompted(messageID),
childCreated(),
permissionAsked("ses_child"),
formCreated(form("frm_child", "ses_child")),
settled(),
],
})
expect(sdk.permission.reply).toHaveBeenCalledWith({
sessionID: "ses_child",
requestID: "per_1",
decision: "reject",
})
expect(sdk.session.interrupt).toHaveBeenCalledWith({ sessionID: "ses_child" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_child", formID: "frm_child" })
})
test("auto-approves permissions owned by child sessions", async () => {
const sdk = await run({
auto: true,
turn: (messageID) => [prompted(messageID), childCreated(), permissionAsked("ses_child"), settled()],
})
expect(sdk.permission.reply).toHaveBeenCalledWith({
sessionID: "ses_child",
requestID: "per_1",
decision: "once",
})
expect(sdk.session.interrupt).not.toHaveBeenCalled()
})
test("keeps formatted tool output and compact tool metadata in JSON", async () => {
const output = await capture({ format: "json", turn: successfulGrep })
const events = output.stdout
@@ -429,7 +585,10 @@ describe("runNonInteractivePrompt", () => {
}
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.session.form.cancel).toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
globalOptions,
)
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
@@ -443,7 +602,10 @@ describe("runNonInteractivePrompt", () => {
})
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_live" },
expect.anything(),
)
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),