Repository navigation
fix(acp): OpenCode choice questions are asked once #34931
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
|
|
||
| import { extractOpenCodeElicitationForm } from "./opencodeAcp.ts"; | ||
|
|
||
| describe("extractOpenCodeElicitationForm", () => { | ||
| it("asks a choice with its custom-answer companion as one question", () => { | ||
| const form = extractOpenCodeElicitationForm({ | ||
| sessionId: "session-1", | ||
| mode: "form", | ||
| message: "Questions", | ||
| requestedSchema: { | ||
| type: "object", | ||
| properties: { | ||
| q0: { | ||
| type: "string", | ||
| title: "Button colour", | ||
| description: "Which colour should the button be?", | ||
| oneOf: [ | ||
| { const: "Red", title: "Red", description: "Make the button red" }, | ||
| { const: "Blue", title: "Blue", description: "Make the button blue" }, | ||
| ], | ||
| }, | ||
| q0_custom: { | ||
| type: "string", | ||
| title: "Button colour (other)", | ||
| description: "Type your own answer", | ||
| }, | ||
| }, | ||
| required: [], | ||
| }, | ||
| }); | ||
| assert(form !== undefined); | ||
| assert.deepStrictEqual( | ||
| form.questions.map((question) => question.question), | ||
| ["Which colour should the button be?"], | ||
| ); | ||
| assert.deepStrictEqual(form.respond({ q0: "Red" }), { | ||
| action: "accept", | ||
| content: { q0: "Red" }, | ||
| }); | ||
| assert.deepStrictEqual(form.respond({ q0: "Purple" }), { | ||
| action: "accept", | ||
| content: { q0_custom: "Purple" }, | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { type OrchestrationV2UserInputQuestion, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter"; | ||
| import * as Predicate from "effect/Predicate"; | ||
| import * as Schema from "effect/Schema"; | ||
|
|
||
| const isOpenCodeForm = Schema.is( | ||
| Schema.Struct({ | ||
| properties: Schema.Record(Schema.String, Schema.Unknown), | ||
| required: Schema.Array(Schema.String), | ||
| }), | ||
| ); | ||
| const isOpenCodeQuestion = Schema.is( | ||
| Schema.Struct({ | ||
| type: Schema.Literal("string"), | ||
| title: TrimmedNonEmptyString, | ||
| description: TrimmedNonEmptyString, | ||
| oneOf: Schema.NonEmptyArray( | ||
| Schema.Struct({ | ||
| const: Schema.String, | ||
| title: TrimmedNonEmptyString, | ||
| description: Schema.optional(Schema.String), | ||
| }), | ||
| ), | ||
| }), | ||
| ); | ||
| const isOpenCodeCompanion = Schema.is(Schema.Struct({ type: Schema.Literal("string") })); | ||
|
|
||
| // OpenCode pairs each optional choice q<n> with an optional q<n>_custom text | ||
| // field. The client already offers custom answers, so hide the companion and | ||
| // send a typed answer back in it. | ||
| export const extractOpenCodeElicitationForm: NonNullable< | ||
| AcpAdapterV2Flavor["extractElicitationForm"] | ||
| > = (request) => { | ||
| if (request.mode !== "form" || !isOpenCodeForm(request.requestedSchema)) return undefined; | ||
| const { properties, required } = request.requestedSchema; | ||
| const questions: Array<OrchestrationV2UserInputQuestion> = []; | ||
| for (const [id, field] of Object.entries(properties)) { | ||
| if (!/^q\d+$/.test(id)) continue; | ||
| if (!isOpenCodeQuestion(field) || !isOpenCodeCompanion(properties[`${id}_custom`])) { | ||
| return undefined; | ||
| } | ||
| questions.push({ | ||
| id, | ||
| header: field.title, | ||
| question: field.description, | ||
| options: field.oneOf.map((option) => ({ | ||
| label: option.title, | ||
| description: option.description?.trim() || option.title, | ||
| value: option.const, | ||
| })), | ||
| }); | ||
| } | ||
| // Recognize only complete forms; leave every other schema to the ACP fallback. | ||
| if ( | ||
| questions.length === 0 || | ||
| Object.keys(properties).length !== questions.length * 2 || | ||
| required.length !== 0 | ||
| ) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| questions, | ||
| respond: (answers) => { | ||
| const entries: Array<[string, string]> = []; | ||
| for (const question of questions) { | ||
| const answer = Object.hasOwn(answers, question.id) ? answers[question.id] : undefined; | ||
| if (!Predicate.isString(answer) || answer.trim().length === 0) continue; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep a listed option whose value is an empty string. When the user selects an option with Check the options first. Skip only blank answers that are not listed options. Suggested fix
|
||
| entries.push( | ||
| question.options.some((option) => option.value === answer) | ||
| ? [question.id, answer] | ||
| : [`${question.id}_custom`, answer], | ||
| ); | ||
| } | ||
| return { action: "accept", content: Object.fromEntries(entries) }; | ||
| }, | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let the client skip blank optional questions.
For an OpenCode form with
required: [],buildPendingUserInputAnswersreturnsnullwhen any question is blank. It never readsrequired. The web and mobile submit gates use that result, so a blank optional question blocks submission before the server runs. The server skips an unanswered question only whenquestion.required === false. Settingrequired: falseon the OpenCode questions alone does not fix this. The builder also needs to skip blank optional questions.Suggested fix
export interface PendingUserInputQuestion { readonly id: string; + readonly required?: boolean | undefined; readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>;for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { + if (question.required === false) continue; return null; }questions.push({ id, header: field.title, question: field.description, + required: false, options: field.oneOf.map((option) => ({