From deb70f77f8617a9728c8fd86f7a785e569c4d937 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 23 Jul 2026 14:30:24 +0800 Subject: [PATCH 1/4] feat(app): migrate session interactions --- .../regression/session-request-docks.spec.ts | 11 +- .../subagent-child-navigation.spec.ts | 11 +- packages/app/src/components/dialog-fork.tsx | 10 +- .../components/prompt-input/submit.test.ts | 140 +++++++++++++++--- packages/app/src/context/permission.tsx | 38 +++-- .../src/pages/home-session-archive.test.ts | 4 +- .../app/src/pages/home-session-archive.ts | 14 +- packages/app/src/pages/home.tsx | 2 +- .../composer/session-composer-controls.ts | 3 +- .../composer/session-composer-state.ts | 2 +- .../composer/session-question-dock.tsx | 5 +- .../pages/session/use-session-commands.tsx | 21 ++- 12 files changed, 182 insertions(+), 79 deletions(-) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 714d6ca96f15..6c253f462ac6 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,8 @@ 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 === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -64,7 +65,9 @@ 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 === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) - expect(request.postDataJSON()).toEqual({ response: "once" }) + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 19d2c29af025..019cc156eca1 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,6 +1,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/SubagentNavigation" @@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) { events, eventRetry: events ? 16 : undefined, }) - // The child session resolves via /session/:id but is absent from the /session list, + // The child session resolves by ID but is absent from the session list, // matching a subagent session that has not been loaded into the list cache yet. await page.route( - (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]), + body: JSON.stringify({ + data: [currentSession(session(parentID, parentTitle, 1700000000000))], + cursor: {}, + }), }), ) await configurePage(page) diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 601f03084cea..5187d980ea26 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,15 +69,11 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .client.session.fork({ sessionID, messageID: item.id }) + .api.session.fork({ sessionID, messageID: item.id }) .then((forked) => { - if (!forked.data) { - showToast({ title: language.t("common.requestFailed") }) - return - } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 834fc4795a59..6e1bee51cedf 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] +const sessionCreateInputs: Array<{ + agent?: string + model?: { id: string; providerID: string; variant?: string } + location?: { directory: string } +}> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] const optimistic: Array<{ directory?: string @@ -21,9 +26,14 @@ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const sessionDirectories: Record = {} const promoted: Array<{ directory: string; sessionID: string }> = [] -const sentShell: string[] = [] +const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const syncedDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] +const sentPrompts: string[] = [] +const promptInputs: unknown[] = [] +const sentCommands: unknown[] = [] +const commands: Array<{ name: string }> = [] +let serverSessionSyncs = 0 let params: { id?: string } = {} let search: { draftId?: string } = {} @@ -32,7 +42,7 @@ let variant: string | undefined let permissionServer = "server-a" let createSessionGate: Promise | undefined -const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ prompt: promptValue, cursor: 0, @@ -64,23 +74,39 @@ const prompt = { const clientFor = (directory: string) => { createdClients.push(directory) return { - session: { - create: async () => { - await createSessionGate - createdSessions.push(directory) - return { - data: { + api: { + session: { + create: async (input: (typeof sessionCreateInputs)[number]) => { + await createSessionGate + const location = input.location?.directory ?? directory + createdSessions.push(location) + sessionCreateInputs.push(input) + return { id: `session-${createdSessions.length}`, + projectID: "project", + agent: input.agent, + model: input.model, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, title: `New session ${createdSessions.length}`, - }, - } - }, - shell: async () => { - sentShell.push(directory) - return { data: undefined } + location: { directory: location }, + } + }, + prompt: async (input: unknown) => { + sentPrompts.push(directory) + promptInputs.push(input) + return { data: undefined } + }, + command: async (input: unknown) => { + sentCommands.push(input) + }, + shell: async (input: { sessionID: string; id?: string; command: string }) => { + sentShell.push(input) + }, }, - prompt: async () => ({ data: undefined }), - promptAsync: async () => ({ data: undefined }), + }, + session: { command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -195,6 +221,7 @@ beforeAll(async () => { directory: "/repo/main", api, client: rootClient, + api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -206,7 +233,7 @@ beforeAll(async () => { mock.module("@/context/sync", () => ({ useSync: () => () => ({ - data: { command: [] }, + data: { command: commands }, session: { optimistic: { add: (value: { @@ -233,6 +260,9 @@ beforeAll(async () => { session: { remember: () => undefined, set: () => undefined, + sync: async () => { + serverSessionSyncs++ + }, }, child: (directory: string) => { syncedDirectories.push(directory) @@ -274,11 +304,17 @@ beforeAll(async () => { beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 + sessionCreateInputs.length = 0 enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 promotedDrafts.length = 0 + sentPrompts.length = 0 + promptInputs.length = 0 + sentCommands.length = 0 + commands.length = 0 + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] params = {} search = {} sentShell.length = 0 @@ -287,6 +323,7 @@ beforeEach(() => { variant = undefined permissionServer = "server-a" createSessionGate = undefined + serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] }) @@ -321,8 +358,24 @@ describe("prompt submit worktree selection", () => { expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(sessionCreateInputs).toEqual([ + { + agent: "agent", + model: { id: "model", providerID: "provider", variant: undefined }, + location: { directory: "/repo/worktree-a" }, + }, + { + agent: "agent", + model: { id: "model", providerID: "provider", variant: undefined }, + location: { directory: "/repo/worktree-b" }, + }, + ]) + expect(sentShell).toEqual([ + expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), + expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), + ]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) + expect(serverSessionSyncs).toBe(0) expect(promoted).toEqual([ { directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-b", sessionID: "session-2" }, @@ -443,6 +496,7 @@ describe("prompt submit worktree selection", () => { const event = { preventDefault: () => undefined } as unknown as Event await submit.handleSubmit(event) + await Bun.sleep(0) expect(optimistic).toHaveLength(1) expect(optimistic[0]).toMatchObject({ @@ -451,6 +505,53 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) + expect(sentPrompts).toEqual(["/repo/main"]) + expect(promptInputs[0]).toMatchObject({ + sessionID: "session-1", + text: "ls", + files: [], + agents: [], + }) + expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") + }) + + test("submits slash commands through the current session API", async () => { + params = { id: "session-1" } + variant = "high" + commands.push({ name: "review" }) + promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] + + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(sentCommands).toEqual([ + { + sessionID: "session-1", + id: expect.stringMatching(/^msg_/), + command: "review", + arguments: "staged changes", + agent: "agent", + model: { id: "model", providerID: "provider", variant: "high" }, + files: [], + }, + ]) + expect(serverSessionSyncs).toBe(0) }) test("uses an injected model selection", async () => { @@ -511,7 +612,8 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) + expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) + expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) expect(optimisticSeeded).toEqual([true]) }) }) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 496d1ab4a282..388e4534a11a 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs" import { useSettings } from "./settings" import { requireServerKey } from "@/utils/session-route" import type { ServerScope } from "@/utils/server-scope" +import { normalizePermissionRequest } from "./global-sync/utils" import { acceptKey, directoryAcceptKey, @@ -243,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } const respond: PermissionRespondFn = (request) => { if (meta.disposed) return - input.sdk.client.permission.respond(request).catch(() => { - responded.delete(request.permissionID) - }) + input.sdk.api.permission + .reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response }) + .catch(() => { + responded.delete(request.permissionID) + }) + } + + const list = async (directory: string) => { + if ((await input.sdk.protocol) === "v1") { + return (await input.sdk.client.permission.list({ directory })).data ?? [] + } + return input.sdk.api.permission.request + .list({ location: { directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) } function respondOnce(permission: PermissionRequest, directory?: string) { @@ -343,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory)) + for (const permission of permissions) { + void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory)) } }) .catch(() => undefined) @@ -377,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (enableVersion.get(key) !== version) return if (!isAutoAccepting(sessionID, directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue + for (const permission of permissions) { void respondPending( - perm, + permission, directory, () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory), ) diff --git a/packages/app/src/pages/home-session-archive.test.ts b/packages/app/src/pages/home-session-archive.test.ts index 0ad30afcd250..2d04e808f46c 100644 --- a/packages/app/src/pages/home-session-archive.test.ts +++ b/packages/app/src/pages/home-session-archive.test.ts @@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => { await archiveHomeSession({ server: remote, session: { id: "ses_1", directory: "/workspace" }, - update: async () => undefined, + archive: async () => undefined, remove: () => { removed = true }, @@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => { await archiveHomeSession({ server: remote, session: { id: "ses_1", directory: "/workspace" }, - update: async () => Promise.reject(failure), + archive: async () => Promise.reject(failure), remove: () => { removed = true }, diff --git a/packages/app/src/pages/home-session-archive.ts b/packages/app/src/pages/home-session-archive.ts index 7e6634ed7ab7..bafca66e72df 100644 --- a/packages/app/src/pages/home-session-archive.ts +++ b/packages/app/src/pages/home-session-archive.ts @@ -6,25 +6,15 @@ type HomeSession = { directory: string } -type SessionUpdate = { - directory: string - sessionID: string - time: { archived: number } -} - export async function archiveHomeSession(input: { server: ServerConnection.Key session: HomeSession - update: (value: SessionUpdate) => Promise + archive: (sessionID: string) => Promise remove: () => void onError?: (error: unknown) => void }) { await input - .update({ - directory: input.session.directory, - sessionID: input.session.id, - time: { archived: Date.now() }, - }) + .archive(input.session.id) .then(() => { input.remove() notifySessionTabsRemoved({ diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 03924ec973d6..da7cad7e1952 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -606,7 +606,7 @@ export function NewHome() { await archiveHomeSession({ server: ServerConnection.key(conn), session, - update: (value) => ctx.sdk.client.session.update(value), + archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }), remove: () => setStore( produce((draft) => { diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index f52b7f4b4223..4ae7827e210a 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -45,7 +45,8 @@ export function createPromptInputController(input: { model: { selection: input.model ?? local.model, paid: providers.paid().length > 0, - loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, + loading: + (local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading, }, session: { id: input.sessionID(), 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 45f5e4cb26ff..f54e0c9e4f5d 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( setStore("responding", perm.id) sdk() - .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) 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 445a9f47a082..941424e247cf 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 275e6ec4bc22..12dd96a5e66b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" @@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" +import { useLocal } from "@/context/local" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const dialog = useDialog() const file = useFile() const language = useLanguage() - const local = useLocal() const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() @@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sync = useSync() const terminal = useTerminal() const layout = useLayout() + const local = useLocal() const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) @@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await client.session.abort({ sessionID }).catch(() => {}) + await session.interrupt({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: message.id }), + request: () => session.revert.stage({ sessionID, messageID: message.id }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const messages = userMessages() const promptSession = prompt.capture() @@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.unrevert({ sessionID }), + request: () => session.revert.clear({ sessionID }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), }) @@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: next.id }), + request: () => session.revert.stage({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().client.session.summarize({ + await sdk().api.session.compact({ sessionID, - modelID: model.id, - providerID: model.provider.id, + model: { providerID: model.provider.id, modelID: model.id }, }) } From ff386224f0ba4b9c922ce471070cb489d9043926 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 10:41:16 +0800 Subject: [PATCH 2/4] test(app): fix current session request fixture --- packages/app/src/components/prompt-input/submit.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 6e1bee51cedf..0763aacd0dfc 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -128,8 +128,8 @@ const api = { sessionDirectories[session.id] = input.location.directory return session }, - async shell(input: { sessionID: string }) { - sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main") + async shell(input: { sessionID: string; id?: string; command: string }) { + sentShell.push(input) }, async prompt() {}, async command() {}, @@ -221,7 +221,6 @@ beforeAll(async () => { directory: "/repo/main", api, client: rootClient, - api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) From d732ece9abd42079b296312a8b7ea6f099e43dc2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 10:42:42 +0800 Subject: [PATCH 3/4] test(app): use current request client fixture --- .../components/prompt-input/submit.test.ts | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 0763aacd0dfc..ac0691646451 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -24,7 +24,6 @@ const optimistic: Array<{ }> = [] const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} -const sessionDirectories: Record = {} const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const syncedDirectories: string[] = [] @@ -116,27 +115,6 @@ const clientFor = (directory: string) => { } } -const api = { - session: { - async create(input: { location: { directory: string } }) { - await createSessionGate - createdSessions.push(input.location.directory) - const session = { - id: `session-${createdSessions.length}`, - title: `New session ${createdSessions.length}`, - } - sessionDirectories[session.id] = input.location.directory - return session - }, - async shell(input: { sessionID: string; id?: string; command: string }) { - sentShell.push(input) - }, - async prompt() {}, - async command() {}, - async interrupt() {}, - }, -} - beforeAll(async () => { const rootClient = clientFor("/repo/main") @@ -219,8 +197,8 @@ beforeAll(async () => { const sdk = { scope: "local", directory: "/repo/main", - api, client: rootClient, + api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -324,7 +302,6 @@ beforeEach(() => { createSessionGate = undefined serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] - for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] }) describe("prompt submit worktree selection", () => { From 6577e765544003d64fb4961c642b997509f98db3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 24 Jul 2026 12:30:57 +0800 Subject: [PATCH 4/4] test(app): fix v1 session request e2e --- .../app/e2e/regression/remote-session-settings.spec.ts | 6 +++--- packages/app/e2e/regression/session-request-docks.spec.ts | 8 ++++---- packages/app/e2e/utils/mock-server.ts | 6 ++++++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index c17ae5c1c66e..40491c867ea1 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, @@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, }, { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: childSessionA.id, permissionID: "permission-background-a-child", body: { response: "once" }, diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 6c253f462ac6..cd829ad95ce6 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,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}/question/question-request/reject`) + if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) }) @@ -67,7 +67,7 @@ test("shows a pending question dock", async ({ page }) => { const reply = page.waitForRequest( (request) => request.method() === "POST" && - new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, + new URL(request.url()).pathname === "/question/question-request/reply", ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -100,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) - expect(request.postDataJSON()).toEqual({ reply: "once" }) + expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) + expect(request.postDataJSON()).toEqual({ response: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 78f60bbbca96..84a38771e6ad 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -205,6 +205,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } + if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } if ( /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && route.request().method() === "POST"