fix(server): surface opencode questions in permission UI

This commit is contained in:
Mohamed Boudra
2026-03-26 14:49:52 +07:00
parent e47eb64de0
commit 5b3aea8bfe
2 changed files with 260 additions and 0 deletions
@@ -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(
event: unknown,
state: OpenCodeEventTranslationState,
@@ -785,6 +821,60 @@ export function translateOpenCodeEvent(
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": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === state.sessionId) {
@@ -1196,6 +1286,38 @@ class OpenCodeAgentSession implements AgentSession {
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";
await this.client.permission.reply({
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,
);
});