mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85fdc875f6 | ||
|
|
7ae1ffc913 |
@@ -3,6 +3,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentProvider,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { splitMultiSelectAnswer } from "../question-card-answer.js";
|
||||
import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js";
|
||||
|
||||
type UiRequest = Extract<OmpRuntimeEvent, { type: "extension_ui_request" }>;
|
||||
@@ -176,21 +177,7 @@ function selectedAnswer(
|
||||
return labels.includes(answer)
|
||||
? { selected: [answer], custom: null }
|
||||
: { selected: [], custom: answer };
|
||||
|
||||
// The shared question UI sends a comma-joined answer in click order. Consume
|
||||
// exact option labels from the front; any remaining text is the Other answer.
|
||||
let remaining = answer;
|
||||
const selected: string[] = [];
|
||||
while (remaining.length > 0) {
|
||||
const label = labels
|
||||
.filter((candidate) => !selected.includes(candidate))
|
||||
.sort((left, right) => right.length - left.length)
|
||||
.find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `));
|
||||
if (!label) break;
|
||||
selected.push(label);
|
||||
remaining = remaining === label ? "" : remaining.slice(label.length + 2);
|
||||
}
|
||||
return { selected, custom: remaining || null };
|
||||
return splitMultiSelectAnswer(answer, labels);
|
||||
}
|
||||
|
||||
/** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("OpenCode v2 question tool", () => {
|
||||
{ value: "Explore", label: "Explore", description: "Map the codebase" },
|
||||
{ value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" },
|
||||
],
|
||||
multiple: false,
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
@@ -129,6 +129,99 @@ describe("OpenCode v2 question tool", () => {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("answers a multi-select question with the checked options and a typed answer", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.api.session.form.list = async () => [
|
||||
{
|
||||
id: "question",
|
||||
sessionID: "session",
|
||||
title: "Questions",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Fixes",
|
||||
description: "Which fixes should I make?",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "delete", label: "Delete both" },
|
||||
{ value: "delete-and-fix", label: "Delete both, fix code" },
|
||||
{ value: "email", label: "Email the user" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = [];
|
||||
harness.api.session.form.reply = async (input) => {
|
||||
answers.push(input);
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
const [request] = session.getPendingPermissions();
|
||||
expect(request.input).toMatchObject({ questions: [{ header: "Fixes", multiSelect: true }] });
|
||||
await session.respondToPermission("question", {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: { Fixes: "Delete both, fix code, Email the user, Also check the logs" },
|
||||
},
|
||||
});
|
||||
expect(answers).toEqual([
|
||||
{
|
||||
sessionID: "session",
|
||||
formID: "question",
|
||||
answer: { q0: ["delete-and-fix", "email", "Also check the logs"] },
|
||||
},
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects typed text on a multi-select question that has no typed answer", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.api.session.form.list = async () => [
|
||||
{
|
||||
id: "question",
|
||||
sessionID: "session",
|
||||
title: "Questions",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Fixes",
|
||||
type: "multiselect",
|
||||
options: [{ value: "delete", label: "Delete both" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const answers: Parameters<V2Api["session"]["form"]["reply"]>[0][] = [];
|
||||
harness.api.session.form.reply = async (input) => {
|
||||
answers.push(input);
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
await expect(
|
||||
session.respondToPermission("question", {
|
||||
behavior: "allow",
|
||||
updatedInput: { answers: { Fixes: "Delete both, Something else" } },
|
||||
}),
|
||||
).rejects.toThrow("Invalid answer for OpenCode question q0");
|
||||
expect(answers).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode v2 permission routing", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "../../../agent-sdk-types.js";
|
||||
import { splitMultiSelectAnswer } from "../../question-card-answer.js";
|
||||
|
||||
export class SessionPermissions {
|
||||
private readonly pending = new Map<string, AgentPermissionRequest>();
|
||||
@@ -103,7 +104,7 @@ export class SessionPermissions {
|
||||
header: field.title ?? field.key,
|
||||
question: field.description ?? field.title ?? field.key,
|
||||
options: "options" in field ? field.options : undefined,
|
||||
multiple: field.type === "multiselect",
|
||||
multiSelect: field.type === "multiselect",
|
||||
allowOther: "custom" in field && field.custom === true,
|
||||
})),
|
||||
},
|
||||
@@ -137,8 +138,8 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu
|
||||
);
|
||||
};
|
||||
if (field.type === "multiselect") {
|
||||
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
|
||||
return value.map(labelValue);
|
||||
const selected = multiSelectAnswer(field, value);
|
||||
if (selected) return selected.map(labelValue);
|
||||
} else if (field.type === "string" && typeof value === "string") {
|
||||
return labelValue(value);
|
||||
} else if (field.type === "boolean") {
|
||||
@@ -156,3 +157,17 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu
|
||||
}
|
||||
throw new Error(`Invalid answer for OpenCode question ${field.key}`);
|
||||
}
|
||||
|
||||
type MultiSelectField = Extract<FormInfo["fields"][number], { type: "multiselect" }>;
|
||||
|
||||
function multiSelectAnswer(field: MultiSelectField, value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
|
||||
return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
const { selected, custom } = splitMultiSelectAnswer(
|
||||
value,
|
||||
field.options.map((option) => option.label),
|
||||
);
|
||||
if (custom === null) return selected;
|
||||
return field.custom === true ? [...selected, custom] : undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { splitMultiSelectAnswer } from "./question-card-answer.js";
|
||||
|
||||
describe("splitMultiSelectAnswer", () => {
|
||||
const labels = ["Delete both", "Delete both, fix code", "Email the user"];
|
||||
|
||||
test("splits checked labels in click order", () => {
|
||||
expect(splitMultiSelectAnswer("Email the user, Delete both", labels)).toEqual({
|
||||
selected: ["Email the user", "Delete both"],
|
||||
custom: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a label that contains a comma as one selection", () => {
|
||||
expect(splitMultiSelectAnswer("Delete both, fix code, Email the user", labels)).toEqual({
|
||||
selected: ["Delete both, fix code", "Email the user"],
|
||||
custom: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns trailing text as the typed answer", () => {
|
||||
expect(splitMultiSelectAnswer("Delete both, Also check the logs", labels)).toEqual({
|
||||
selected: ["Delete both"],
|
||||
custom: "Also check the logs",
|
||||
});
|
||||
expect(splitMultiSelectAnswer("Only typed text", labels)).toEqual({
|
||||
selected: [],
|
||||
custom: "Only typed text",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The shared question card sends a multi-select answer as one string: the checked option
|
||||
* labels in click order, then any typed answer, joined with ", ". Consume exact option
|
||||
* labels from the front, longest first so a label containing ", " wins over its prefix;
|
||||
* any remaining text is the typed answer.
|
||||
*/
|
||||
export function splitMultiSelectAnswer(
|
||||
answer: string,
|
||||
labels: string[],
|
||||
): { selected: string[]; custom: string | null } {
|
||||
let remaining = answer;
|
||||
const selected: string[] = [];
|
||||
while (remaining.length > 0) {
|
||||
const label = labels
|
||||
.filter((candidate) => !selected.includes(candidate))
|
||||
.sort((left, right) => right.length - left.length)
|
||||
.find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `));
|
||||
if (!label) break;
|
||||
selected.push(label);
|
||||
remaining = remaining === label ? "" : remaining.slice(label.length + 2);
|
||||
}
|
||||
return { selected, custom: remaining || null };
|
||||
}
|
||||
Reference in New Issue
Block a user