From c0d60384f3102c649c2af4405be2326fd862a6a0 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Fri, 23 Oct 2026 16:15:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(acp):=20=E6=AD=A3=E7=A1=AE=E6=98=A0?= =?UTF-8?q?=E5=B0=84=20OMP=20=E5=8D=95=E9=80=89=E9=97=AE=E9=A2=98=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=9B=9E=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 OMP 本地命令适配边界合并自定义输入字段,保留题目、标题和选项。 将选择值和自由输入还原为 OMP 所需的响应字段,保留取消与附件引用。 通过 ACP 回放验证问题事件及返回内容,变更范围检查通过;不修改通用多选协议。 --- .../Adapters/AcpAdapterV2.test.ts | 72 ++++++++++++++++++- .../src/server/adapter.ts | 8 ++- .../src/server/ompAcp.ts | 65 +++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 packages/provider-acp-registry/src/server/ompAcp.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 1a0c6caf1f925..33094768c735f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -95,6 +95,11 @@ import { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); const DROID_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({ agentId: "factory-droid" }); +const OMP_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({ + source: "local", + commandPath: "/usr/local/bin/omp", + commandArgs: ["acp"], +}); const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); @@ -431,6 +436,67 @@ describe("AcpAdapterV2", () => { // regression: #20417 Droid AskUser answers were sent as a cancel it.live.each([ + ...[ + { scenario: "OMP selected option", answers: { q0: "Approve" }, content: { q0: "Approve" } }, + { + scenario: "OMP custom answer", + answers: { q0: "Please wait" }, + content: { q0__other: "Please wait" }, + }, + { scenario: "OMP cancellation", answers: undefined, content: undefined }, + { + scenario: "OMP missing header", + answers: { q0: "Hold" }, + content: { q0: "Hold" }, + withoutHeader: true, + }, + { + scenario: "OMP selected label with attachment references", + answers: { q0: 'Approve\n\nAttached file "notes.txt": "/server/notes.txt"' }, + content: { q0__other: 'Approve\n\nAttached file "notes.txt": "/server/notes.txt"' }, + }, + ].map((fixture) => ({ + ...fixture, + settings: OMP_SETTINGS, + schema: { + type: "object", + properties: { + q0: { + type: "string", + title: "Approve this exact code-only commit?", + ...("withoutHeader" in fixture ? {} : { description: "Safety-hook false positive" }), + oneOf: [ + { const: "Approve", title: "Approve", description: "Commit the verified fix." }, + { const: "Hold", title: "Hold" }, + ], + default: "Approve", + }, + q0__other: { type: "string", title: "Other (type your own)" }, + }, + }, + questions: [ + { + id: "q0", + header: "withoutHeader" in fixture ? "Question 1" : "Safety-hook false positive", + question: "Approve this exact code-only commit?", + options: [ + { label: "Approve", value: "Approve", description: "Commit the verified fix." }, + { label: "Hold", value: "Hold", description: "Hold" }, + ], + }, + ], + result: fixture.content + ? { action: "accept", content: fixture.content } + : { action: "cancel" }, + })), + { + scenario: "OMP unrelated form uses generic mapping", + settings: OMP_SETTINGS, + schema: { type: "object", properties: { name: { type: "string" } } }, + questions: [{ id: "name", header: "Question 1", question: "Your answer?", options: [] }], + answers: { name: "Ada" }, + result: { action: "accept", content: { name: "Ada" } }, + }, { scenario: "preserves selected IDs and encodes custom answers", schema: droidSchema, @@ -474,8 +540,10 @@ describe("AcpAdapterV2", () => { answers: { name: "Ada" }, result: { action: "accept", content: { name: "Ada" } }, }, - ])("Droid elicitation $scenario", ({ schema, questions, answers, result, withAttachments }) => + ])("Registry elicitation $scenario", (fixture) => Effect.gen(function* () { + const { schema, questions, answers, result, settings } = fixture; + const withAttachments = "withAttachments" in fixture && fixture.withAttachments; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-droid-form-" }); @@ -582,7 +650,7 @@ describe("AcpAdapterV2", () => { const adapter = yield* makeAcpRegistryAdapterV2({ instanceId, selfInvocation: yield* resolveSelfInvocation(), - settings: DROID_SETTINGS, + settings: settings ?? DROID_SETTINGS, environment: {}, makeRuntime: makeAcpReplayRuntime({ transcript, diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index d8eeca222bdee..fe327478f6903 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -9,6 +9,7 @@ import { extractDevinSubagentUpdate, } from "./devinAcp.ts"; import { extractDroidElicitationForm } from "./droidAcp.ts"; +import { extractOmpElicitationForm } from "./ompAcp.ts"; import { copilotClientCapabilitiesMeta, extractCopilotSubagentUpdate, @@ -186,6 +187,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu const startupKey = options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; const isDevin = registryAgentId === "devin"; + const isOmp = + options.settings.source === "local" && + /^omp(\.exe|\.cmd)?$/i.test(options.settings.commandPath.split(/[\\/]/).pop() ?? ""); // Local installs launch the `copilot` binary directly with no registry id. const isCopilot = registryAgentId === "github-copilot-cli" || @@ -215,7 +219,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" ? { extractElicitationForm: extractDroidElicitationForm } - : {}), + : isOmp + ? { extractElicitationForm: extractOmpElicitationForm } + : {}), ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), diff --git a/packages/provider-acp-registry/src/server/ompAcp.ts b/packages/provider-acp-registry/src/server/ompAcp.ts new file mode 100644 index 0000000000000..851988337027c --- /dev/null +++ b/packages/provider-acp-registry/src/server/ompAcp.ts @@ -0,0 +1,65 @@ +import { type OrchestrationV2UserInputQuestion, TrimmedNonEmptyString } from "@t3tools/contracts"; +import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter"; +import * as Schema from "effect/Schema"; + +const isForm = Schema.is( + Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Unknown) }), +); +const choice = Schema.Struct({ + const: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), +}); +const isQuestion = Schema.is( + Schema.Struct({ + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + type: Schema.Literal("string"), + oneOf: Schema.Array(choice), + }), +); +const isCompanion = Schema.is( + Schema.Struct({ type: Schema.Literal("string"), title: Schema.Literal("Other (type your own)") }), +); + +/** OMP askDialog pairs qN choices with qN__other and puts the question in title. */ +export const extractOmpElicitationForm: NonNullable< + AcpAdapterV2Flavor["extractElicitationForm"] +> = (request) => { + if (request.mode !== "form" || !isForm(request.requestedSchema)) return undefined; + const { properties } = request.requestedSchema; + const questions: OrchestrationV2UserInputQuestion[] = []; + for (const [id, field] of Object.entries(properties)) { + if (!/^q\d+$/.test(id)) continue; + if (!isQuestion(field) || !isCompanion(properties[`${id}__other`])) return undefined; + const choices = field.oneOf; + if (choices.length === 0) return undefined; + questions.push({ + id, + header: field.description?.trim() || `Question ${questions.length + 1}`, + question: field.title, + options: choices.map((option) => ({ + label: option.title, + value: option.const, + description: option.description?.trim() || option.title, + })), + }); + } + // Do not hide unknown fields or partially reinterpret a different form. + if (questions.length === 0 || Object.keys(properties).length !== questions.length * 2) + return undefined; + return { + questions, + respond: (answers) => { + const content: Record = {}; + for (const question of questions) { + const answer = Object.hasOwn(answers, question.id) ? answers[question.id] : undefined; + if (typeof answer !== "string" || !answer.trim()) return { action: "cancel" }; + if (question.options.some((option) => option.value === answer)) + content[question.id] = answer; + else content[`${question.id}__other`] = answer; + } + return { action: "accept", content }; + }, + }; +};