Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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-" });
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/provider-acp-registry/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
extractDevinSubagentUpdate,
} from "./devinAcp.ts";
import { extractDroidElicitationForm } from "./droidAcp.ts";
import { extractOmpElicitationForm } from "./ompAcp.ts";
import {
copilotClientCapabilitiesMeta,
extractCopilotSubagentUpdate,
Expand Down Expand Up @@ -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" ||
Expand Down Expand Up @@ -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 }
: {}),
Expand Down
65 changes: 65 additions & 0 deletions packages/provider-acp-registry/src/server/ompAcp.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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 };
},
};
};
Loading