From 5b3aea8bfe890fd54adfdd1412837f023e60f8a1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 26 Mar 2026 14:49:52 +0700 Subject: [PATCH] fix(server): surface opencode questions in permission UI --- .../server/agent/providers/opencode-agent.ts | 122 ++++++++++++++++ ...encode-plan-and-questions.real.e2e.test.ts | 138 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index e11dd3a6d..1c018400b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -597,6 +597,42 @@ function stringifyStructuredAssistantMessage(value: unknown): string | null { } } +function readOpenCodeRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : 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, diff --git a/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts new file mode 100644 index 000000000..4f9289854 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-plan-and-questions.real.e2e.test.ts @@ -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>; +}> { + 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, + ); +});