From ef2140d1219d98578663b9d7b13831a89c48810b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:32:10 -0500 Subject: [PATCH] fix(core): revert form service and mcp elicitation (#35080) --- .../regression/session-request-docks.spec.ts | 29 +- packages/app/e2e/utils/mock-server.ts | 12 - packages/app/src/context/directory-sync.ts | 2 - .../src/context/global-sync/bootstrap.test.ts | 5 +- .../app/src/context/global-sync/bootstrap.ts | 21 +- .../context/global-sync/event-reducer.test.ts | 54 +- .../src/context/global-sync/event-reducer.ts | 34 +- .../context/global-sync/session-cache.test.ts | 8 +- .../src/context/global-sync/session-cache.ts | 4 +- packages/app/src/context/global-sync/types.ts | 4 +- packages/app/src/context/server-session.ts | 18 +- packages/app/src/context/server-sync.tsx | 4 +- packages/app/src/pages/layout.tsx | 14 +- .../composer/session-composer-state.test.ts | 9 +- .../composer/session-composer-state.ts | 5 +- .../composer/session-question-dock.tsx | 202 +- .../session/composer/session-request-tree.ts | 9 +- packages/app/src/utils/question-form.test.ts | 36 - packages/app/src/utils/question-form.ts | 72 - packages/client/src/effect.ts | 2 +- .../client/src/generated-effect/client.ts | 428 +- packages/client/src/generated/client.ts | 165 +- packages/client/src/generated/types.ts | 1124 +--- packages/client/test/promise.test.ts | 49 +- packages/core/src/form.ts | 308 - packages/core/src/location-services.ts | 4 +- packages/core/src/mcp/client.ts | 32 - packages/core/src/mcp/index.ts | 149 +- packages/core/src/question.ts | 151 + packages/core/src/session/runner/llm.ts | 6 +- packages/core/src/tool/question.ts | 83 +- packages/core/test/form.test.ts | 58 - packages/core/test/question.test.ts | 114 + packages/core/test/session-runner.test.ts | 20 +- packages/core/test/tool-question.test.ts | 56 +- packages/opencode/script/httpapi-exercise.ts | 3 - .../src/cli/cmd/run/noninteractive.ts | 8 +- packages/opencode/src/cli/cmd/run/runtime.ts | 7 +- .../src/cli/cmd/run/stream-v2.transport.ts | 20 +- .../src/server/routes/instance/httpapi/api.ts | 10 + .../server/routes/instance/httpapi/server.ts | 13 + .../test/cli/run/stream-v2.transport.test.ts | 4 +- .../test/server/httpapi-exercise/index.ts | 40 +- .../server/httpapi-public-openapi.test.ts | 15 +- .../plugin/src/v2/effect/generated/api.ts | 386 +- packages/protocol/src/api.ts | 64 +- packages/protocol/src/client.ts | 9 +- packages/protocol/src/errors.ts | 24 +- packages/protocol/src/groups/form.ts | 151 - packages/protocol/src/groups/question.ts | 84 + packages/schema/src/event-manifest.ts | 4 +- packages/schema/src/form.ts | 149 - packages/schema/src/index.ts | 2 +- packages/schema/src/question.ts | 86 + packages/schema/test/contract-hygiene.test.ts | 4 +- packages/schema/test/event-manifest.test.ts | 12 +- packages/sdk/js/script/build.ts | 45 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 463 +- packages/sdk/js/src/v2/gen/types.gen.ts | 5039 ++++++++++++----- packages/sdk/js/test/session-history.test.ts | 10 +- packages/server/src/api.ts | 4 - packages/server/src/handlers.ts | 4 +- packages/server/src/handlers/form.ts | 136 - packages/server/src/handlers/question.ts | 62 + packages/server/src/location.ts | 4 +- .../server/src/middleware/form-location.ts | 76 - packages/server/src/routes.ts | 2 - packages/tui/src/context/data.tsx | 106 +- .../feature-plugins/system/notifications.ts | 19 - packages/tui/src/routes/session/form.tsx | 710 --- packages/tui/src/routes/session/index.tsx | 31 +- packages/tui/src/routes/session/question.tsx | 514 ++ packages/tui/test/cli/tui/data.test.tsx | 42 +- 73 files changed, 5874 insertions(+), 5779 deletions(-) delete mode 100644 packages/app/src/utils/question-form.test.ts delete mode 100644 packages/app/src/utils/question-form.ts delete mode 100644 packages/core/src/form.ts create mode 100644 packages/core/src/question.ts delete mode 100644 packages/core/test/form.test.ts create mode 100644 packages/core/test/question.test.ts delete mode 100644 packages/protocol/src/groups/form.ts create mode 100644 packages/protocol/src/groups/question.ts delete mode 100644 packages/schema/src/form.ts create mode 100644 packages/schema/src/question.ts delete mode 100644 packages/server/src/handlers/form.ts create mode 100644 packages/server/src/handlers/question.ts delete mode 100644 packages/server/src/middleware/form-location.ts delete mode 100644 packages/tui/src/routes/session/form.tsx create mode 100644 packages/tui/src/routes/session/question.tsx diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 6bcc783281..036eaaef42 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -10,21 +10,17 @@ const title = "Request dock regression" test("shows a pending question dock", async ({ page }) => { await mockServer(page, { - forms: [ + questions: [ { - id: "frm_question_request", + id: "question-request", sessionID, - mode: "form", - metadata: { kind: "question" }, - fields: [ + questions: [ { - key: "question_0", - type: "string", - title: "Which implementation should be used?", - description: "Implementation", + header: "Implementation", + question: "Which implementation should be used?", options: [ - { value: "Minimal", label: "Minimal", description: "Use the smallest correct change" }, - { value: "Extended", label: "Extended", description: "Include additional behavior" }, + { label: "Minimal", description: "Use the smallest correct change" }, + { label: "Extended", description: "Include additional behavior" }, ], }, ], @@ -45,8 +41,7 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`) - rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -68,12 +63,10 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => - request.method() === "POST" && - new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`, + (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", ) await question.getByRole("button", { name: "Submit" }).click() - expect((await reply).postDataJSON()).toEqual({ answer: { question_0: "Minimal" } }) + expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) }) test("shows a pending permission dock", async ({ page }) => { @@ -112,7 +105,6 @@ async function mockServer( requests: { permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) - forms?: unknown[] | (() => unknown[]) }, ) { await mockOpenCodeServer(page, { @@ -156,7 +148,6 @@ async function mockServer( pageMessages: () => ({ items: [] }), permissions: requests.permissions, questions: requests.questions, - forms: requests.forms, }) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index d0978bed09..875c3b7a96 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -17,7 +17,6 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) - forms?: unknown[] | (() => unknown[]) } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -54,17 +53,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) - if (path === "/api/form/request") - return json(route, { - location: { directory: config.directory, project: config.project }, - data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), - }) - if (/^\/api\/session\/[^/]+\/form$/.test(path)) - return json(route, { - location: { directory: config.directory, project: config.project }, - data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), - }) - if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path)) return json(route, undefined, undefined, 204) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index bb31621f36..68e6b19cef 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -30,13 +30,11 @@ export const createDirSyncContext = ( const data = new Proxy({} as State, { get(_, property: keyof State) { if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data) - if (property === "question") return { ...serverSync.session.data.question, global: current()[0].question.global ?? [] } if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data] return current()[0][property] }, }) const set = ((...input: unknown[]) => { - if (input[0] === "question" && input[1] === "global") return (current()[1] as (...args: unknown[]) => unknown)(...input) if (typeof input[0] === "string" && sessionFields.has(input[0])) { return (serverSync.session.set as (...args: unknown[]) => unknown)(...input) } diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index f857cfbed1..73de5b9ce6 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -69,10 +69,7 @@ describe("bootstrapDirectory", () => { }, permission: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) }, - v2: { - form: { request: { list: async () => ({ data: { data: [] } }) } }, - reference: { list: async () => ({ data: { data: [] } }) }, - }, + v2: { reference: { list: async () => ({ data: { data: [] } }) } }, mcp: { status: async () => { mcpReads.push("status") diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 07ed070915..8f253a975a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -5,6 +5,7 @@ import type { PermissionRequest, Project, ProviderAuthResponse, + QuestionRequest, ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" @@ -21,7 +22,6 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" -import { isQuestionForm, type QuestionForm } from "@/utils/question-form" type GlobalStore = { ready: boolean @@ -319,11 +319,9 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - input.sdk.v2.form.request.list().then((x) => { - const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : [])) - const global = forms.filter((question) => question.sessionID === "global") - const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global")) - const ids = Object.keys(grouped) + input.sdk.question.list().then((x) => { + const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) @@ -331,22 +329,11 @@ export async function bootstrapDirectory(input: { batch(() => { const current = input.session?.data.question ?? input.store.question for (const sessionID of Object.keys(current)) { - if (sessionID === "global") continue if (grouped[sessionID]) continue if (input.session?.get(sessionID)?.directory !== input.directory) continue if (input.session) input.session.set("question", sessionID, []) if (!input.session) input.setStore("question", sessionID, []) } - if (global.length > 0 || input.store.question.global) { - input.setStore( - "question", - "global", - reconcile( - global.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } for (const [sessionID, questions] of Object.entries(grouped)) { const value = reconcile( questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f45aff92fc..2811e6e3c6 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, Project, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" -import type { QuestionForm } from "@/utils/question-form" const rootSession = (input: { id: string; parentID?: string; archived?: number }) => ({ @@ -49,18 +48,14 @@ const questionRequest = (id: string, sessionID: string, title = id) => ({ id, sessionID, - mode: "form", - metadata: { kind: "question" }, - fields: [ + questions: [ { - key: "question_0", - title, - description: title, - type: "string", - options: [{ value: title, label: title, description: title }], + question: title, + header: title, + options: [{ label: title, description: title }], }, ], - }) as QuestionForm + }) as QuestionRequest const baseState = (input: Partial = {}) => ({ @@ -510,7 +505,7 @@ describe("applyDirectoryEvent", () => { expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) applyDirectoryEvent({ - event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } }, + event: { type: "question.asked", properties: questionRequest("q_2", sessionID) }, store, setStore, push() {}, @@ -520,18 +515,17 @@ describe("applyDirectoryEvent", () => { expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"]) applyDirectoryEvent({ - event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } }, + event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") }, store, setStore, push() {}, directory: "/tmp", loadLsp() {}, }) - const form = store.question[sessionID]?.find((x) => x.id === "q_2") - expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated") + expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated") applyDirectoryEvent({ - event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } }, + event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } }, store, setStore, push() {}, @@ -541,34 +535,6 @@ describe("applyDirectoryEvent", () => { expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"]) }) - test("tracks global form lifecycles when session content is delegated", () => { - const [store, setStore] = createStore(baseState()) - - applyDirectoryEvent({ - event: { type: "form.created", properties: { form: questionRequest("q_1", "global") } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - sessionContent: false, - }) - - expect(store.question.global?.map((x) => x.id)).toEqual(["q_1"]) - - applyDirectoryEvent({ - event: { type: "form.cancelled", properties: { sessionID: "global", id: "q_1" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - sessionContent: false, - }) - - expect(store.question.global).toEqual([]) - }) - test("updates vcs branch in store and cache", () => { const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) const [cacheStore, setCacheStore] = createStore({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index d35f4c9f84..15d125df39 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -5,6 +5,7 @@ import type { Part, PermissionRequest, Project, + QuestionRequest, Session, SessionStatus, SnapshotFileDiff, @@ -14,7 +15,6 @@ import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" -import { isQuestionForm } from "@/utils/question-form" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SESSION_CONTENT_EVENTS = new Set([ @@ -28,9 +28,9 @@ const SESSION_CONTENT_EVENTS = new Set([ "message.part.delta", "permission.asked", "permission.replied", - "form.created", - "form.replied", - "form.cancelled", + "question.asked", + "question.replied", + "question.rejected", ]) export function applyGlobalEvent(input: { @@ -120,7 +120,7 @@ export function applyDirectoryEvent(input: { permission?: State["permission"] }) { const event = input.event - if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(event)) return + if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) switch (event.type) { case "server.instance.disposed": { @@ -364,10 +364,8 @@ export function applyDirectoryEvent(input: { ) break } - case "form.created": { - const properties = event.properties as { form?: unknown } - if (!isQuestionForm(properties.form)) break - const question = properties.form + case "question.asked": { + const question = event.properties as QuestionRequest const questions = input.store.question[question.sessionID] if (!questions) { input.setStore("question", question.sessionID, [question]) @@ -387,12 +385,12 @@ export function applyDirectoryEvent(input: { ) break } - case "form.replied": - case "form.cancelled": { - const props = event.properties as { sessionID: string; id: string } + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } const questions = input.store.question[props.sessionID] if (!questions) break - const result = Binary.search(questions, props.id, (q) => q.id) + const result = Binary.search(questions, props.requestID, (q) => q.id) if (!result.found) break input.setStore( "question", @@ -413,13 +411,3 @@ export function applyDirectoryEvent(input: { } } } - -export function isGlobalQuestionEvent(event: { type: string; properties?: unknown }) { - if (event.type === "form.created") { - const form = (event.properties as { form?: unknown } | undefined)?.form - return isQuestionForm(form) && form.sessionID === "global" - } - if (event.type !== "form.replied" && event.type !== "form.cancelled") return false - const properties = event.properties as { sessionID?: unknown } | undefined - return properties?.sessionID === "global" -} diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 9ecd893d09..4b2be505ea 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -3,12 +3,12 @@ import type { Message, Part, PermissionRequest, + QuestionRequest, SessionStatus, SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" -import type { QuestionForm } from "@/utils/question-form" const msg = (id: string, sessionID: string) => ({ @@ -38,7 +38,7 @@ describe("app session cache", () => { message: Record part: Record permission: Record - question: Record + question: Record part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, @@ -47,7 +47,7 @@ describe("app session cache", () => { message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, - question: { ses_1: [] as QuestionForm[] }, + question: { ses_1: [] as QuestionRequest[] }, part_text_accum_delta: { prt_1: "streamed text" }, } @@ -72,7 +72,7 @@ describe("app session cache", () => { message: Record part: Record permission: Record - question: Record + question: Record part_text_accum_delta: Record } = { session_status: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index a9d7bc4206..05cdc84643 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -2,11 +2,11 @@ import type { Message, Part, PermissionRequest, + QuestionRequest, SessionStatus, SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" -import type { QuestionForm } from "@/utils/question-form" export const SESSION_CACHE_LIMIT = 40 @@ -17,7 +17,7 @@ type SessionCache = { message: Record part: Record permission: Record - question: Record + question: Record part_text_accum_delta: Record } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index d91a786176..86b489cd09 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -9,6 +9,7 @@ import type { Part, Path, PermissionRequest, + QuestionRequest, ReferenceInfo, Session, SessionStatus, @@ -16,7 +17,6 @@ import type { Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" -import type { QuestionForm } from "@/utils/question-form" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -60,7 +60,7 @@ export type State = { [sessionID: string]: PermissionRequest[] } question: { - [sessionID: string]: QuestionForm[] + [sessionID: string]: QuestionRequest[] } mcp_ready: boolean mcp: { diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 226d66bcb7..9ba712ef25 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -5,12 +5,12 @@ import type { OpencodeClient, Part, PermissionRequest, + QuestionRequest, Session, SessionStatus, SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" -import { isQuestionForm, type QuestionForm } from "@/utils/question-form" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" @@ -136,7 +136,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: session_diff: {} as Record, todo: {} as Record, permission: {} as Record, - question: {} as Record, + question: {} as Record, message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, @@ -932,10 +932,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) return } - case "form.created": { - const properties = event.properties as { form?: unknown } - if (!isQuestionForm(properties.form)) return - const question = properties.form + case "question.asked": { + const question = event.properties as QuestionRequest const questions = data.question[question.sessionID] if (!questions) { setData("question", question.sessionID, [question]) @@ -951,15 +949,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) return } - case "form.replied": - case "form.cancelled": { - const props = event.properties as { sessionID: string; id: string } + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } setData( "question", props.sessionID, produce((draft) => { if (!draft) return - const result = Binary.search(draft, props.id, (item) => item.id) + const result = Binary.search(draft, props.requestID, (item) => item.id) if (result.found) draft.splice(result.index, 1) }), ) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 6e11bd75b1..9b337aa076 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -25,7 +25,7 @@ import { loadReferencesQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" -import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } from "./global-sync/event-reducer" +import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" @@ -375,7 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 - if (!isGlobalQuestionEvent(event)) session.apply(event) + session.apply(event) if (directory === "global") { applyGlobalEvent({ diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 448638771d..690460d3af 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -48,7 +48,6 @@ import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" -import { isQuestionForm } from "@/utils/question-form" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" @@ -404,22 +403,25 @@ export default function LegacyLayout(props: ParentProps) { return } - if (e.details?.type === "form.replied" || e.details?.type === "form.cancelled" || e.details?.type === "permission.replied") { + if ( + e.details?.type === "question.replied" || + e.details?.type === "question.rejected" || + e.details?.type === "permission.replied" + ) { const props = e.details.properties as { sessionID: string } const sessionKey = `${e.name}:${props.sessionID}` dismissSessionAlert(sessionKey) return } - const questionForm = e.details?.type === "form.created" && isQuestionForm(e.details.properties?.form) ? e.details.properties.form : undefined - if (e.details?.type !== "permission.asked" && !questionForm) return + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return const title = e.details.type === "permission.asked" ? language.t("notification.permission.title") : language.t("notification.question.title") const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) const directory = e.name - const props = questionForm ?? (e.details.properties as { sessionID: string }) + const props = e.details.properties if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return const [store] = serverSync().child(directory, { bootstrap: false }) @@ -448,7 +450,7 @@ export default function LegacyLayout(props: ParentProps) { } } - if (questionForm) { + if (e.details.type === "question.asked") { if (settings.notifications.agent()) { void platform.notify(title, description, href) } diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index 3db22f347b..49efcc971a 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" -import type { QuestionForm } from "@/utils/question-form" +import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { todoDockAtBoundary, todoState } from "./session-composer-state" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" @@ -20,10 +19,8 @@ const question = (id: string, sessionID: string) => ({ id, sessionID, - mode: "form", - metadata: { kind: "question" }, - fields: [], - }) as QuestionForm + questions: [], + }) as QuestionRequest describe("sessionPermissionRequest", () => { test("prefers the current session permission", () => { diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index aa3833e04b..45f5e4cb26 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,7 +1,6 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import type { PermissionRequest, Todo } from "@opencode-ai/sdk/v2" -import type { QuestionForm } from "@/utils/question-form" +import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" import { useServerSync } from "@/context/server-sync" @@ -34,7 +33,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( const language = useLanguage() const permission = usePermission() - const questionRequest = createMemo((): QuestionForm | undefined => { + const questionRequest = createMemo((): QuestionRequest | undefined => { return sessionQuestionRequest(sync().data.session, sync().data.question, params.id) }) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index b9aba13bb4..445a9f47a0 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -6,21 +6,13 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" import { useSpring } from "@opencode-ai/ui/motion-spring" import { showToast } from "@/utils/toast" +import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" import { ScopedKey } from "@/utils/server-scope" -import { - questionAllowsCustom, - questionAnswer, - questionLabel, - questionMessage, - questionOptions, - type QuestionAnswer, - type QuestionForm, -} from "@/utils/question-form" const cache = new Map() @@ -69,14 +61,14 @@ function Option(props: { ) } -export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: () => void }> = (props) => { +export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { const sdk = useSDK() const serverSDK = useServerSDK() const language = useLanguage() const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id) - const questions = createMemo(() => (props.request.mode === "form" ? props.request.fields : [])) - const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length)) + const questions = createMemo(() => props.request.questions) + const total = createMemo(() => questions().length) const cached = cache.get(cacheKey) const [store, setStore] = createStore({ @@ -98,23 +90,16 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( let focusFrame: number | undefined const question = createMemo(() => questions()[store.tab]) - const options = createMemo(() => questionOptions(question())) - const custom = createMemo(() => questionAllowsCustom(question())) + const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") - const on = createMemo(() => custom() && store.customOn[store.tab] === true) - const multi = createMemo(() => question()?.type === "multiselect") - const count = createMemo(() => options().length + (custom() ? 1 : 0)) + const on = createMemo(() => store.customOn[store.tab] === true) + const multi = createMemo(() => question()?.multiple === true) + const count = createMemo(() => options().length + 1) const summary = createMemo(() => { - if (props.request.title) return props.request.title const n = Math.min(store.tab + 1, total()) return language.t("session.question.progress", { current: n, total: total() }) }) - const body = createMemo(() => { - if (props.request.mode === "url") return props.request.title ?? "Open URL request" - return questionLabel(question()) - }) - const message = createMemo(() => questionMessage(props.request)) const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") const customPlaceholder = () => language.t("ui.question.custom.placeholder") @@ -168,11 +153,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i)) const pickFocus = (tab: number = store.tab) => { - const list = questionOptions(questions()[tab]) - if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length + const list = questions()[tab]?.options ?? [] + if (store.customOn[tab] === true) return list.length return Math.max( 0, - list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false), + list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false), ) } @@ -238,12 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => - sdk().client.v2.session.form.reply({ - sessionID: props.request.sessionID, - formID: props.request.id, - formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) }, - }), + mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -255,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.v2.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }), + mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), onMutate: () => { props.onSubmit() }, @@ -282,7 +262,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const answered = (i: number) => { if ((store.answers[i]?.length ?? 0) > 0) return true - return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 + return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 } const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false @@ -303,7 +283,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const customToggle = () => { if (sending()) return - if (!custom()) return setStore("focus", options().length) if (!multi()) { @@ -329,7 +308,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( const customOpen = () => { if (sending()) return - if (!custom()) return setStore("focus", options().length) if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) @@ -391,7 +369,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( if (sending()) return if (optIndex === options().length) { - if (!custom()) return customOpen() return } @@ -400,10 +377,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( if (!opt) return if (multi()) { setStore("editing", false) - toggle(opt.value) + toggle(opt.label) return } - pick(opt.value) + pick(opt.label) } const commitCustom = () => { @@ -549,24 +526,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: ( overflow: store.minimized ? "hidden" : undefined, }} > - {body()} + {question()?.question} - -
{message()}
-
- {language.t("ui.question.singleHint")}}> -
{language.t("ui.question.multiHint")}
-
- } - > -
Open this URL, complete the request, then submit.
- - {props.request.mode === "url" ? props.request.url : ""} - + {language.t("ui.question.singleHint")}}> +
{language.t("ui.question.multiHint")}
(