fix(server): surface opencode questions in permission UI
This commit is contained in:
@@ -597,6 +597,42 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOpenCodeRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuestionOptions(
|
||||||
|
value: unknown,
|
||||||
|
): Array<{ label: string; description?: string }> | null {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Array<{ label: string; description?: string }> = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item === "string" && item.trim().length > 0) {
|
||||||
|
options.push({ label: item.trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = readOpenCodeRecord(item);
|
||||||
|
const label = readNonEmptyString(record?.label);
|
||||||
|
if (!label) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const description = readNonEmptyString(record?.description);
|
||||||
|
options.push(description ? { label, description } : { label });
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
export function translateOpenCodeEvent(
|
export function translateOpenCodeEvent(
|
||||||
event: unknown,
|
event: unknown,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
@@ -785,6 +821,60 @@ export function translateOpenCodeEvent(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "question.asked": {
|
||||||
|
const sessionId = props.sessionID as string | undefined;
|
||||||
|
if (sessionId !== state.sessionId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = props.id as string | undefined;
|
||||||
|
const rawQuestions = Array.isArray(props.questions) ? props.questions : [];
|
||||||
|
if (!requestId || rawQuestions.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = rawQuestions.flatMap((item) => {
|
||||||
|
const questionRecord = readOpenCodeRecord(item);
|
||||||
|
const question = readNonEmptyString(questionRecord?.question);
|
||||||
|
const header = readNonEmptyString(questionRecord?.header);
|
||||||
|
if (!question || !header) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = normalizeQuestionOptions(questionRecord?.options) ?? [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
question,
|
||||||
|
header,
|
||||||
|
options,
|
||||||
|
...(questionRecord?.multiple === true ? { multiSelect: true } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (questions.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "opencode",
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
provider: "opencode",
|
||||||
|
name: "question",
|
||||||
|
kind: "question",
|
||||||
|
title: "Question",
|
||||||
|
input: { questions },
|
||||||
|
metadata: {
|
||||||
|
source: "opencode_question",
|
||||||
|
...(readOpenCodeRecord(props.tool) ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "session.idle": {
|
case "session.idle": {
|
||||||
const sessionId = props.sessionID as string | undefined;
|
const sessionId = props.sessionID as string | undefined;
|
||||||
if (sessionId === state.sessionId) {
|
if (sessionId === state.sessionId) {
|
||||||
@@ -1196,6 +1286,38 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
throw new Error(`No pending permission request with id '${requestId}'`);
|
throw new Error(`No pending permission request with id '${requestId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pending.kind === "question") {
|
||||||
|
if (response.behavior === "deny") {
|
||||||
|
await this.client.question.reject({
|
||||||
|
requestID: requestId,
|
||||||
|
directory: this.config.cwd,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const answersRecord = readOpenCodeRecord(response.updatedInput?.answers);
|
||||||
|
const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : [];
|
||||||
|
const answers = questions.map((item) => {
|
||||||
|
const header = readNonEmptyString(readOpenCodeRecord(item)?.header);
|
||||||
|
const rawAnswer = header ? readNonEmptyString(answersRecord?.[header]) : null;
|
||||||
|
if (!rawAnswer) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rawAnswer
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.question.reply({
|
||||||
|
requestID: requestId,
|
||||||
|
directory: this.config.cwd,
|
||||||
|
answers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPermissions.delete(requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reply = response.behavior === "allow" ? "once" : "reject";
|
const reply = response.behavior === "allow" ? "once" : "reject";
|
||||||
await this.client.permission.reply({
|
await this.client.permission.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||||
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
|
import { isProviderAvailable } from "./agent-configs.js";
|
||||||
|
|
||||||
|
function tmpCwd(): string {
|
||||||
|
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOpenCodeModel(
|
||||||
|
models: Array<{ id: string }>,
|
||||||
|
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
|
||||||
|
): string {
|
||||||
|
const preferred = models.find((model) => preferences.some((fragment) => model.id.includes(fragment)));
|
||||||
|
return preferred?.id ?? models[0]!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHarness(): Promise<{
|
||||||
|
client: DaemonClient;
|
||||||
|
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
|
||||||
|
}> {
|
||||||
|
const logger = pino({ level: "silent" });
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
agentClients: { opencode: new OpenCodeAgentClient(logger) },
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-real" } });
|
||||||
|
return { client, daemon };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => {
|
||||||
|
test.runIf(isProviderAvailable("opencode"))(
|
||||||
|
"surfaces clarifying questions as pending permissions",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const { client, daemon } = await createHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modelList = await client.listProviderModels("opencode");
|
||||||
|
expect(modelList.models.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const agent = await client.createAgent({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd,
|
||||||
|
title: "OpenCode question regression",
|
||||||
|
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
|
||||||
|
modeId: "plan",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
[
|
||||||
|
"Use the question tool/feature to ask me exactly one clarifying question.",
|
||||||
|
"Ask this exact question: What kind of project should the plan cover?",
|
||||||
|
"Wait for my answer before doing anything else.",
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshotWithQuestion = await client.waitForAgentUpsert(
|
||||||
|
agent.id,
|
||||||
|
(snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question",
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const permission = snapshotWithQuestion.pendingPermissions?.[0];
|
||||||
|
expect(permission).toBeTruthy();
|
||||||
|
expect(permission?.kind).toBe("question");
|
||||||
|
expect(Array.isArray(permission?.input?.questions)).toBe(true);
|
||||||
|
|
||||||
|
const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined;
|
||||||
|
expect(firstQuestion?.header).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await daemon.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
180_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(isProviderAvailable("opencode"))(
|
||||||
|
"plan mode stays read-only through the daemon path",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const filePath = path.join(cwd, "plan-mode-output.txt");
|
||||||
|
const { client, daemon } = await createHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modelList = await client.listProviderModels("opencode");
|
||||||
|
expect(modelList.models.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const agent = await client.createAgent({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd,
|
||||||
|
title: "OpenCode plan mode regression",
|
||||||
|
model: pickOpenCodeModel(modelList.models),
|
||||||
|
modeId: "plan",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.sendMessage(
|
||||||
|
agent.id,
|
||||||
|
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = await client.waitForFinish(agent.id, 180_000);
|
||||||
|
expect(state.status).toBe("idle");
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
|
||||||
|
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||||
|
direction: "tail",
|
||||||
|
limit: 0,
|
||||||
|
projection: "projected",
|
||||||
|
});
|
||||||
|
const assistantText = timeline.entries
|
||||||
|
.filter((entry) => entry.item.type === "assistant_message")
|
||||||
|
.map((entry) => entry.item.text)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
expect(assistantText.toLowerCase()).toContain("plan");
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await daemon.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
240_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user