mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): let form cancellation carry a message (#52137)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user