From e4078c9a08635c8bb857d4098edbf890c80c8af1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 4 Aug 2026 13:13:34 +0800 Subject: [PATCH 1/3] test(app): migrate e2e fixtures to v2 --- .../performance/timeline-stability/fixture.ts | 41 +- .../e2e/performance/unit/mock-server.test.ts | 119 +++- .../regression/cross-server-tab-close.spec.ts | 6 +- .../remote-session-settings.spec.ts | 64 ++- .../e2e/regression/remote-tab-busy.spec.ts | 6 +- .../e2e/regression/review-open-file.spec.ts | 2 +- .../review-state-persistence.spec.ts | 39 +- .../review-terminal-stacked.spec.ts | 75 +-- .../regression/session-request-docks.spec.ts | 1 - .../session-timeline-collapse-state.spec.ts | 2 +- .../session-timeline-context-resize.spec.ts | 8 +- .../session-timeline-history-root.spec.ts | 49 +- .../session-timeline-lifecycle-state.spec.ts | 2 +- .../session-timeline-projection.spec.ts | 17 +- ...sion-timeline-reasoning-projection.spec.ts | 5 +- ...ession-timeline-reducer-projection.spec.ts | 7 +- .../session-timeline-shell-outline.spec.ts | 1 - .../session-timeline-transport.spec.ts | 6 +- .../session-todo-dock-navigation.spec.ts | 22 +- .../regression/tab-navigate-mousedown.spec.ts | 6 +- .../e2e/regression/terminal-hidden.spec.ts | 1 - .../regression/terminal-tab-switch.spec.ts | 1 - .../app/e2e/smoke/session-timeline.fixture.ts | 27 +- .../app/e2e/smoke/session-timeline.spec.ts | 14 +- .../user-story/model-selection-flow.spec.ts | 38 +- packages/app/e2e/utils/mock-server.ts | 521 ++++++++++++------ packages/app/e2e/utils/sse-transport.ts | 22 +- 27 files changed, 684 insertions(+), 418 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index df67da5a6621..c24b27cf0632 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -4,15 +4,13 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { SessionV1 } from "@opencode-ai/schema/session-v1" import type { AssistantMessage, - GlobalEvent, Message, Part, - Session, - SessionStatus, ToolPart, ToolState, UserMessage, } from "@opencode-ai/sdk/v2/client" +import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise" import { expect, type Page } from "@playwright/test" import { Schema } from "effect" import { mockOpenCodeServer } from "../../utils/mock-server" @@ -27,18 +25,29 @@ export const assistantID = "msg_1001_timeline_assistant" export const title = "Timeline visual stability" export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -type TimelinePayload = Extract< - GlobalEvent["payload"], - { - type: - | "message.updated" - | "message.removed" - | "message.part.updated" - | "message.part.removed" - | "message.part.delta" - | "session.status" +type Session = SessionV1Info +type GlobalEvent = { + directory: string + project?: string + workspace?: string + payload: { + id: string + type: string + properties: Record } -> +} + +type TimelineProperties = { + "message.updated": { sessionID: string; info: Message } + "message.removed": { sessionID: string; messageID: string } + "message.part.updated": { sessionID: string; part: Part; time: number } + "message.part.removed": { sessionID: string; messageID: string; partID: string } + "message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string } + "session.status": { sessionID: string; status: SessionStatus } +} +type TimelinePayload = { + [Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] } +}[keyof TimelineProperties] type DeepReadonly = Value extends readonly unknown[] ? { readonly [Key in keyof Value]: DeepReadonly } @@ -97,7 +106,6 @@ export async function setupTimeline( locale?: string deviceScaleFactor?: number seedHistory?: boolean - protocol?: "v1" | "v2" } = {}, ) { const sessions = input.sessions ?? [session()] @@ -115,7 +123,6 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { - protocol: input.protocol, directory, project: project(), provider: provider(), @@ -235,7 +242,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload } export function validateTimelineEvent(input: unknown): TimelineEvent { - return decodeEvent(input, decodeOptions) + return decodeEvent(input, decodeOptions) as TimelineEvent } export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts index 83308c0a866b..8af12a948cba 100644 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -1,6 +1,121 @@ import { expect, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { Page, Route } from "@playwright/test" -import { mockOpenCodeServer } from "../../utils/mock-server" +import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server" + +test("preserves current messages", () => { + const message = { + id: "msg_current", + type: "user", + time: { created: 1 }, + text: "current", + files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }], + } satisfies SessionMessageInfo + + expect(currentMessage(message)).toBe(message) +}) + +test("converts rich legacy messages to current message types", () => { + expect( + currentMessage({ + info: { id: "msg_user", role: "user", time: { created: 1 } }, + parts: [ + { type: "text", text: "Use @src/a.ts with @explore" }, + { + type: "file", + mime: "application/json", + filename: "data.json", + url: "data:application/json;base64,e30=", + }, + { + type: "file", + mime: "text/plain", + filename: "a.ts", + url: "src/a.ts", + source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } }, + }, + { type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } }, + ], + }), + ).toEqual({ + id: "msg_user", + type: "user", + time: { created: 1 }, + text: "Use @src/a.ts with @explore", + files: [ + { data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } }, + { + data: "", + mime: "text/plain", + name: "a.ts", + source: { type: "uri", uri: "src/a.ts" }, + mention: { text: "@src/a.ts", start: 4, end: 13 }, + }, + ], + agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }], + }) + + expect( + currentMessage({ + info: { + id: "msg_assistant", + role: "assistant", + time: { created: 2, completed: 5 }, + agent: "explore", + modelID: "model", + providerID: "provider", + variant: "high", + cost: 0.5, + tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, + finish: "tool-calls", + error: { name: "MessageAbortedError", data: { message: "Stopped" } }, + }, + parts: [ + { type: "text", text: "Answer" }, + { type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } }, + { + id: "prt_tool", + callID: "call_tool", + type: "tool", + tool: "read", + state: { + status: "completed", + input: { filePath: "src/a.ts" }, + output: "contents", + metadata: { title: "a.ts" }, + time: { start: 3, end: 4 }, + }, + }, + ], + }), + ).toEqual({ + id: "msg_assistant", + type: "assistant", + time: { created: 2, completed: 5 }, + agent: "explore", + model: { id: "model", providerID: "provider", variant: "high" }, + cost: 0.5, + tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, + finish: "tool-calls", + error: { type: "MessageAbortedError", message: "Stopped" }, + content: [ + { type: "text", text: "Answer" }, + { type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } }, + { + type: "tool", + id: "call_tool", + name: "read", + time: { created: 3, ran: 3, completed: 4 }, + state: { + status: "completed", + input: { filePath: "src/a.ts" }, + content: [{ type: "text", text: "contents" }], + metadata: { title: "a.ts" }, + }, + }, + ], + }) +}) test("applies message latency after a list response gate is released", async () => { const events: string[] = [] @@ -30,7 +145,7 @@ test("applies message latency after a list response gate is released", async () }) const response = handler!({ - request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }), + request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }), fulfill: () => { events.push("fulfill") return Promise.resolve() diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index f09a2c7b63ae..e6f3481f16e8 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -85,21 +85,17 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + if (url.pathname === "/api/event") return sse(route) - if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) - if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 35a0aa44cda8..3e6d0791a999 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { installSseTransport } from "../utils/sse-transport" import { currentSession } from "../utils/mock-server" -const serverA = "http://127.0.0.1:4096" +const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const serverB = "http://127.0.0.1:4097" const directoryA = "C:/server-a" const directoryB = "/home/server-b" @@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => { .poll(() => permissionRequests.some((request) => { const url = new URL(request) - return url.origin === serverB && url.searchParams.get("directory") === directoryB + return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB }), ) .toBe(true) @@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .poll(() => permissionRequests.some((request) => { const url = new URL(request) - return url.origin === serverA && url.searchParams.get("directory") === directoryA + return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA }), ) .toBe(true) @@ -99,10 +99,10 @@ 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" }, + body: { reply: "once" }, }, ]) @@ -127,17 +127,17 @@ 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" }, + body: { reply: "once" }, }, { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: childSessionA.id, permissionID: "permission-background-a-child", - body: { response: "once" }, + body: { reply: "once" }, }, ]) }) @@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR const remote = url.origin === serverB const directory = remote ? directoryB : directoryA const sessions = remote ? [sessionB] : [sessionA, childSessionA] - const requestDirectory = url.searchParams.get("directory") - const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + const requestDirectory = url.searchParams.get("location[directory]") + const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/) if (route.request().method() === "POST" && response) { permissionResponses.push({ origin: url.origin, @@ -181,13 +181,21 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR return json(route, true) } if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + if (url.pathname === "/api/event") return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") - return json(route, { data: [] }) - if (url.pathname === "/api/model/default") return json(route, { data: null }) - if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + if (url.pathname === "/api/provider") + return json(route, { + location: { directory }, + data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }], + }) + if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] }) + if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) }) + if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/permission/request") { + permissionRequests.push(url.toString()) + return json(route, { location: { directory }, data: [] }) + } + if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname)) return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp/resource") @@ -211,8 +219,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) return json(route, { data: [], cursor: {} }) - const current = sessions.find((session) => url.pathname === `/session/${session.id}`) - if (current) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) @@ -222,7 +228,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR } if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/project" || url.pathname === "/project/current") { @@ -288,6 +293,25 @@ function provider(id: string) { } } +function model(remote: boolean) { + const id = remote ? "server-b" : "server-a" + const name = remote ? "Server B" : "Server A" + return { + id, + modelID: id, + providerID: id, + name: `${name} Model`, + family: id, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: Date.now() }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 200_000, output: 32_000 }, + } +} + function json(route: Route, body: unknown, status = 200) { return route.fulfill({ status, diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 2d9b1e234971..43c3365102f9 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -58,22 +58,18 @@ async function mockServers(page: Page) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event") - if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session/active") return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) - if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 04e6d2cced83..7cc7b32f0ada 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -127,7 +127,7 @@ test("opens and searches project files inline", async ({ page }) => { await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() - expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) + expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 0d6756201e7e..878eb84e372a 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -19,36 +19,25 @@ test("restores review mode and selected file per session", async ({ page }) => { await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() - await selectMode(page, "Git changes", "Branch changes") - await selectFile(page, "beta.ts") + await selectFile(page, "alpha.ts") await switchSession(page, titleB) await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await selectFile(page, "gamma.ts") await switchSession(page, titleA) - await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() - await expectSelectedFile(page, "beta.ts") - await selectMode(page, "Branch changes", "Git changes") await expectSelectedFile(page, "alpha.ts") - await selectMode(page, "Git changes", "Branch changes") - await expectSelectedFile(page, "beta.ts") await page.reload() await expectSessionTitle(page, titleA) - await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() - await expectSelectedFile(page, "beta.ts") + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await expectSelectedFile(page, "alpha.ts") await switchSession(page, titleB) await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await expectSelectedFile(page, "gamma.ts") }) -async function selectMode(page: Page, current: string, next: string) { - await page.getByRole("button", { name: current }).click() - await page.getByRole("option", { name: next }).dispatchEvent("click") -} - async function selectFile(page: Page, file: string) { await page.getByRole("button", { name: file }).click() await expectSelectedFile(page, file) @@ -65,7 +54,6 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { - protocol: "v1", directory, project: { id: projectID, @@ -89,22 +77,27 @@ async function setup(page: Page) { sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], pageMessages: () => ({ items: [] }), }) - await page.route(/\/vcs(?:\?.*)?$/, (route) => + await page.route(/\/api\/vcs(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: { branch: "feature", defaultBranch: "dev" }, + }), }), ) - await page.route("**/vcs/diff**", (route) => + await page.route("**/api/vcs/diff**", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - new URL(route.request().url()).searchParams.get("mode") === "branch" - ? [diff("src/alpha.ts"), diff("src/beta.ts")] - : [diff("src/alpha.ts"), diff("src/gamma.ts")], - ), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + }), }), ) await page.addInitScript( diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 79b564820e20..72d92188c56b 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -25,7 +25,6 @@ test("keeps the review tree and terminal sized when both panels are open", async let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v1", directory, project: { id: projectID, @@ -62,33 +61,32 @@ test("keeps the review tree and terminal sized when both panels are open", async events: () => events.splice(0, 1), eventRetry: 16, }) - await page.route(/\/vcs(?:\?.*)?$/, (route) => + await page.route(/\/api\/vcs(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ - branch: "review-pane-performance", - default_branch: "dev", + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: { branch: "review-pane-performance", defaultBranch: "dev" }, }), }), ) - await page.route("**/vcs/diff**", (route) => { + await page.route("**/api/vcs/diff**", (route) => { const url = new URL(route.request().url()) - const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/") const detail = scope?.endsWith("/src/branch/d00027") if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - url.searchParams.get("mode") === "branch" - ? detail - ? branchDiffs - .filter((diff) => diff.file.startsWith("src/branch/d00027/")) - .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) - : branchDiffs - : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), - ), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs, + }), }) }) await page.route("**/pty*", (route) => @@ -109,7 +107,7 @@ test("keeps the review tree and terminal sized when both panels are open", async }), }), ) - await page.route("**/pty/pty_review_terminal*", (route) => + await page.route("**/api/pty/pty_review_terminal*", (route) => route.fulfill({ status: 200, contentType: "application/json", @@ -127,7 +125,7 @@ test("keeps the review tree and terminal sized when both panels are open", async }), }), ) - await page.route("**/pty/pty_review_terminal/connect-token*", (route) => + await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) => route.fulfill({ status: 200, contentType: "application/json", @@ -137,7 +135,7 @@ test("keeps the review tree and terminal sized when both panels are open", async }), }), ) - await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem( @@ -149,9 +147,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) await expect(page.locator("#review-panel")).toBeVisible() - await expectTree(page, 8, "git-0.ts") - - await selectMode(page, "Git changes", "Branch changes") + await expectTree(page, 2_773, "action.yml") await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() @@ -174,9 +170,9 @@ test("keeps the review tree and terminal sized when both panels are open", async expect(bottomGap).toBeLessThanOrEqual(16) const lazyDiff = page.waitForRequest((request) => { const url = new URL(request.url()) - return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + return ( + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) await lastFile.click() @@ -190,59 +186,46 @@ test("keeps the review tree and terminal sized when both panels are open", async const refreshedDiff = page.waitForRequest((request) => { const url = new URL(request.url()) return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) sessionStatus[sessionID] = { type: "idle" } events.push(statusEvent("idle")) await refreshedDiff await expect(preview).toContainText("after-2") - await selectMode(page, "Branch changes", "Git changes") - await expectTree(page, 8, "git-0.ts") - await page.getByRole("button", { name: "git-0.ts" }).click() - await selectMode(page, "Git changes", "Branch changes") - await expectTree(page, 2_773, "action.yml") - const filter = page.getByRole("searchbox", { name: "Filter files" }) await filter.fill("generated-2738") await expectTree(page, 1, "generated-2738.ts") await filter.fill("") - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.getByRole("button", { name: "Toggle file tree" }).click() await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) await page.getByRole("button", { name: "Toggle file tree" }).click() - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toHaveCount(0) - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.getByRole("button", { name: "Toggle review" }).click() await expect(page.locator("#review-panel")).toHaveCount(0) await page.getByRole("button", { name: "Toggle review" }).click() - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.setViewportSize({ width: 1_000, height: 700 }) - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await expectStackGeometry(page) await page.setViewportSize({ width: 1_000, height: 120 }) await page.setViewportSize({ width: 1_400, height: 900 }) - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await expectStackGeometry(page) }) -async function selectMode(page: Page, current: string, next: string) { - await page.getByRole("button", { name: current }).click() - const option = page.getByRole("option", { name: next }) - await expect(option).toBeVisible() - await option.click() -} - async function expectTree(page: Page, total: number, file: string) { await expectMountedTree(page, total) await expect(page.getByRole("button", { name: file })).toBeVisible() diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 5ea9d4f7613b..6c253f462ac6 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -173,7 +173,6 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 5b6e0b127b17..6023467fddbe 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -52,7 +52,7 @@ const editPart = { sessionID, messageID: assistantMessageID, type: "tool", - callID: "call_edit_regression", + callID: editPartID, tool: "edit", state: { status: "completed", diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a9a4738da928..053999e5e203 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression" const sessionID = "ses_context_resize_regression" const title = "Context resize regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] -const followingTextID = "prt_0104_text" +const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] +const followingTextID = `${id("msg_assistant", 10)}:text:0` type Message = { info: Record & { id: string; role: "user" | "assistant" } @@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" = ), contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), { - id: followingTextID, + id: "prt_0104_text", sessionID, messageID: assistantID, type: "text", @@ -295,7 +295,7 @@ function contextTool( sessionID, messageID, type: "tool", - callID: `call_${partID}`, + callID: partID, tool, state: { status, diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index e5ef7998ea7f..a259628f7665 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -10,7 +10,6 @@ import { status, textPart, title, - userID, userMessage, } from "../performance/timeline-stability/fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 const historyPageSize = 200 -const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => - assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { - id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, - parentID: userID, - created: 1700000001000 + index * 1_000, - completed: index < initialPageSize, - }), -) -const messages = [userMessage(), ...assistants] +const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => { + const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` + return [ + userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: id, + created: 1700000001000 + index * 2_000, + completed: index < initialPageSize, + }), + ] +}).flat() +const assistants = messages.filter((message) => message.info.role === "assistant") const lastAssistant = assistants.at(-1)! -const lastPartID = assistants.at(-1)!.parts[0]!.id -const userPartID = `prt_${userID}_text` +const lastPartID = `${assistants.at(-1)!.info.id}:text:0` +const userPartID = `${messages.at(-2)!.info.id}:text:0` const completed = { ...lastAssistant.info, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, @@ -154,15 +157,23 @@ for (const scenario of scenarios) { await expectSessionTitle(page, title) await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() - await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) + const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await viewport.hover() + const deadline = Date.now() + 10_000 + while (requests.filter((request) => request.phase === "start").length < 2) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) - expect(sequence.slice(0, 4)).toEqual([ + expect(sequence.slice(0, 3)).toEqual([ "messages:start:latest", "messages:end:latest", - `message:${userID}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) + await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount( + initialPageSize / 2, + ) await page.evaluate(() => { ;( window as Window & { @@ -174,7 +185,9 @@ for (const scenario of scenarios) { expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect + .poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count()) + .toBeGreaterThan(initialPageSize / 2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) @@ -182,7 +195,7 @@ for (const scenario of scenarios) { { before: undefined, limit: initialPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, ]) - expect(roots).toEqual([{ sessionID, messageID: userID }]) + expect(roots).toEqual([]) const message = messageUpdated(scenario.info) const idle = status("idle") diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index b303071c87f7..1d83ea810841 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag await timeline.send(status("idle"), 350) await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response") }) function lines(count: number) { diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index b1aabcc32ca9..09d132b581e7 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -89,7 +89,6 @@ test.describe("session timeline projection", () => { const aborted = assistantMessage( [ { id: "prt_before_abort", type: "text", text: "Before interruption" }, - { id: "prt_compaction", type: "compaction", auto: true }, ], { id: "msg_1001_assistant_aborted", @@ -122,13 +121,13 @@ test.describe("session timeline projection", () => { await scroller.evaluate((element) => (element.scrollTop = 0)) await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1) - await expect(page.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(page.getByText("Before interruption", { exact: true })).toBeVisible() await expect(page.getByText("Visible provider failure")).toBeVisible() await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() }) - test("renders comment strips and historical diff summary overflow", async ({ page }) => { + test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => { const user = userMessage( [ userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { @@ -159,10 +158,14 @@ test.describe("session timeline projection", () => { const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) await scroller.evaluate((element) => (element.scrollTop = 0)) - await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible() - await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible() - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() - await expect(page.getByText(/show all/i)).toBeVisible() + await expect( + page.getByText( + "The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment", + { exact: true }, + ), + ).toBeVisible() + await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) }) test("renders interruption independently when the turn is not compacted", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index 7c0864e5845b..387b713069e2 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test" import { + assistantID, assistantMessage, reasoningPart, setupTimeline, @@ -70,7 +71,7 @@ for (const profile of profiles) { await timeline.send(status("busy"), 150) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) + await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0) if (!profile.summaries && profile.reasoning.trim()) { await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() } @@ -89,5 +90,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible() }) diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index ad35eef601cb..5db1051686e0 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries", ] await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() + await expect( + page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'), + ).toBeVisible() await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) + await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4) }) test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts index 54139cc3711b..4491515b15b3 100644 --- a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -145,7 +145,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async }), ], }) - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 778ff3a3af94..681e1bcd6642 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => { }) test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) + const timeline = await setupTimeline(page, { eventRetry: 10 }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -107,10 +107,10 @@ test("passes through non-event fetches", async ({ page }) => { const timeline = await setupTimeline(page) const health = await page.evaluate(async () => { - const response = await fetch("/global/health") + const response = await fetch("/api/health") return response.json() }) - expect(health).toEqual({ healthy: true }) + expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 }) expect(await timeline.transport.connections()).toHaveLength(1) }) diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 55e71212753c..c0fa157c6eb4 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -23,7 +23,7 @@ type EventPayload = { test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) -test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { +test("animates todo opening without replaying it across session tabs", async ({ page }) => { test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } @@ -86,28 +86,8 @@ test("animates todo lifecycle without replaying it across session tabs", async ( await switchSession(page, otherID, otherTitle) await expect(dock).toHaveCount(0) - const returningOpen = sampleDock(page, 700) - await switchSession(page, sourceID, sourceTitle) - const openSamples = (await returningOpen).filter((sample) => sample.present) - expect(openSamples.length).toBeGreaterThan(0) - expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) - expect(openSamples[0]!.height).toBeGreaterThan(70) - await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) - - const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) - const closing = sampleDock(page, 1_000) - todos[sourceID] = completedTodos - events.push(todoEvent(sourceID, completedTodos)) - await expect(dock).toHaveCount(0) - expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) - todos[sourceID] = [] - events.push(todoEvent(sourceID, [])) - - await switchSession(page, otherID, otherTitle) - const returningEmpty = sampleDock(page, 700) await switchSession(page, sourceID, sourceTitle) await expect(dock).toHaveCount(0) - expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) }) function session(id: string, title: string, created: number) { diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index b969b590d89b..8caaaca9645a 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -89,23 +89,19 @@ async function mockServer(page: Page) { if (url.origin !== server) return route.fallback() if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) return new Promise(() => {}) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + if (url.pathname === "/api/event") return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) return json(route, { data: [], cursor: {} }) - const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) - if (byId) return json(route, byId) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 8e08d60ff2ac..633dcb485ecc 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -10,7 +10,6 @@ const title = "Hidden terminal regression" test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 165920753cb2..5b8d53affaa7 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -66,7 +66,6 @@ async function readProbe(page: Page) { async function setup(page: Page) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 3dce37cafd9d..939c8bd3f30f 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,7 +21,7 @@ const words = [ "vector", ] -const serverKey = "http://127.0.0.1:4096" +const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -134,7 +134,7 @@ function toolPart( return { id: id(`prt_tool_${tool}_${partIndex}`, index), type: "tool", - callID: id("call", index * 10 + partIndex), + callID: id("call", index * 100 + partIndex), tool, state: { status: "completed", @@ -235,8 +235,17 @@ function renderable(part: MessagePart) { return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" } -function orderedParts(message: Message) { - return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) +function currentPartIDs(message: Message) { + const ordinals = { text: 0, reasoning: 0 } + return message.parts + .flatMap((part) => { + if (!renderable(part)) return [] + if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`] + if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`] + if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id] + return [] + }) + .sort() } export const fixture = { @@ -290,12 +299,10 @@ export const fixture = { targetMessageIDs: targetMessages .filter((message) => message.info.role === "user") .map((message) => message.info.id), - targetPartIDs: targetMessages.flatMap((message) => - orderedParts(message) - .filter(renderable) - .map((part) => part.id), - ), - expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, + targetPartIDs: targetMessages.flatMap(currentPartIDs), + expandedShellPartID: targetMessages + .flatMap((message) => message.parts) + .find((part) => part.tool === "bash")!.callID, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index bdf3f55bdc10..1c3375ac4eff 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -125,20 +125,19 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ dirBase64, sourceID, targetID }) => { + ({ server, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server: "http://127.0.0.1:4096", - dirBase64, + server, sessionId, })), ), ) }, - { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) @@ -251,20 +250,19 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ dirBase64, sourceID, targetID }) => { + ({ server, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server: "http://127.0.0.1:4096", - dirBase64, + server, sessionId, })), ), ) }, - { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) await expectSessionTitle(page, fixture.expected.sourceTitle) diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index 22b8bb41fe25..c92e6bd4f457 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -4,11 +4,7 @@ import { expectAppVisible } from "../utils/waits" const directory = "C:/OpenCode/NewProject" -test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { - let connectedGo = false - let pendingGo = false - const connections: Array<{ integrationID: string; body: unknown }> = [] - +test("creates a session in a new project and selects its model", async ({ page }) => { await mockOpenCodeServer(page, { directory, project: { @@ -46,17 +42,9 @@ test("creates a session in a new project, connects OpenCode Go, and selects its }, }, ], - connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], + connected: ["opencode", "opencode-go"], default: { providerID: "opencode", modelID: "free-model" }, }), - integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, - onConnectKey: (input) => { - connections.push(input) - if (input.integrationID === "opencode-go") pendingGo = true - }, - onInstanceDispose: () => { - if (pendingGo) connectedGo = true - }, sessions: [], pageMessages: () => ({ items: [] }), fileList: (path) => @@ -66,6 +54,17 @@ test("creates a session in a new project, connects OpenCode Go, and selects its await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ + user: [ + { providerID: "opencode", modelID: "free-model", visibility: "show" }, + { providerID: "opencode-go", modelID: "go-model-1", visibility: "show" }, + ], + recent: [], + variant: {}, + }), + ) }) await page.goto("/") @@ -79,16 +78,7 @@ test("creates a session in a new project, connects OpenCode Go, and selects its const modelControl = page.locator('[data-action="prompt-model"]') await modelControl.click() - await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") - - await page.locator('[data-provider-id="opencode-go"]').click() - await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") - await page.locator('[data-action="provider-connect-submit"]').click() - await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) - expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }]) - - await expect(modelControl).toHaveAttribute("data-control-type", "popover") - await modelControl.click() + await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible() const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') await expect(goModel).toBeVisible() await goModel.click() diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 76987421b607..4cf374830550 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,14 +1,17 @@ import type { Page, Route } from "@playwright/test" - -const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) +import type { + JsonValue, + PromptAgentAttachment, + PromptFileAttachment, + SessionMessageAssistant, + SessionMessageInfo, + SessionStructuredError, +} from "@opencode-ai/client/promise" export interface MockServerConfig { - protocol?: "v1" | "v2" provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -33,21 +36,6 @@ export interface MockServerConfig { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 - const staticRoutes: Record = { - "/path": { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }, - "/project": [config.project], - "/project/current": config.project, - "/agent": [{ name: "build", mode: "primary" }], - "/vcs": { branch: "main", default_branch: "main" }, - "/session": config.sessions, - } - await page.route("**/*", async (route) => { const url = new URL(route.request().url()) const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -57,61 +45,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event" || path === "/api/event") { + if (path === "/api/event") { const events = config.events?.() return sse( route, - path === "/api/event" - ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] - : [ - ...(path === "/global/event" - ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] - : []), - ...(events ?? []), - ], + [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])], config.eventRetry, ) } - if (path === "/global/health") - return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) - if (path === "/api/health" && config.protocol === "v2") - return json(route, { healthy: true, version: "2.0.0", pid: 1 }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) - if (path === "/provider") - return json(route, typeof config.provider === "function" ? config.provider() : config.provider) - if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) - const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] - if (legacyAuth && route.request().method() === "PUT") { - config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) - return json(route, true) - } - if (path === "/instance/dispose" && route.request().method() === "POST") { - config.onInstanceDispose?.() - return json(route, true) - } - if (path === "/permission") - 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 === "/session/status") - return json( - route, - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), - ) - if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) - if (path === "/file" && config.fileList) - return json(route, await config.fileList(url.searchParams.get("path") ?? "")) - if (path === "/file/content" && config.fileContent) - return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) - if (path === "/find/file" && config.findFiles) - return json( - route, - await config.findFiles({ - query: url.searchParams.get("query") ?? "", - dirs: url.searchParams.get("dirs") ?? undefined, - limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, - }), - ) + if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) if (path === "/api/reference") return json(route, { location: { @@ -134,7 +76,17 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, ], }) + if (path === "/api/provider") + return json(route, { + location: location(config), + data: currentProviders(providerConfig(config)), + }) + if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) }) + if (path === "/api/model/default") + return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) }) + if (path === "/api/integration") return json(route, { location: location(config), data: [] }) if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/plugin") return json(route, { location: location(config), data: [] }) if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) if (path === "/api/mcp/resource") return json(route, { location: location(config), data: { resources: [], templates: [] } }) @@ -142,25 +94,31 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (integration && route.request().method() === "GET") return json(route, { location: location(config), - data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + data: { + id: integration, + name: integration, + methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }], + connections: [], + }, }) const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] if (integrationConnect && route.request().method() === "POST") { config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) if (path === "/api/project") return json(route, [config.project]) if (path === "/api/project/current") return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) - if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) - if (path === "/api/path") - return json(route, { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }) + if (path === "/api/location") return json(route, location(config)) + const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1] + if (projectCopy && route.request().method() === "POST") { + const input = route.request().postDataJSON() as { directory: string; name?: string } + return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` }) + } + if (projectCopy && route.request().method() === "DELETE") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) if (path === "/api/permission/request") return json(route, { location: location(config), @@ -177,11 +135,43 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/fs/list" && config.fileList) + return json(route, { + location: location(config), + data: await config.fileList(url.searchParams.get("path") ?? ""), + }) + const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1] + if (fileRead && config.fileContent) { + const value = await config.fileContent(decodeURIComponent(fileRead)) + const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "") + return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } }) + } + if (path === "/api/fs/find" && config.findFiles) { + const entries = await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("type") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }) + return json(route, { + location: location(config), + data: Array.isArray(entries) + ? entries.map((entry) => + typeof entry === "string" + ? { + name: entry.split(/[\\/]/).at(-1) ?? entry, + path: entry, + absolute: `${config.directory}/${entry}`, + type: "directory", + ignored: false, + } + : entry, + ) + : entries, + }) + } if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) - if (emptyObject.has(path)) return json(route, {}) - if (emptyList.has(path)) return json(route, []) if (path === "/api/session") { const directory = url.searchParams.get("directory") const parentID = url.searchParams.get("parentID") @@ -208,7 +198,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } if (path === "/api/session/active") { - const statuses = (config.sessionStatus ?? {}) as Record + const statuses = ( + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}) + ) as Record return json(route, { data: Object.fromEntries( Object.entries(statuses).flatMap(([id, status]) => @@ -226,12 +218,6 @@ 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" @@ -241,7 +227,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } - if (path in staticRoutes) return json(route, staticRoutes[path]) const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) if (currentSessionMatch) { @@ -252,28 +237,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } - const sessionMatch = path.match(/^\/session\/([^/]+)$/) - if (sessionMatch) { - const session = config.sessions.find((s) => s.id === sessionMatch[1]) - return json(route, session ?? {}) - } - - const projectMatch = path.match(/^\/project\/([^/]+)$/) - if (projectMatch) return json(route, config.project) - - const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) - if (messageMatch) { - config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) + const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/) + if (currentMessageMatch) { + config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! }) if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!) if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, message) + return json(route, { data: currentMessage(message) }) } - const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) - if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) - if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) if (currentMessagesMatch) { const token = url.searchParams.get("cursor") ?? undefined @@ -292,23 +264,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) - if (messagesMatch) { - const token = url.searchParams.get("before") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const limit = Number(url.searchParams.get("limit") ?? 80) - const pageData = config.pageMessages(messagesMatch[1], limit, before) - config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) - if (!pageData.cursor) return json(route, pageData.items) - const cursor = `cursor_${++nextCursor}` - cursors.set(cursor, pageData.cursor) - return json(route, pageData.items, { "x-next-cursor": cursor }) - } - if (url.port === targetPort && targetPort !== appPort) return json(route, {}) return route.fallback() }) @@ -317,10 +272,75 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { function location(config: MockServerConfig) { return { directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, + project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory }, } } +function providerConfig(config: MockServerConfig) { + return typeof config.provider === "function" ? config.provider() : config.provider +} + +function currentProviders(value: unknown) { + if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : [] + return value.all.filter(record).flatMap((provider) => + typeof provider.id === "string" && typeof provider.name === "string" + ? [{ id: provider.id, name: provider.name, package: provider.id }] + : [], + ) +} + +function currentModels(value: unknown) { + if (!record(value) || !Array.isArray(value.all)) return [] + return value.all.filter(record).flatMap((provider) => { + if (typeof provider.id !== "string" || !record(provider.models)) return [] + return Object.values(provider.models) + .filter(record) + .flatMap((model) => { + if (typeof model.id !== "string" || typeof model.name !== "string") return [] + const limit = record(model.limit) ? model.limit : {} + const cost = record(model.cost) ? model.cost : {} + return [ + { + id: model.id, + modelID: model.id, + providerID: provider.id, + name: model.name, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: record(model.variants) + ? Object.entries(model.variants).map(([id, settings]) => ({ + id, + ...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}), + })) + : [], + time: { released: Date.now() }, + cost: [ + { + input: typeof cost.input === "number" ? cost.input : 0, + output: typeof cost.output === "number" ? cost.output : 0, + cache: { read: 0, write: 0 }, + }, + ], + status: "active", + enabled: true, + limit: { + context: typeof limit.context === "number" ? limit.context : 200_000, + output: typeof limit.output === "number" ? limit.output : 32_000, + }, + }, + ] + }) + }) +} + +function currentDefaultModel(value: unknown) { + if (!record(value) || !record(value.default)) return null + const selected = value.default + const models = currentModels(value) + return models.find( + (model) => model.providerID === selected.providerID && model.id === selected.modelID, + ) ?? null +} + function currentPermission(value: unknown) { const permission = value as Record if (permission.action) return permission @@ -364,65 +384,224 @@ export function currentSession(session: { id: string } & Record } } -function currentMessage(value: unknown) { - const item = value as { - info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } - parts: Array & { type: string }> +export function currentMessage(value: unknown): SessionMessageInfo { + if (isCurrentMessage(value)) return value + if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture") + + const info = value.info + const parts = value.parts.filter(record) + if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number") + throw new Error("Invalid legacy message fixture") + + const time = { + created: info.time.created, + ...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}), } - if (item.info.role === "user") { + if (info.role === "user") { return { - id: item.info.id, + id: info.id, type: "user", - time: item.info.time, - text: item.parts + time: { created: time.created }, + text: parts .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) .join("\n"), + files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])), + agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])), } } + if (info.role !== "assistant") throw new Error("Invalid legacy message role") + return { - id: item.info.id, + id: info.id, type: "assistant", - time: item.info.time, - agent: item.info.agent ?? "build", - model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, - cost: item.info.cost, - tokens: item.info.tokens, - error: item.info.error, - content: item.parts.flatMap((part) => { - if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] - if (part.type !== "tool") return [] - const state = part.state as Record - return [ - { - type: "tool", - id: part.id, - name: part.tool, - time: state.time ?? { created: item.info.time.created }, - state: - state.status === "pending" - ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } - : state.status === "completed" - ? { - status: "completed", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [{ type: "text", text: state.output ?? "" }], - } - : state.status === "error" - ? { - status: "error", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [], - error: { type: "ToolError", message: state.error ?? "Tool failed" }, - } - : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + time, + agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build", + model: { + id: typeof info.modelID === "string" ? info.modelID : "model", + providerID: typeof info.providerID === "string" ? info.providerID : "provider", + ...(typeof info.variant === "string" ? { variant: info.variant } : {}), + }, + content: parts.flatMap((part) => legacyAssistantContent(part, time.created)), + ...(typeof info.cost === "number" ? { cost: info.cost } : {}), + ...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}), + ...(structuredError(info.error) ? { error: structuredError(info.error) } : {}), + ...(finish(info.finish) ? { finish: finish(info.finish) } : {}), + } +} + +function isCurrentMessage(value: unknown): value is SessionMessageInfo { + return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info) +} + +function legacyFile(part: Record): PromptFileAttachment[] { + if (typeof part.mime !== "string" || typeof part.url !== "string") return [] + const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "" + const source = record(part.source) ? part.source : undefined + const sourceText = source && record(source.text) ? source.text : undefined + const mention = mentionFrom(sourceText) + const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url + return [ + { + data, + mime: part.mime, + source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri }, + ...(typeof part.filename === "string" ? { name: part.filename } : {}), + ...(mention ? { mention } : {}), + }, + ] +} + +function legacyAgent(part: Record): PromptAgentAttachment[] { + if (typeof part.name !== "string") return [] + const mention = mentionFrom(record(part.source) ? part.source : undefined) + return [{ name: part.name, ...(mention ? { mention } : {}) }] +} + +function mentionFrom(value: Record | undefined) { + if ( + !value || + typeof value.value !== "string" || + typeof value.start !== "number" || + typeof value.end !== "number" + ) + return + return { text: value.value, start: value.start, end: value.end } +} + +function legacyAssistantContent( + part: Record, + created: number, +): SessionMessageAssistant["content"] { + if (part.type === "text" && typeof part.text === "string") + return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }] + if (part.type === "reasoning" && typeof part.text === "string") { + const time = record(part.time) ? part.time : undefined + return [ + { + type: "reasoning", + text: part.text, + ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}), + ...(time && typeof time.start === "number" + ? { + time: { + created: time.start, + ...(typeof time.end === "number" ? { completed: time.end } : {}), + }, + } + : {}), + }, + ] + } + if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state)) + return [] + + const state = part.state + const time = record(state.time) ? state.time : undefined + const toolTime = { + created: time && typeof time.start === "number" ? time.start : created, + ...(time && typeof time.start === "number" ? { ran: time.start } : {}), + ...(time && typeof time.end === "number" ? { completed: time.end } : {}), + } + const input = jsonRecord(state.input) ?? {} + const metadata = jsonRecord(state.metadata) + const base = { + type: "tool" as const, + id: typeof part.callID === "string" ? part.callID : part.id, + name: part.tool, + time: toolTime, + ...(typeof part.executed === "boolean" ? { executed: part.executed } : {}), + ...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}), + ...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}), + } + if (state.status === "pending") + return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }] + if (state.status === "completed") + return [ + { + ...base, + state: { + status: "completed", + input, + content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }], + ...(metadata ? { metadata } : {}), }, - ] - }), + }, + ] + if (state.status === "error") + return [ + { + ...base, + state: { + status: "error", + input, + error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" }, + ...(metadata ? { metadata } : {}), + }, + }, + ] + return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }] +} + +function structuredError(value: unknown): SessionStructuredError | undefined { + if (typeof value === "string") return { type: "Error", message: value } + if (!record(value)) return + if (typeof value.type === "string" && typeof value.message === "string") + return { type: value.type, message: value.message } + if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return + return { type: value.name, message: value.data.message } +} + +function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined { + if (!record(value) || !record(value.cache)) return + if ( + typeof value.input !== "number" || + typeof value.output !== "number" || + typeof value.reasoning !== "number" || + typeof value.cache.read !== "number" || + typeof value.cache.write !== "number" + ) + return + return { + input: value.input, + output: value.output, + reasoning: value.reasoning, + cache: { read: value.cache.read, write: value.cache.write }, } } +function finish(value: unknown): SessionMessageAssistant["finish"] | undefined { + if ( + value === "stop" || + value === "length" || + value === "tool-calls" || + value === "content-filter" || + value === "error" || + value === "unknown" + ) + return value +} + +function jsonRecord(value: unknown): Record | undefined { + if (!record(value)) return + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + const next = jsonValue(item) + return next === undefined ? [] : [[key, next]] + }), + ) +} + +function jsonValue(value: unknown): JsonValue | undefined { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null) + return jsonRecord(value) +} + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index b0e3b74c6d9a..a245082bc715 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" | "/api/event" + path: "/api/event" headers: Record openedAt: number endedAt?: number @@ -174,10 +174,7 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if ( - url.origin !== server || - (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") - ) + if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request) const id = ++nextConnectionID @@ -193,18 +190,9 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - if (url.pathname === "/api/event") - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) - if (url.pathname === "/global/event") - controller.enqueue( - encoder.encode( - frame({ - payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, - }), - ), - ) + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) request.signal.addEventListener( "abort", () => { From f3e91f5873c5f53097bb7e265d4056d7fbfbe6e9 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 4 Aug 2026 14:58:11 +0800 Subject: [PATCH 2/3] test(app): keep e2e mocks stack-compatible --- .../performance/timeline-stability/fixture.ts | 3 +- .../e2e/regression/review-open-file.spec.ts | 1 + .../review-state-persistence.spec.ts | 1 + .../review-terminal-stacked.spec.ts | 1 + .../regression/session-request-docks.spec.ts | 1 + .../session-timeline-context-resize.spec.ts | 8 +- .../session-timeline-history-root.spec.ts | 49 +++----- .../session-todo-dock-navigation.spec.ts | 22 +++- .../e2e/regression/terminal-hidden.spec.ts | 1 + .../regression/terminal-tab-switch.spec.ts | 1 + .../app/e2e/smoke/session-timeline.fixture.ts | 27 ++--- .../app/e2e/smoke/session-timeline.spec.ts | 14 ++- .../user-story/model-selection-flow.spec.ts | 1 + packages/app/e2e/utils/mock-server.ts | 111 +++++++++++++++++- packages/app/e2e/utils/sse-transport.ts | 22 +++- 15 files changed, 195 insertions(+), 68 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index c24b27cf0632..515338196e22 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -123,6 +123,7 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: project(), provider: provider(), @@ -467,7 +468,7 @@ export function toolPart( input: Record, options: ToolOptions = {}, ): Omit { - const base = { id, type: "tool" as const, callID: `call_${id}`, tool } + const base = { id, type: "tool" as const, callID: id, tool } if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } if (state === "running") return { diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 7cc7b32f0ada..ccdfcef04076 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -14,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } }) test("opens and searches project files inline", async ({ page }) => { const searches: { query: string; dirs?: string; limit?: number }[] = [] await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 878eb84e372a..b91ba13a9e4a 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -54,6 +54,7 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 72d92188c56b..2ccae4a8edd0 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -25,6 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 6c253f462ac6..5ea9d4f7613b 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -173,6 +173,7 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index 053999e5e203..a9a4738da928 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression" const sessionID = "ses_context_resize_regression" const title = "Context resize regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] -const followingTextID = `${id("msg_assistant", 10)}:text:0` +const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] +const followingTextID = "prt_0104_text" type Message = { info: Record & { id: string; role: "user" | "assistant" } @@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" = ), contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), { - id: "prt_0104_text", + id: followingTextID, sessionID, messageID: assistantID, type: "text", @@ -295,7 +295,7 @@ function contextTool( sessionID, messageID, type: "tool", - callID: partID, + callID: `call_${partID}`, tool, state: { status, diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index a259628f7665..e5ef7998ea7f 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -10,6 +10,7 @@ import { status, textPart, title, + userID, userMessage, } from "../performance/timeline-stability/fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -18,22 +19,18 @@ import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 const historyPageSize = 200 -const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => { - const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` - return [ - userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), - assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { - id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, - parentID: id, - created: 1700000001000 + index * 2_000, - completed: index < initialPageSize, - }), - ] -}).flat() -const assistants = messages.filter((message) => message.info.role === "assistant") +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: userID, + created: 1700000001000 + index * 1_000, + completed: index < initialPageSize, + }), +) +const messages = [userMessage(), ...assistants] const lastAssistant = assistants.at(-1)! -const lastPartID = `${assistants.at(-1)!.info.id}:text:0` -const userPartID = `${messages.at(-2)!.info.id}:text:0` +const lastPartID = assistants.at(-1)!.parts[0]!.id +const userPartID = `prt_${userID}_text` const completed = { ...lastAssistant.info, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, @@ -157,23 +154,15 @@ for (const scenario of scenarios) { await expectSessionTitle(page, title) await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() - const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await viewport.hover() - const deadline = Date.now() + 10_000 - while (requests.filter((request) => request.phase === "start").length < 2) { - if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") - await page.mouse.wheel(0, -240) - await page.waitForTimeout(20) - } + await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) - expect(sequence.slice(0, 3)).toEqual([ + expect(sequence.slice(0, 4)).toEqual([ "messages:start:latest", "messages:end:latest", + `message:${userID}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) - await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount( - initialPageSize / 2, - ) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { @@ -185,9 +174,7 @@ for (const scenario of scenarios) { expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect - .poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count()) - .toBeGreaterThan(initialPageSize / 2) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) @@ -195,7 +182,7 @@ for (const scenario of scenarios) { { before: undefined, limit: initialPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, ]) - expect(roots).toEqual([]) + expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) const idle = status("idle") diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index c0fa157c6eb4..55e71212753c 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -23,7 +23,7 @@ type EventPayload = { test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) -test("animates todo opening without replaying it across session tabs", async ({ page }) => { +test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } @@ -86,8 +86,28 @@ test("animates todo opening without replaying it across session tabs", async ({ await switchSession(page, otherID, otherTitle) await expect(dock).toHaveCount(0) + const returningOpen = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + const openSamples = (await returningOpen).filter((sample) => sample.present) + expect(openSamples.length).toBeGreaterThan(0) + expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) + expect(openSamples[0]!.height).toBeGreaterThan(70) + await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) + + const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) + const closing = sampleDock(page, 1_000) + todos[sourceID] = completedTodos + events.push(todoEvent(sourceID, completedTodos)) + await expect(dock).toHaveCount(0) + expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + todos[sourceID] = [] + events.push(todoEvent(sourceID, [])) + + await switchSession(page, otherID, otherTitle) + const returningEmpty = sampleDock(page, 700) await switchSession(page, sourceID, sourceTitle) await expect(dock).toHaveCount(0) + expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) }) function session(id: string, title: string, created: number) { diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 633dcb485ecc..8e08d60ff2ac 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -10,6 +10,7 @@ const title = "Hidden terminal regression" test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 5b8d53affaa7..165920753cb2 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -66,6 +66,7 @@ async function readProbe(page: Page) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 939c8bd3f30f..3dce37cafd9d 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,7 +21,7 @@ const words = [ "vector", ] -const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const serverKey = "http://127.0.0.1:4096" const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -134,7 +134,7 @@ function toolPart( return { id: id(`prt_tool_${tool}_${partIndex}`, index), type: "tool", - callID: id("call", index * 100 + partIndex), + callID: id("call", index * 10 + partIndex), tool, state: { status: "completed", @@ -235,17 +235,8 @@ function renderable(part: MessagePart) { return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" } -function currentPartIDs(message: Message) { - const ordinals = { text: 0, reasoning: 0 } - return message.parts - .flatMap((part) => { - if (!renderable(part)) return [] - if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`] - if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`] - if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id] - return [] - }) - .sort() +function orderedParts(message: Message) { + return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) } export const fixture = { @@ -299,10 +290,12 @@ export const fixture = { targetMessageIDs: targetMessages .filter((message) => message.info.role === "user") .map((message) => message.info.id), - targetPartIDs: targetMessages.flatMap(currentPartIDs), - expandedShellPartID: targetMessages - .flatMap((message) => message.parts) - .find((part) => part.tool === "bash")!.callID, + targetPartIDs: targetMessages.flatMap((message) => + orderedParts(message) + .filter(renderable) + .map((part) => part.id), + ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 1c3375ac4eff..bdf3f55bdc10 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -125,19 +125,20 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ server, sourceID, targetID }) => { + ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server, + server: "http://127.0.0.1:4096", + dirBase64, sessionId, })), ), ) }, - { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) @@ -250,19 +251,20 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ server, sourceID, targetID }) => { + ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server, + server: "http://127.0.0.1:4096", + dirBase64, sessionId, })), ), ) }, - { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) await expectSessionTitle(page, fixture.expected.sourceTitle) diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index c92e6bd4f457..b8b379d05c18 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -6,6 +6,7 @@ const directory = "C:/OpenCode/NewProject" test("creates a session in a new project and selects its model", async ({ page }) => { await mockOpenCodeServer(page, { + protocol: "v1", directory, project: { id: "proj_model_selection_flow", diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 4cf374830550..2cd7d8defd18 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -8,10 +8,15 @@ import type { SessionStructuredError, } from "@opencode-ai/client/promise" +const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) + export interface MockServerConfig { + protocol?: "v1" | "v2" provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -36,6 +41,20 @@ export interface MockServerConfig { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 + const staticRoutes: Record = { + "/path": { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }, + "/project": [config.project], + "/project/current": config.project, + "/agent": [{ name: "build", mode: "primary" }], + "/vcs": { branch: "main", default_branch: "main" }, + "/session": config.sessions, + } await page.route("**/*", async (route) => { const url = new URL(route.request().url()) const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -45,15 +64,60 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/api/event") { + if (path === "/global/event" || path === "/event" || path === "/api/event") { const events = config.events?.() return sse( route, - [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])], + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : [ + ...(path === "/global/event" + ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] + : []), + ...(events ?? []), + ], config.eventRetry, ) } - if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) + if (path === "/provider") return json(route, providerConfig(config)) + if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) + const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] + if (legacyAuth && route.request().method() === "PUT") { + config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) + return json(route, true) + } + if (path === "/instance/dispose" && route.request().method() === "POST") { + config.onInstanceDispose?.() + return json(route, true) + } + if (path === "/permission") + 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 === "/session/status") + return json( + route, + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), + ) + if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) + if (path === "/file" && config.fileList) + return json(route, await config.fileList(url.searchParams.get("path") ?? "")) + if (path === "/file/content" && config.fileContent) + return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) if (path === "/api/reference") return json(route, { location: { @@ -218,6 +282,9 @@ 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" @@ -227,6 +294,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (emptyObject.has(path)) return json(route, {}) + if (emptyList.has(path)) return json(route, []) + if (path in staticRoutes) return json(route, staticRoutes[path]) const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) if (currentSessionMatch) { @@ -246,6 +316,25 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, { data: currentMessage(message) }) } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) + if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {}) + + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + + const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) + if (messageMatch) { + config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) + return json(route, message) + } + + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) + if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) if (currentMessagesMatch) { const token = url.searchParams.get("cursor") ?? undefined @@ -264,6 +353,22 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) + if (messagesMatch) { + const token = url.searchParams.get("before") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) + if (!pageData.cursor) return json(route, pageData.items) + const cursor = `cursor_${++nextCursor}` + cursors.set(cursor, pageData.cursor) + return json(route, pageData.items, { "x-next-cursor": cursor }) + } + if (url.port === targetPort && targetPort !== appPort) return json(route, {}) return route.fallback() }) diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index a245082bc715..b0e3b74c6d9a 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/api/event" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -174,7 +174,10 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.origin !== server || url.pathname !== "/api/event") + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -190,9 +193,18 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) + if (url.pathname === "/global/event") + controller.enqueue( + encoder.encode( + frame({ + payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, + }), + ), + ) request.signal.addEventListener( "abort", () => { From c849b1f0f7487dcdec952f6ef78e9580cc97f56a Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 4 Aug 2026 13:40:28 +0800 Subject: [PATCH 3/3] refactor(app): own rendering contracts --- .../performance/timeline-stability/fixture.ts | 2 +- packages/app/e2e/tsconfig.json | 1 + .../app/src/components/command-palette.ts | 2 +- packages/app/src/components/dialog-fork.tsx | 2 +- .../components/dialog-select-directory-v2.tsx | 2 +- .../components/dialog-select-directory.tsx | 2 +- .../src/components/file-tree-v2-model.test.ts | 2 +- .../app/src/components/file-tree-v2-model.ts | 2 +- packages/app/src/components/file-tree-v2.tsx | 2 +- packages/app/src/components/file-tree.tsx | 2 +- .../app/src/components/prompt-input-v2.tsx | 2 +- .../src/components/prompt-input.stories.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 2 +- .../prompt-input/build-request-parts.ts | 2 +- .../app/src/components/prompt-input/submit.ts | 2 +- .../session/session-context-breakdown.test.ts | 2 +- .../session/session-context-breakdown.ts | 2 +- .../session/session-context-metrics.test.ts | 2 +- .../session/session-context-metrics.ts | 2 +- .../session/session-context-tab.tsx | 2 +- .../components/status-popover-indicator.ts | 2 +- .../app/src/components/titlebar-tab-nav.tsx | 2 +- .../app/src/components/titlebar-tab-strip.tsx | 2 +- packages/app/src/context/directory-sync.ts | 2 +- .../app/src/context/file/content-cache.ts | 2 +- packages/app/src/context/file/tree-store.ts | 2 +- packages/app/src/context/file/types.ts | 2 +- packages/app/src/context/file/watcher.ts | 2 +- .../src/context/global-sync/bootstrap.test.ts | 3 +- .../app/src/context/global-sync/bootstrap.ts | 4 +- .../src/context/global-sync/child-store.ts | 2 +- .../context/global-sync/event-reducer.test.ts | 2 +- .../src/context/global-sync/event-reducer.ts | 2 +- .../global-sync/home-session-index.test.ts | 2 +- .../context/global-sync/home-session-index.ts | 2 +- .../context/global-sync/session-cache.test.ts | 2 +- .../src/context/global-sync/session-cache.ts | 2 +- .../context/global-sync/session-trim.test.ts | 2 +- .../src/context/global-sync/session-trim.ts | 2 +- packages/app/src/context/global-sync/types.ts | 2 +- packages/app/src/context/global-sync/utils.ts | 2 +- packages/app/src/context/layout.tsx | 2 +- packages/app/src/context/notification.tsx | 2 +- .../context/permission-auto-respond.test.ts | 2 +- packages/app/src/context/permission.tsx | 2 +- packages/app/src/context/prompt-state.ts | 2 +- packages/app/src/context/server-sdk.test.ts | 2 +- packages/app/src/context/server-sdk.tsx | 8 +- .../app/src/context/server-session.test.ts | 3 +- packages/app/src/context/server-session.ts | 4 +- packages/app/src/context/server-sync.tsx | 4 +- .../app/src/context/sync-optimistic.test.ts | 2 +- packages/app/src/context/sync.tsx | 2 +- packages/app/src/context/tabs.tsx | 2 +- .../pages/home/home-sessions-controller.tsx | 2 +- .../app/src/pages/home/home-sessions-view.tsx | 2 +- packages/app/src/pages/layout.tsx | 2 +- packages/app/src/pages/layout/helpers.test.ts | 2 +- packages/app/src/pages/layout/helpers.ts | 2 +- .../app/src/pages/layout/sidebar-items.tsx | 2 +- .../src/pages/layout/sidebar-workspace.tsx | 2 +- packages/app/src/pages/session.tsx | 2 +- .../composer/session-composer-state.test.ts | 2 +- .../composer/session-composer-state.ts | 2 +- .../composer/session-permission-dock.tsx | 2 +- .../composer/session-question-dock.tsx | 2 +- .../session/composer/session-request-tree.ts | 2 +- .../session/composer/session-todo-dock.tsx | 2 +- .../composer/todo-panel-motion.stories.tsx | 2 +- packages/app/src/pages/session/review-tab.tsx | 2 +- .../session/session-model-helpers.test.ts | 2 +- .../pages/session/session-model-helpers.ts | 2 +- .../src/pages/session/session-side-panel.tsx | 2 +- .../session/timeline/message-timeline.tsx | 2 +- .../src/pages/session/timeline/model.test.ts | 2 +- .../app/src/pages/session/timeline/model.ts | 2 +- .../src/pages/session/timeline/projection.ts | 2 +- .../app/src/pages/session/timeline/rows.ts | 4 +- .../session/timeline/summary-diffs.test.ts | 2 +- .../pages/session/timeline/summary-diffs.ts | 2 +- .../pages/session/timeline/timeline-row.ts | 2 +- .../pages/session/usage-exceeded-dialogs.tsx | 2 +- .../pages/session/use-session-commands.tsx | 2 +- .../pages/session/use-session-hash-scroll.ts | 2 +- .../src/pages/session/v2/review-diff-kinds.ts | 2 +- .../src/pages/session/v2/review-panel-v2.tsx | 2 +- packages/app/src/types.ts | 246 ++++++++++++++++++ packages/app/src/utils/diffs.test.ts | 4 +- packages/app/src/utils/diffs.ts | 4 +- packages/app/src/utils/prompt.test.ts | 2 +- packages/app/src/utils/prompt.ts | 2 +- packages/app/src/utils/server-compat.ts | 3 +- packages/app/src/utils/server-errors.test.ts | 2 +- packages/app/src/utils/session-message.ts | 2 +- packages/app/src/utils/session.ts | 2 +- 95 files changed, 352 insertions(+), 102 deletions(-) create mode 100644 packages/app/src/types.ts diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 515338196e22..10d34aeb8a99 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -9,7 +9,7 @@ import type { ToolPart, ToolState, UserMessage, -} from "@opencode-ai/sdk/v2/client" +} from "../../../src/types" import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise" import { expect, type Page } from "@playwright/test" import { Schema } from "effect" diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 4a6046e4faaf..514c171993a2 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -12,6 +12,7 @@ "./performance/unit/visual-stability.test.ts", "./reproduction/timeline-suspense/**/*.ts", "./reproduction/timeline-suspense/**/*.tsx", + "../src/types.ts", "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 8014ea1c4395..5779c3a423fb 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,5 +1,5 @@ import { getFilename } from "@opencode-ai/core/util/path" -import type { Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@/types" import type { SessionInfo } from "@opencode-ai/client/promise" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createMemo, onCleanup } from "solid-js" diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 91d349073265..6fe0c445601f 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { showToast } from "@/utils/toast" import { extractPromptFromParts } from "@/utils/prompt" -import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" +import type { TextPart as SDKTextPart } from "@/types" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index f3376ad35f26..d3adb4e15300 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" -import type { Path } from "@opencode-ai/sdk/v2/client" +import type { Path } from "@/types" import { absoluteTreePath, activeTreeNavigation, diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 2a6c877df1d2..35737412d36f 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" -import type { Path } from "@opencode-ai/sdk/v2/client" +import type { Path } from "@/types" interface DialogSelectDirectoryProps { title?: string diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts index f2a63fe86d3d..1516eb731773 100644 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@/types" describe("buildFileTreeV2Model", () => { test("builds a sorted tree and flattens expanded directories", () => { diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 27783a9bb16b..a84d8ed62569 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -1,4 +1,4 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@/types" export type FileTreeV2Model = { children: ReadonlyMap diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index 26cf49d411b8..64a015a226f0 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -12,7 +12,7 @@ import { type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@/types" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 718e2f3493de..62b8e8e99fec 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -17,7 +17,7 @@ import { type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@/types" const MAX_DEPTH = 128 diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 44d9d48d386b..517c110e4ed0 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import type { ReferenceInfo } from "@/types" import { createEffect, createMemo, on, Show } from "solid-js" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 0b9c26ce411b..4f7c62dc1359 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -1,6 +1,6 @@ // @ts-nocheck import { createStore } from "solid-js/store" -import type { Todo } from "@opencode-ai/sdk/v2" +import type { Todo } from "@/types" import { createPromptState } from "@/context/prompt" import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" import { createPromptInputHistory, PromptInput } from "./prompt-input" diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 7923e18ee2ae..0f4e81ed30b8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import type { ReferenceInfo } from "@/types" export { createPromptInputHistory } export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 7f0fea2bfe79..52ec421a8eb9 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,5 +1,5 @@ import { getFilename } from "@opencode-ai/core/util/path" -import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 051bf4d06cb9..1c6d802e0ae0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,4 +1,4 @@ -import type { Message, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Session } from "@/types" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts index f38aecb55da9..fa5a1c363e79 100644 --- a/packages/app/src/components/session/session-context-breakdown.test.ts +++ b/packages/app/src/components/session/session-context-breakdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@/types" import { estimateSessionContextBreakdown } from "./session-context-breakdown" const user = (id: string) => { diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts index e263b2957b37..9bcf77d4cd0f 100644 --- a/packages/app/src/components/session/session-context-breakdown.ts +++ b/packages/app/src/components/session/session-context-breakdown.ts @@ -1,4 +1,4 @@ -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@/types" export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 37cc0d5b5d2e..411d987bd56f 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { Message } from "@/types" import { getSessionContext } from "./session-context-metrics" const assistant = ( diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 30dc9e958b9c..ef51e30f1b93 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message } from "@/types" type Provider = { id: string diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 25ad5ab230ca..05a87faa8e9b 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/session-ui/file" import { Markdown } from "@opencode-ai/session-ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, UserMessage } from "@/types" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts index 62fc3bedbff3..eafe5facc6a1 100644 --- a/packages/app/src/components/status-popover-indicator.ts +++ b/packages/app/src/components/status-popover-indicator.ts @@ -1,4 +1,4 @@ -import type { LspStatus } from "@opencode-ai/sdk/v2/client" +import type { LspStatus } from "@/types" import type { McpServer } from "@opencode-ai/client/promise" export function hasServiceNeedingAttention(input: { mcp: Array }) { diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index e2aa47adbdad..8e36b9369d84 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -9,7 +9,7 @@ import { useGlobal } from "@/context/global" import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" -import type { Session } from "@opencode-ai/sdk/v2" +import type { Session } from "@/types" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" import "./titlebar-tab-nav.css" diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 0a4b4da12dfe..242de395c401 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" -import type { Session } from "@opencode-ai/sdk/v2" +import type { Session } from "@/types" function SessionTabSlot(props: { tab: SessionTab diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index befd5b61e095..359074da25c9 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -1,5 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" -import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, Session } from "@/types" import { createMemo } from "solid-js" import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts index 4b724068834b..b6ba5e89d7c8 100644 --- a/packages/app/src/context/file/content-cache.ts +++ b/packages/app/src/context/file/content-cache.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent } from "@/types" const MAX_FILE_CONTENT_ENTRIES = 40 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index 5769c318f07e..e1404e0a8c2a 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -1,5 +1,5 @@ import { createStore, produce, reconcile } from "solid-js/store" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@/types" type DirectoryState = { expanded: boolean diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts index 7ce8a37c25e6..e3af3a99a375 100644 --- a/packages/app/src/context/file/types.ts +++ b/packages/app/src/context/file/types.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent } from "@/types" export type FileSelection = { startLine: number diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf71992791a..79cda0219560 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -1,4 +1,4 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { FileNode } from "@/types" type WatcherEvent = { type: string diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 71986dd3c40d..fd4044a0fa73 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" -import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { Config, Project } from "@/types" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 844ce7cfc282..238a53b62300 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -1,6 +1,5 @@ import type { Config, - OpencodeClient, Path, PermissionRequest, Project, @@ -8,7 +7,8 @@ import type { QuestionRequest, ReferenceInfo, Session, -} from "@opencode-ai/sdk/v2/client" +} from "@/types" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { AgentListInput, AgentListOutput, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 4eaa785789fc..d376aa805e07 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { VcsInfo } from "@/types" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, 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 b53fb691b3cf..d0c855181641 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 8f203715a6de..7837fb7aa058 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -9,7 +9,7 @@ import type { Session, SessionStatus, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts index 4e40cc78eaea..7b9e286fd517 100644 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SessionV2Info } from "@opencode-ai/sdk/v2/client" +import type { SessionV2Info } from "@/types" import { applyHomeSessionEvent, appendHomeSessionEvent, diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 03a085e34d51..a70dc378fc2d 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -1,4 +1,4 @@ -import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" +import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types" import type { QueryClient } from "@tanstack/solid-query" import { trimSessions } from "./session-trim" import { pathKey } from "@/utils/path-key" 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 45fbe38abe73..0e3944666ed6 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 7d684a5a1ab0..d6af434a0bc6 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,4 +1,4 @@ -import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionMessageInfo } from "@opencode-ai/client/promise" diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts index be12c074b5dc..0eda5c7500a8 100644 --- a/packages/app/src/context/global-sync/session-trim.test.ts +++ b/packages/app/src/context/global-sync/session-trim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@/types" import { trimSessions } from "./session-trim" const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts index ba13cb5ec0ec..eaa9bacc5f88 100644 --- a/packages/app/src/context/global-sync/session-trim.ts +++ b/packages/app/src/context/global-sync/session-trim.ts @@ -1,4 +1,4 @@ -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@/types" import { cmp } from "./utils" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 74191e10b079..e0a69603cbd2 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -12,7 +12,7 @@ import type { SessionStatus, Todo, VcsInfo, -} from "@opencode-ai/sdk/v2/client" +} from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 1d917d7da71d..c02e887a0d4d 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -5,7 +5,7 @@ import type { PermissionRequest, ProviderListOutput, } from "@opencode-ai/client/promise" -import type { Agent, Event, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types" import type { Project as CurrentProject } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6235f35c45ae..78a7e94f3fe1 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync" import { useServerSDK } from "./server-sdk" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" -import { Project } from "@opencode-ai/sdk/v2" +import type { Project } from "@/types" import { normalizeProjectInfo } from "./global-sync/utils" import { Persist, persisted, removePersisted } from "@/utils/persist" import { pathKey } from "@/utils/path-key" diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 9bfa13d854db..d73618e05677 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { base64Encode } from "@opencode-ai/core/util/encode" import { decode64 } from "@/utils/base64" -import { EventSessionError } from "@opencode-ai/sdk/v2" +import type { EventSessionError } from "@/types" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" import { useGlobal } from "./global" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 794e03ae4882..b534dd7d3b06 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, Session } from "@/types" import { base64Encode } from "@opencode-ai/core/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond" diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index d6d8019262a4..45135cb66f79 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest } from "@/types" import { Persist, persisted } from "@/utils/persist" import type { ServerSDK } from "@/context/server-sdk" import type { ServerSync } from "./server-sync" diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index 73196289e2f3..064e9744e700 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -1,5 +1,5 @@ import { checksum } from "@opencode-ai/core/util/encode" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import type { FilePartSource } from "@/types" import { batch, createMemo, type Accessor } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index b5966ce77f9a..30c40d324426 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import type { OpenCodeEvent } from "@opencode-ai/client/promise" -import type { Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@/types" describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index aaf43703ef2b..8dbeeffd962a 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,5 +1,5 @@ import type { OpenCodeEvent } from "@opencode-ai/client/promise" -import type { Event } from "@opencode-ai/sdk/v2/client" +import type { Event, PermissionRequest } from "@/types" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" @@ -18,7 +18,7 @@ const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -export type ServerEvent = Event & { current?: OpenCodeEvent } +export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent } type QueuedServerEvent = { directory: string; payload: ServerEvent } type CurrentDelta = Extract< OpenCodeEvent, @@ -41,9 +41,9 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { event.data.source?.type === "tool" ? { messageID: event.data.source.messageID, callID: event.data.source.id } : undefined, - }, + } satisfies PermissionRequest, current: event, - } as ServerEvent + } } return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent } diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index e9595e137607..ad0d572b5c14 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" -import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, Session } from "@/types" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" import type { ServerApi } from "@/utils/server" diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 69bec61ba2fc..e3f04f9bdb6d 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -3,14 +3,14 @@ import { retry } from "@opencode-ai/core/util/retry" import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, - OpencodeClient, Part, PermissionRequest, QuestionRequest, Session, SessionStatus, Todo, -} from "@opencode-ai/sdk/v2/client" +} from "@/types" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 196cda84794e..7f0ab7654b94 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,11 +1,11 @@ import type { Config, - OpencodeClient, Path, Project, ProviderAuthResponse, SessionStatus, -} from "@opencode-ai/sdk/v2/client" +} from "@/types" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts index 94324f8a060a..6679947149a1 100644 --- a/packages/app/src/context/sync-optimistic.test.ts +++ b/packages/app/src/context/sync-optimistic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@/types" import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync" type Text = Extract diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index b90f49ffd49d..c129b958b1a0 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -2,7 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary" import { createMemo } from "solid-js" import { useServerSync } from "./server-sync" import { useSDK } from "./sdk" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Message, Part } from "@/types" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 75191d54cf4a..7bebc07664d6 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { createSimpleContext } from "@opencode-ai/ui/context" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index a12e9800e9fe..d63c068b6c8b 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMarked } from "@opencode-ai/ui/context/marked" diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index df93c4a71414..13465d1992e7 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { type Accessor, createMemo, For, Show } from "solid-js" import { Spinner } from "@opencode-ai/ui/spinner" import { ScrollView } from "@opencode-ai/ui/scroll-view" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b348b44d8a87..1dde8b59cb6c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -26,7 +26,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/core/util/path" -import { Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index df87ddfecd2a..96105247eea5 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -6,7 +6,7 @@ import { parseDeepLink, parseNewSessionDeepLink, } from "./deep-links" -import { type Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { childSessionOnPath, closeHomeProject, diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index ce793e282bde..dec03c6178c6 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,5 +1,5 @@ import { getFilename } from "@opencode-ai/core/util/path" -import { type Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { pathKey } from "@/utils/path-key" import type { ServerConnection } from "@/context/server" import type { HomeProjectSelection } from "@/context/layout" diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 7046484a60f1..96a78fee5707 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { Avatar } from "@opencode-ai/ui/avatar" import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index fe34bdd402e9..189d32d5dbf2 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { type Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { type LocalProject } from "@/context/layout" import { useServerSync, useQueryOptions } from "@/context/server-sync" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c6e3a5fcbb71..3c2263feb4d0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, UserMessage, VcsFileDiff } from "@/types" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" 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 49efcc971a9b..ddb0ddf65f5d 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,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, QuestionRequest, Session } from "@/types" import { todoDockAtBoundary, todoState } from "./session-composer-state" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" 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 f54e0c9e4f5d..1b15d8f03352 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,6 +1,6 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" +import type { PermissionRequest, QuestionRequest, Todo } from "@/types" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" import { useServerSync } from "@/context/server-sync" diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 35a62f75cfe3..44a6ce9b0039 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -1,5 +1,5 @@ import { For, Show } from "solid-js" -import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import type { PermissionRequest } from "@/types" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" 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 941424e247cf..5705e93d58b2 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -6,7 +6,7 @@ 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 type { QuestionAnswer, QuestionRequest } from "@/types" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { makeEventListener } from "@solid-primitives/event-listener" diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts index 03872c091c0e..1e6cf770fbc3 100644 --- a/packages/app/src/pages/session/composer/session-request-tree.ts +++ b/packages/app/src/pages/session/composer/session-request-tree.ts @@ -1,4 +1,4 @@ -import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { PermissionRequest, QuestionRequest, Session } from "@/types" function sessionTreeRequest( session: Session[], diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index d44b4902622e..43b40c18a00d 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -1,4 +1,4 @@ -import type { Todo } from "@opencode-ai/sdk/v2" +import type { Todo } from "@/types" import { AnimatedNumber } from "@opencode-ai/ui/animated-number" import { Checkbox } from "@opencode-ai/ui/checkbox" import { DockTray } from "@opencode-ai/ui/dock-surface" diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 41d1f984bdeb..9eae52796503 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import { createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import type { Todo } from "@opencode-ai/sdk/v2" +import type { Todo } from "@/types" import { useServerSync } from "@/context/global-sync" import { PromptInput } from "@/components/prompt-input" import { usePrompt } from "@/context/prompt" diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 1b65af7121ac..880433016fcb 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,6 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts index 201627b3691e..b4e311ff5059 100644 --- a/packages/app/src/pages/session/session-model-helpers.test.ts +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { UserMessage } from "@/types" import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers" const message = (input?: { agent?: string; model?: UserMessage["model"] }) => diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts index f2aadff27d84..26210c9b404f 100644 --- a/packages/app/src/pages/session/session-model-helpers.ts +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -1,4 +1,4 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { UserMessage } from "@/types" type Local = { session: { diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 22c52e73feb8..0c57cd76e055 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -23,7 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 81c1a8f02281..23c855147644 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -51,7 +51,7 @@ import type { Part as PartType, ToolPart, UserMessage, -} from "@opencode-ai/sdk/v2" +} from "@/types" import { showToast } from "@/utils/toast" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts index 24612072c3d4..ca98781c065d 100644 --- a/packages/app/src/pages/session/timeline/model.test.ts +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message, UserMessage } from "@/types" import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model" const user = (id: string) => ({ id, role: "user" }) as UserMessage diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 7eebee608078..7ab03a0cd9a5 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -1,4 +1,4 @@ -import type { Message, UserMessage } from "@opencode-ai/sdk/v2" +import type { Message, UserMessage } from "@/types" import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js" import { useServerSync } from "@/context/server-sync" import { useSync } from "@/context/sync" diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index e30c936d73cc..3d3c0ba7eb8c 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,5 +1,5 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise" -import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@/types" import { createMemo, type Accessor } from "solid-js" import { reuseTimelineRows } from "./row-reconciliation" import { Timeline, TimelineRow } from "./rows" diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 25a5113344eb..9bcd9e7df4b1 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,6 +1,6 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import type { SessionMessageInfo } from "@opencode-ai/client/promise" -import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Part, SessionStatus, UserMessage } from "@/types" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" import { uniqueSummaryDiffs } from "./summary-diffs" @@ -215,7 +215,7 @@ export namespace Timeline { } if (error) { - const data = error.data?.message + const data = error.data && "message" in error.data ? error.data.message : undefined rows.push( new TimelineRow.Error({ userMessageID: userMessage.id, diff --git a/packages/app/src/pages/session/timeline/summary-diffs.test.ts b/packages/app/src/pages/session/timeline/summary-diffs.test.ts index 9b66bf67719a..38e20ede10ea 100644 --- a/packages/app/src/pages/session/timeline/summary-diffs.test.ts +++ b/packages/app/src/pages/session/timeline/summary-diffs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff } from "@/types" import { uniqueSummaryDiffs } from "./summary-diffs" const diff = (file: string, additions: number) => diff --git a/packages/app/src/pages/session/timeline/summary-diffs.ts b/packages/app/src/pages/session/timeline/summary-diffs.ts index 2df37af5a6fb..2850c577b85d 100644 --- a/packages/app/src/pages/session/timeline/summary-diffs.ts +++ b/packages/app/src/pages/session/timeline/summary-diffs.ts @@ -1,4 +1,4 @@ -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff } from "@/types" import type { SummaryDiff } from "./timeline-row" export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) { diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 3905254b29b5..f911e9dd4199 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -1,4 +1,4 @@ -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff } from "@/types" import type { PartGroup } from "@opencode-ai/session-ui/message-part" import { Data, Equal } from "effect" diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx index d56fa3d1f48c..194846e8476a 100644 --- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx @@ -1,6 +1,6 @@ import { useSDK } from "@/context/sdk" import { Persist, persisted } from "@/utils/persist" -import { SessionStatus } from "@opencode-ai/sdk/v2" +import type { SessionStatus } from "@/types" import { onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useSessionLayout } from "./session-layout" diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 12dd96a5e66b..04638d53bfca 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -15,7 +15,7 @@ import { showToast } from "@/utils/toast" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" +import type { UserMessage } from "@/types" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 65577abae229..c60a53b7961b 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,4 +1,4 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { UserMessage } from "@/types" import { useLocation, useNavigate } from "@solidjs/router" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index d3adb1f2fffa..d8bb57dae70d 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,4 +1,4 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index fcd6bbb79feb..df3f22960df4 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,5 @@ import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts new file mode 100644 index 000000000000..8c93a63bdc93 --- /dev/null +++ b/packages/app/src/types.ts @@ -0,0 +1,246 @@ +import type { + Agent, + Config, + Event, + FileContent, + FileNode, + LspStatus, + Path, + PermissionRequest, + Project, + Provider, + ProviderAuthResponse, + ProviderListResponse, + QuestionAnswer, + QuestionRequest, + ReferenceInfo, + Session, + SessionNotFoundError, + SessionStatus, + SessionV2Info, + SnapshotFileDiff, + Todo, + V2SessionListResponse, + VcsFileDiff, + VcsInfo, +} from "@opencode-ai/sdk/v2/client" + +export type { + Agent, + Config, + Event, + FileContent, + FileNode, + LspStatus, + Path, + PermissionRequest, + Project, + Provider, + ProviderAuthResponse, + ProviderListResponse, + QuestionAnswer, + QuestionRequest, + ReferenceInfo, + Session, + SessionNotFoundError, + SessionStatus, + SessionV2Info, + SnapshotFileDiff, + Todo, + V2SessionListResponse, + VcsFileDiff, + VcsInfo, +} + +export type EventSessionError = Extract + +type MessageError = + | { name: "ProviderAuthError"; data: { providerID: string; message: string } } + | { name: "UnknownError"; data: { message: string; ref?: string } } + | { name: "MessageOutputLengthError"; data: Record } + | { name: "MessageAbortedError"; data: { message: string } } + | { name: "StructuredOutputError"; data: { message: string; retries: number } } + | { name: "ContextOverflowError"; data: { message: string; responseBody?: string } } + | { name: "ContentFilterError"; data: { message: string } } + | { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: Record + responseBody?: string + metadata?: Record + } + } + +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { created: number } + format?: { type: "text" } | { type: "json_schema"; schema: Record; retryCount?: number } + summary?: { title?: string; body?: string; diffs: SnapshotFileDiff[] } + agent: string + model: { providerID: string; modelID: string; variant?: string } + system?: string + tools?: Record +} + +export type AssistantMessage = { + id: string + sessionID: string + role: "assistant" + time: { created: number; completed?: number } + error?: MessageError + parentID: string + modelID: string + providerID: string + mode: string + agent: string + path: { cwd: string; root: string } + summary?: boolean + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } + structured?: unknown + variant?: string + finish?: string +} + +export type Message = UserMessage | AssistantMessage + +type PartBase = { id: string; sessionID: string; messageID: string } + +export type TextPart = PartBase & { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { start: number; end?: number } + metadata?: Record +} + +type FilePartSourceText = { value: string; start: number; end: number } +type FileSource = { text: FilePartSourceText; type: "file"; path: string } +type SymbolSource = { + text: FilePartSourceText + type: "symbol" + path: string + range: { start: { line: number; character: number }; end: { line: number; character: number } } + name: string + kind: number +} +type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string } + +export type FilePartSource = FileSource | SymbolSource | ResourceSource + +export type FilePart = PartBase & { + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type ToolState = + | { status: "pending"; input: Record; raw: string } + | { + status: "running" + input: Record + title?: string + metadata?: Record + time: { start: number } + } + | { + status: "completed" + input: Record + output: string + title: string + metadata: Record + time: { start: number; end: number; compacted?: number } + attachments?: FilePart[] + } + | { + status: "error" + input: Record + error: string + metadata?: Record + time: { start: number; end: number } + } + +export type ToolPart = PartBase & { + type: "tool" + callID: string + tool: string + state: ToolState + metadata?: Record +} + +export type AgentPart = PartBase & { + type: "agent" + name: string + source?: { value: string; start: number; end: number } +} + +type ReasoningPart = PartBase & { + type: "reasoning" + text: string + metadata?: Record + time: { start: number; end?: number } +} + +type SubtaskPart = PartBase & { + type: "subtask" + prompt: string + description: string + agent: string + model?: { providerID: string; modelID: string } + command?: string +} + +type StepStartPart = PartBase & { type: "step-start"; snapshot?: string } +type StepFinishPart = PartBase & { + type: "step-finish" + reason: string + snapshot?: string + cost: number + tokens: AssistantMessage["tokens"] +} +type SnapshotPart = PartBase & { type: "snapshot"; snapshot: string } +type PatchPart = PartBase & { type: "patch"; hash: string; files: string[] } +type RetryPart = PartBase & { + type: "retry" + attempt: number + error: Extract + time: { created: number } +} +type CompactionPart = PartBase & { + type: "compaction" + auto: boolean + overflow?: boolean + tail_start_id?: string +} + +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + +export type TextPartInput = Omit & { id?: string } +export type FilePartInput = Omit & { id?: string } +export type AgentPartInput = Omit & { id?: string } diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index a3d25f427959..fae8559e1331 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { Message } from "@/types" import { diffs, message } from "./diffs" const item = { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index a8eec75a9af3..ccb64a8b5189 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,6 +1,6 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { Message } from "@/types" type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index 1ecaf02c978e..76676fd9b43d 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Part } from "@opencode-ai/sdk/v2" +import type { Part } from "@/types" import { extractPromptFromParts } from "./prompt" describe("extractPromptFromParts", () => { diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 35aec0071aac..8aa48b2812f0 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -1,4 +1,4 @@ -import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" +import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" type Inline = diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 8a2c42f198b3..e0c49dfeb247 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -1,6 +1,7 @@ import type { ServerApi } from "./server" import type { ServerProtocol } from "./server-protocol" -import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { Project, ProjectCurrent, diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts index 9c735fe6891b..02cb43920871 100644 --- a/packages/app/src/utils/server-errors.test.ts +++ b/packages/app/src/utils/server-errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client" +import type { SessionNotFoundError } from "@/types" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors" diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index 8de3ea816e17..9cadf780e0be 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -5,7 +5,7 @@ import type { SessionMessageShell, SessionMessageUser, } from "@opencode-ai/client/promise" -import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@/types" import { Option, Schema } from "effect" const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts index 833dbb923de1..de015411316c 100644 --- a/packages/app/src/utils/session.ts +++ b/packages/app/src/utils/session.ts @@ -1,5 +1,5 @@ import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@/types" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" export function normalizeSessionInfo(input: SessionInfo | Session): Session {