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(
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user