feat(core): let form cancellation carry a message (#52137)

This commit is contained in:
James Long
2026-09-30 09:20:26 -04:00
committed by GitHub
parent 1844c11104
commit 02c3891d9b
13 changed files with 104 additions and 16 deletions
+13 -2
View File
@@ -67,6 +67,9 @@ type FormRequest = Extract<V2Event, { type: "form.created" }>["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<FormRequest, "id" | "sessionID">) => {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID" | "metadata">) => {
// 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
}
+5 -1
View File
@@ -1414,7 +1414,11 @@ export type SessionFormReplyOperation<E = never> = (
input: SessionFormReplyInput,
) => Effect.Effect<SessionFormReplyOutput, E>
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<E = never> = (
input: SessionFormCancelInput,
@@ -735,9 +735,10 @@ const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: S
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
preserveEffect<SessionFormCancelOutput>()(
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) =>
@@ -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,
@@ -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
+10 -3
View File
@@ -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<ReadonlyArray<Info>>
readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, AlreadySettledError | InvalidAnswerError | NotFoundError>
readonly cancel: (id: ID) => Effect.Effect<void, AlreadySettledError | NotFoundError>
readonly cancel: (id: ID, options?: CancelOptions) => Effect.Effect<void, AlreadySettledError | NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@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)
@@ -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 => {
+19 -1
View File
@@ -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"],
+2 -1
View File
@@ -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.",
}),
),
)
+2 -1
View File
@@ -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" })
+1 -1
View File
@@ -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 }),
+19 -1
View File
@@ -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"],
+19 -1
View File
@@ -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"],