Skip to content

Commit 7ebd344

Browse files
authored
feat(core): add session form service (#34855)
1 parent 460cdc5 commit 7ebd344

67 files changed

Lines changed: 7862 additions & 5223 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/app/e2e/regression/session-request-docks.spec.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,21 @@ const title = "Request dock regression"
1010

1111
test("shows a pending question dock", async ({ page }) => {
1212
await mockServer(page, {
13-
questions: [
13+
forms: [
1414
{
15-
id: "question-request",
15+
id: "frm_question_request",
1616
sessionID,
17-
questions: [
17+
mode: "form",
18+
metadata: { kind: "question" },
19+
fields: [
1820
{
19-
header: "Implementation",
20-
question: "Which implementation should be used?",
21+
key: "question_0",
22+
type: "string",
23+
title: "Which implementation should be used?",
24+
description: "Implementation",
2125
options: [
22-
{ label: "Minimal", description: "Use the smallest correct change" },
23-
{ label: "Extended", description: "Include additional behavior" },
26+
{ value: "Minimal", label: "Minimal", description: "Use the smallest correct change" },
27+
{ value: "Extended", label: "Extended", description: "Include additional behavior" },
2428
],
2529
},
2630
],
@@ -41,7 +45,8 @@ test("shows a pending question dock", async ({ page }) => {
4145
const rejectRequests: string[] = []
4246
page.on("request", (request) => {
4347
if (request.method() !== "POST") return
44-
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
48+
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
49+
rejectRequests.push(request.url())
4550
})
4651

4752
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -63,10 +68,12 @@ test("shows a pending question dock", async ({ page }) => {
6368

6469
await question.getByRole("radio", { name: /Minimal/ }).click()
6570
const reply = page.waitForRequest(
66-
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
71+
(request) =>
72+
request.method() === "POST" &&
73+
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
6774
)
6875
await question.getByRole("button", { name: "Submit" }).click()
69-
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
76+
expect((await reply).postDataJSON()).toEqual({ answer: { question_0: "Minimal" } })
7077
})
7178

7279
test("shows a pending permission dock", async ({ page }) => {
@@ -105,6 +112,7 @@ async function mockServer(
105112
requests: {
106113
permissions?: unknown[] | (() => unknown[])
107114
questions?: unknown[] | (() => unknown[])
115+
forms?: unknown[] | (() => unknown[])
108116
},
109117
) {
110118
await mockOpenCodeServer(page, {
@@ -148,6 +156,7 @@ async function mockServer(
148156
pageMessages: () => ({ items: [] }),
149157
permissions: requests.permissions,
150158
questions: requests.questions,
159+
forms: requests.forms,
151160
})
152161
await page.addInitScript(() => {
153162
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))

‎packages/app/e2e/utils/mock-server.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface MockServerConfig {
1717
todos?: (sessionID: string) => unknown[]
1818
permissions?: unknown[] | (() => unknown[])
1919
questions?: unknown[] | (() => unknown[])
20+
forms?: unknown[] | (() => unknown[])
2021
}
2122

2223
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -53,6 +54,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
5354
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
5455
if (path === "/question")
5556
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
57+
if (path === "/api/form/request")
58+
return json(route, {
59+
location: { directory: config.directory, project: config.project },
60+
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
61+
})
62+
if (/^\/api\/session\/[^/]+\/form$/.test(path))
63+
return json(route, {
64+
location: { directory: config.directory, project: config.project },
65+
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
66+
})
67+
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path)) return json(route, undefined, undefined, 204)
5668
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
5769
if (emptyObject.has(path)) return json(route, {})
5870
if (emptyList.has(path)) return json(route, [])

‎packages/app/src/context/global-sync/bootstrap.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ describe("bootstrapDirectory", () => {
6969
},
7070
permission: { list: async () => ({ data: [] }) },
7171
question: { list: async () => ({ data: [] }) },
72-
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
72+
v2: {
73+
form: { request: { list: async () => ({ data: { data: [] } }) } },
74+
reference: { list: async () => ({ data: { data: [] } }) },
75+
},
7376
mcp: {
7477
status: async () => {
7578
mcpReads.push("status")

‎packages/app/src/context/global-sync/bootstrap.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type {
55
PermissionRequest,
66
Project,
77
ProviderAuthResponse,
8-
QuestionRequest,
98
ReferenceInfo,
109
Session,
1110
} from "@opencode-ai/sdk/v2/client"
@@ -22,6 +21,7 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
2221
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
2322
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
2423
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
24+
import { isQuestionForm, type QuestionForm } from "@/utils/question-form"
2525

2626
type GlobalStore = {
2727
ready: boolean
@@ -319,9 +319,10 @@ export async function bootstrapDirectory(input: {
319319
),
320320
() =>
321321
retry(() =>
322-
input.sdk.question.list().then((x) => {
323-
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
324-
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
322+
input.sdk.v2.form.request.list().then((x) => {
323+
const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : []))
324+
const ids = forms.map((question) => question.sessionID)
325+
const grouped = groupBySession(forms)
325326
const warm = input.session
326327
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
327328
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })

‎packages/app/src/context/global-sync/event-reducer.test.ts‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { describe, expect, test } from "bun:test"
2-
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
2+
import type { Message, Part, PermissionRequest, Project, Session } from "@opencode-ai/sdk/v2/client"
33
import { createStore } from "solid-js/store"
44
import type { State } from "./types"
55
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
6+
import type { QuestionForm } from "@/utils/question-form"
67

78
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
89
({
@@ -48,14 +49,18 @@ const questionRequest = (id: string, sessionID: string, title = id) =>
4849
({
4950
id,
5051
sessionID,
51-
questions: [
52+
mode: "form",
53+
metadata: { kind: "question" },
54+
fields: [
5255
{
53-
question: title,
54-
header: title,
55-
options: [{ label: title, description: title }],
56+
key: "question_0",
57+
title,
58+
description: title,
59+
type: "string",
60+
options: [{ value: title, label: title, description: title }],
5661
},
5762
],
58-
}) as QuestionRequest
63+
}) as QuestionForm
5964

6065
const baseState = (input: Partial<State> = {}) =>
6166
({
@@ -505,7 +510,7 @@ describe("applyDirectoryEvent", () => {
505510
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
506511

507512
applyDirectoryEvent({
508-
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
513+
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } },
509514
store,
510515
setStore,
511516
push() {},
@@ -515,17 +520,17 @@ describe("applyDirectoryEvent", () => {
515520
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
516521

517522
applyDirectoryEvent({
518-
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
523+
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } },
519524
store,
520525
setStore,
521526
push() {},
522527
directory: "/tmp",
523528
loadLsp() {},
524529
})
525-
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
530+
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.fields[0]?.description).toBe("updated")
526531

527532
applyDirectoryEvent({
528-
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
533+
event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
529534
store,
530535
setStore,
531536
push() {},

‎packages/app/src/context/global-sync/event-reducer.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type {
55
Part,
66
PermissionRequest,
77
Project,
8-
QuestionRequest,
98
Session,
109
SessionStatus,
1110
SnapshotFileDiff,
@@ -15,6 +14,7 @@ import type { State, VcsCache } from "./types"
1514
import { trimSessions } from "./session-trim"
1615
import { dropSessionCaches } from "./session-cache"
1716
import { diffs as list, message as clean } from "@/utils/diffs"
17+
import { isQuestionForm } from "@/utils/question-form"
1818

1919
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
2020
const SESSION_CONTENT_EVENTS = new Set([
@@ -28,9 +28,9 @@ const SESSION_CONTENT_EVENTS = new Set([
2828
"message.part.delta",
2929
"permission.asked",
3030
"permission.replied",
31-
"question.asked",
32-
"question.replied",
33-
"question.rejected",
31+
"form.created",
32+
"form.replied",
33+
"form.cancelled",
3434
])
3535

3636
export function applyGlobalEvent(input: {
@@ -364,8 +364,10 @@ export function applyDirectoryEvent(input: {
364364
)
365365
break
366366
}
367-
case "question.asked": {
368-
const question = event.properties as QuestionRequest
367+
case "form.created": {
368+
const properties = event.properties as { form?: unknown }
369+
if (!isQuestionForm(properties.form)) break
370+
const question = properties.form
369371
const questions = input.store.question[question.sessionID]
370372
if (!questions) {
371373
input.setStore("question", question.sessionID, [question])
@@ -385,12 +387,12 @@ export function applyDirectoryEvent(input: {
385387
)
386388
break
387389
}
388-
case "question.replied":
389-
case "question.rejected": {
390-
const props = event.properties as { sessionID: string; requestID: string }
390+
case "form.replied":
391+
case "form.cancelled": {
392+
const props = event.properties as { sessionID: string; id: string }
391393
const questions = input.store.question[props.sessionID]
392394
if (!questions) break
393-
const result = Binary.search(questions, props.requestID, (q) => q.id)
395+
const result = Binary.search(questions, props.id, (q) => q.id)
394396
if (!result.found) break
395397
input.setStore(
396398
"question",

‎packages/app/src/context/global-sync/session-cache.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import type {
33
Message,
44
Part,
55
PermissionRequest,
6-
QuestionRequest,
76
SessionStatus,
87
SnapshotFileDiff,
98
Todo,
109
} from "@opencode-ai/sdk/v2/client"
1110
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
11+
import type { QuestionForm } from "@/utils/question-form"
1212

1313
const msg = (id: string, sessionID: string) =>
1414
({
@@ -38,7 +38,7 @@ describe("app session cache", () => {
3838
message: Record<string, Message[] | undefined>
3939
part: Record<string, Part[] | undefined>
4040
permission: Record<string, PermissionRequest[] | undefined>
41-
question: Record<string, QuestionRequest[] | undefined>
41+
question: Record<string, QuestionForm[] | undefined>
4242
part_text_accum_delta: Record<string, string | undefined>
4343
} = {
4444
session_status: { ses_1: { type: "busy" } as SessionStatus },
@@ -47,7 +47,7 @@ describe("app session cache", () => {
4747
message: {},
4848
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
4949
permission: { ses_1: [] as PermissionRequest[] },
50-
question: { ses_1: [] as QuestionRequest[] },
50+
question: { ses_1: [] as QuestionForm[] },
5151
part_text_accum_delta: { prt_1: "streamed text" },
5252
}
5353

@@ -72,7 +72,7 @@ describe("app session cache", () => {
7272
message: Record<string, Message[] | undefined>
7373
part: Record<string, Part[] | undefined>
7474
permission: Record<string, PermissionRequest[] | undefined>
75-
question: Record<string, QuestionRequest[] | undefined>
75+
question: Record<string, QuestionForm[] | undefined>
7676
part_text_accum_delta: Record<string, string | undefined>
7777
} = {
7878
session_status: {},

‎packages/app/src/context/global-sync/session-cache.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import type {
22
Message,
33
Part,
44
PermissionRequest,
5-
QuestionRequest,
65
SessionStatus,
76
SnapshotFileDiff,
87
Todo,
98
} from "@opencode-ai/sdk/v2/client"
9+
import type { QuestionForm } from "@/utils/question-form"
1010

1111
export const SESSION_CACHE_LIMIT = 40
1212

@@ -17,7 +17,7 @@ type SessionCache = {
1717
message: Record<string, Message[] | undefined>
1818
part: Record<string, Part[] | undefined>
1919
permission: Record<string, PermissionRequest[] | undefined>
20-
question: Record<string, QuestionRequest[] | undefined>
20+
question: Record<string, QuestionForm[] | undefined>
2121
part_text_accum_delta: Record<string, string | undefined>
2222
}
2323

‎packages/app/src/context/global-sync/types.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import type {
99
Part,
1010
Path,
1111
PermissionRequest,
12-
QuestionRequest,
1312
ReferenceInfo,
1413
Session,
1514
SessionStatus,
1615
SnapshotFileDiff,
1716
Todo,
1817
VcsInfo,
1918
} from "@opencode-ai/sdk/v2/client"
19+
import type { QuestionForm } from "@/utils/question-form"
2020
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
2121
import type { Accessor } from "solid-js"
2222
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -60,7 +60,7 @@ export type State = {
6060
[sessionID: string]: PermissionRequest[]
6161
}
6262
question: {
63-
[sessionID: string]: QuestionRequest[]
63+
[sessionID: string]: QuestionForm[]
6464
}
6565
mcp_ready: boolean
6666
mcp: {

0 commit comments

Comments
 (0)