From 02c3891d9b40c0cd98dbafa2f102555ac2f277d9 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 30 Sep 2026 09:20:26 -0400 Subject: [PATCH] feat(core): let form cancellation carry a message (#52137) --- packages/cli/src/run/noninteractive.ts | 15 ++++++++++++-- packages/client/src/effect/api/api.ts | 6 +++++- .../client/src/effect/generated/client.ts | 7 ++++--- .../client/src/promise/generated/client.ts | 1 + .../client/src/promise/generated/types.ts | 6 +++++- packages/core/src/form.ts | 13 +++++++++--- packages/core/src/tool/plugin/question.ts | 4 ++++ packages/protocol/openapi.json | 20 ++++++++++++++++++- packages/protocol/src/groups/session.ts | 3 ++- packages/schema/src/form.ts | 3 ++- packages/server/src/handlers/session.ts | 2 +- services/www/openapi.json | 20 ++++++++++++++++++- services/www/public/openapi.json | 20 ++++++++++++++++++- 13 files changed, 104 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index f961efdf00b..f4c56b889ed 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -67,6 +67,9 @@ type FormRequest = Extract["data"]["form"] // attached client must not cancel input that may belong to another session. const GLOBAL_FORM_SESSION_ID = "global" +const QUESTION_CANCELLED_FEEDBACK = + "This non-interactive run cannot ask the user questions, so the question was cancelled. Continue without an answer; make reasonable assumptions and state them." + const PERMISSION_REJECTED_FEEDBACK = "This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action." @@ -182,15 +185,23 @@ export async function runNonInteractivePrompt(input: Input) { } } - const cancelForm = async (request: Pick) => { + const cancelForm = async (request: Pick) => { + // Nobody can answer a question here. Outside V1 compatibility, cancel it with a message so the + // question tool fails as ordinary model-visible output and the model continues without an answer. + const continuing = request.metadata?.kind === "question" && input.compatibility !== "v1" try { await input.client.session.form.cancel( - { sessionID: request.sessionID, formID: request.id }, + { + sessionID: request.sessionID, + formID: request.id, + ...(continuing ? { message: QUESTION_CANCELLED_FEEDBACK } : {}), + }, ...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined), ) } catch (error) { if (!formAlreadySettled(error)) throw error } + if (continuing) return formCancelled = true if (input.compatibility !== "v1") process.exitCode = 1 } diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index cc721aeb956..4e66ee54275 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1414,7 +1414,11 @@ export type SessionFormReplyOperation = ( input: SessionFormReplyInput, ) => Effect.Effect -export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID } +export type SessionFormCancelInput = { + readonly sessionID: string + readonly formID: Form.ID + readonly message?: string | undefined +} export type SessionFormCancelOutput = void export type SessionFormCancelOperation = ( input: SessionFormCancelInput, diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 9b553dfbd9c..a8c4a68a6cc 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -735,9 +735,10 @@ const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: S const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) => preserveEffect()( - raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe( - Effect.mapError(mapClientError), - ), + raw["session.form.cancel"]({ + params: { sessionID: input["sessionID"], formID: input["formID"] }, + query: { message: input["message"] }, + }).pipe(Effect.mapError(mapClientError)), ) const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e78020b7442..27826bac171 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1058,6 +1058,7 @@ export function make(options: ClientOptions) { { method: "DELETE", path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`, + query: { message: input["message"] }, successStatus: 204, declaredStatuses: [400, 401, 404, 409], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 217cc570e5b..5cf39951bb4 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1897,7 +1897,10 @@ export type FormField = | FormMultiselectField | FormExternalField -export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" } +export type FormState = + | { status: "pending" } + | { status: "answered"; answer: FormAnswer } + | { status: "cancelled"; message?: string } export type CredentialKey = { type: "key" @@ -5460,6 +5463,7 @@ export type SessionFormReplyOutput = void export type SessionFormCancelInput = { readonly sessionID: { readonly sessionID: string; readonly formID: string }["sessionID"] readonly formID: { readonly sessionID: string; readonly formID: string }["formID"] + readonly message?: { readonly message?: string | undefined }["message"] } export type SessionFormCancelOutput = void diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 411c3b748dd..fc8e2edb918 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -74,6 +74,10 @@ export interface ReplyInput { readonly answer: Answer } +export interface CancelOptions { + readonly message?: string +} + export interface ListInput { readonly sessionID?: Form.Info["sessionID"] } @@ -86,7 +90,7 @@ export interface Interface { readonly list: (input?: ListInput) => Effect.Effect> readonly state: (id: ID) => Effect.Effect readonly reply: (input: ReplyInput) => Effect.Effect - readonly cancel: (id: ID) => Effect.Effect + readonly cancel: (id: ID, options?: CancelOptions) => Effect.Effect } export class Service extends Context.Service()("@opencode/Form") {} @@ -192,12 +196,15 @@ export const layer = Layer.effect( ), ) - const cancel = Effect.fn("Form.cancel")((id: ID) => + const cancel = Effect.fn("Form.cancel")((id: ID, options?: CancelOptions) => Effect.uninterruptible( Effect.gen(function* () { const entry = yield* requireEntry(id) if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id }) - const next: TerminalState = { status: "cancelled" } + const next: TerminalState = { + status: "cancelled", + ...(options?.message === undefined ? {} : { message: options.message }), + } yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID }) yield* Cache.set(forms, id, { ...entry, state: next }) yield* Deferred.succeed(entry.deferred, next) diff --git a/packages/core/src/tool/plugin/question.ts b/packages/core/src/tool/plugin/question.ts index 29eaa874a3e..60d115eae35 100644 --- a/packages/core/src/tool/plugin/question.ts +++ b/packages/core/src/tool/plugin/question.ts @@ -90,6 +90,10 @@ export const Plugin = { // Deliberate defect tunnel (see Permission.assert): a dismissal must dodge // leaf `mapError` blankets so it never becomes model-facing tool output; it // resurfaces as a typed failure at SessionModelRequest.executeTool. + // A dismissal with a message (e.g. from a non-interactive client) is ordinary + // model-facing feedback: the model continues instead of the step ending. + if (state.status === "cancelled" && state.message !== undefined) + return Effect.fail(new ToolFailure({ message: state.message })) if (state.status === "cancelled") return Effect.die(new CancelledError()) const output = { answers: input.questions.map((_, index): Question.Answer => { diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 7ca75e0ec9b..e4fbd2f5d3c 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -4566,7 +4566,7 @@ } } }, - "description": "Cancel a pending form.", + "description": "Cancel a pending form, optionally telling the asker why it was not answered.", "summary": "Cancel form" } }, @@ -4591,6 +4591,21 @@ "pattern": "^frm_" }, "required": true + }, + { + "name": "message", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false } ], "security": [], @@ -14170,6 +14185,9 @@ "status": { "type": "string", "enum": ["cancelled"] + }, + "message": { + "type": "string" } }, "required": ["status"], diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index e0ce88796d3..d6cc9358bcf 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -855,6 +855,7 @@ export const makeSessionGroup = < .add( HttpApiEndpoint.delete("session.form.cancel", "/api/session/:sessionID/form/:formID", { params: { sessionID: Schema.String, formID: Form.ID }, + query: Schema.Struct({ message: Schema.optional(Schema.String) }), success: HttpApiSchema.NoContent, error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError], }) @@ -863,7 +864,7 @@ export const makeSessionGroup = < OpenApi.annotations({ identifier: "session.form.cancel", summary: "Cancel form", - description: "Cancel a pending form.", + description: "Cancel a pending form, optionally telling the asker why it was not answered.", }), ), ) diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index 5093660b2a9..adbf14a9b65 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -149,7 +149,8 @@ export type Answer = typeof Answer.Type export const State = Schema.Union([ Schema.Struct({ status: Schema.Literal("pending") }), Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }), - Schema.Struct({ status: Schema.Literal("cancelled") }), + // A message tells the asker why nobody answered, e.g. a non-interactive client. + Schema.Struct({ status: Schema.Literal("cancelled"), message: Schema.String.pipe(optional) }), ]) .pipe(Schema.toTaggedUnion("status")) .annotate({ identifier: "Form.State" }) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index b95fbe4544f..cefdef08efd 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -692,7 +692,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl "session.form.cancel", Effect.fn(function* (ctx) { const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID) - yield* owned.form.cancel(ctx.params.formID).pipe( + yield* owned.form.cancel(ctx.params.formID, { message: ctx.query.message }).pipe( Effect.catchTags({ "Form.AlreadySettledError": (error) => new FormAlreadySettledError({ id: error.id, message: error.message }), diff --git a/services/www/openapi.json b/services/www/openapi.json index 7ca75e0ec9b..e4fbd2f5d3c 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -4566,7 +4566,7 @@ } } }, - "description": "Cancel a pending form.", + "description": "Cancel a pending form, optionally telling the asker why it was not answered.", "summary": "Cancel form" } }, @@ -4591,6 +4591,21 @@ "pattern": "^frm_" }, "required": true + }, + { + "name": "message", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false } ], "security": [], @@ -14170,6 +14185,9 @@ "status": { "type": "string", "enum": ["cancelled"] + }, + "message": { + "type": "string" } }, "required": ["status"], diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index 7ca75e0ec9b..e4fbd2f5d3c 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -4566,7 +4566,7 @@ } } }, - "description": "Cancel a pending form.", + "description": "Cancel a pending form, optionally telling the asker why it was not answered.", "summary": "Cancel form" } }, @@ -4591,6 +4591,21 @@ "pattern": "^frm_" }, "required": true + }, + { + "name": "message", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": false } ], "security": [], @@ -14170,6 +14185,9 @@ "status": { "type": "string", "enum": ["cancelled"] + }, + "message": { + "type": "string" } }, "required": ["status"],