Skip to content
Source
Sign inSign up

opencodeAcp.ts

135 lines5.2 KB

1import { type OrchestrationV2UserInputQuestion, TrimmedNonEmptyString } from "@t3tools/contracts";
2import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter";
3import * as Predicate from "effect/Predicate";
4import * as Schema from "effect/Schema";
5​
6const isForm = Schema.is(
7 Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Unknown) }),
8);
9const choice = Schema.Struct({
10 const: Schema.String,
11 title: TrimmedNonEmptyString,
12 description: Schema.optional(Schema.String),
13});
14const isChoiceQuestion = Schema.is(
15 Schema.Struct({
16 type: Schema.Literal("string"),
17 title: TrimmedNonEmptyString,
18 description: Schema.optional(Schema.String),
19 oneOf: Schema.Array(choice),
20 }),
21);
22const isTextQuestion = Schema.is(
23 Schema.Struct({
24 type: Schema.Literal("string"),
25 title: TrimmedNonEmptyString,
26 description: Schema.optional(Schema.String),
27 }),
28);
29// The companion only carries a free-form answer, never a title we can show.
30const isCustomCompanion = Schema.is(
31 Schema.Struct({
32 type: Schema.Literal("string"),
33 description: Schema.Literals(["Type your own answer", "Add your own answer"]),
34 }),
35);
36​
37const questionId = /^q\d+$/;
38const companionSuffix = "_custom";
39​
40// Intake appends file references to a user's answer; keep them with the custom text.
41const splitAttachments = (value: string) => {
42 const marker = value.indexOf("\n\n");
43 if (marker < 0) return undefined;
44 const suffix = value.slice(marker);
45 if (!/^\n\nAttached (?:image|file) "(?:\\.|[^"\\\r\n])*": "/.test(suffix)) return undefined;
46 return { prefix: value.slice(0, marker), suffix };
47};
48​
49type Option = OrchestrationV2UserInputQuestion["options"][number];
50​
51const findChoice = (options: ReadonlyArray<Option>, value: string) =>
52 options.find((option) => option.value === value);
53​
54// OpenCode 2.1 asks through ACP with a `qN` choice field plus a `qN_custom` companion.
55// Multi-select forms stay with the generic mapping: it keeps the companion as its own
56// question, and clients cannot yet send selected choices and custom text as one answer.
57export const extractOpenCodeElicitationForm: NonNullable<
58 AcpAdapterV2Flavor["extractElicitationForm"]
59> = (request) => {
60 if (request.mode !== "form" || !isForm(request.requestedSchema)) return undefined;
61 const { properties } = request.requestedSchema;
62 const questions: Array<OrchestrationV2UserInputQuestion> = [];
63 const withCompanion = new Set<string>();
64 let choices = 0;
65 for (const [id, field] of Object.entries(properties)) {
66 if (!questionId.test(id)) {
67 const owner = id.endsWith(companionSuffix) ? id.slice(0, -companionSuffix.length) : undefined;
68 if (owner === undefined || !questionId.test(owner) || !Object.hasOwn(properties, owner)) {
69 return undefined;
70 }
71 continue;
72 }
73 const companion = properties[`${id}${companionSuffix}`];
74 const hasCompanion = companion !== undefined;
75 if (hasCompanion && !isCustomCompanion(companion)) return undefined;
76 if (!isTextQuestion(field)) return undefined;
77 const fieldChoices = isChoiceQuestion(field) ? field.oneOf : undefined;
78 // Unsupported choice schemas belong to the generic mapping.
79 if (
80 fieldChoices === undefined &&
81 ("enum" in field || "oneOf" in field || "anyOf" in field || "items" in field)
82 ) {
83 return undefined;
84 }
85 const options = (fieldChoices ?? []).map((option) => ({
86 label: option.title,
87 description: option.description?.trim() || option.title,
88 value: option.const,
89 }));
90 if (options.length > 0) choices += 1;
91 if (hasCompanion) withCompanion.add(id);
92 questions.push({
93 id,
94 header: field.title,
95 question: field.description?.trim() || field.title,
96 options,
97 allowCustomAnswer: hasCompanion || options.length === 0,
98 });
99 }
100 // Recognize only question forms with a choice; leave every other schema to the ACP fallback.
101 if (choices === 0) return undefined;
102 return {
103 questions,
104 respond: (answers) => {
105 const content: Record<string, string> = {};
106 for (const question of questions) {
107 const answer = Object.hasOwn(answers, question.id) ? answers[question.id] : undefined;
108 if (!Predicate.isString(answer)) return { action: "cancel" };
109 const companionId = `${question.id}${companionSuffix}`;
110 const exact = findChoice(question.options, answer);
111 if (exact !== undefined) {
112 content[question.id] = answer;
113 continue;
114 }
115 if (answer.trim().length === 0) return { action: "cancel" };
116 const split = splitAttachments(answer);
117 const attached =
118 split === undefined ? undefined : findChoice(question.options, split.prefix);
119 if (withCompanion.has(question.id)) {
120 if (attached !== undefined && split !== undefined) {
121 content[question.id] = split.prefix;
122 content[companionId] = `${attached.label}${split.suffix}`;
123 } else {
124 content[companionId] = answer;
125 }
126 } else if (question.options.length === 0) {
127 content[question.id] = answer;
128 } else {
129 return { action: "cancel" };
130 }
131 }
132 return { action: "accept", content };
133 },
134 };
135};