diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index df67da5a662..c24b27cf063 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 83308c0a866..8af12a948cb 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 f09a2c7b63a..e6f3481f16e 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 35a0aa44cda..3e6d0791a99 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 2d9b1e23497..43c3365102f 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 04e6d2cced8..7cc7b32f0ad 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 0d6756201e7..878eb84e372 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 79b564820e2..72d92188c56 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 5ea9d4f7613..6c253f462ac 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 5b6e0b127b1..6023467fddb 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 a9a4738da92..053999e5e20 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 e5ef7998ea7..a259628f766 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 b303071c87f..1d83ea81084 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 b1aabcc32ca..09d132b581e 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 7c0864e5845..387b713069e 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 ad35eef601c..5db1051686e 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 54139cc3711..4491515b15b 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 778ff3a3af9..681e1bcd664 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 55e71212753..c0fa157c6eb 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 b969b590d89..8caaaca9645 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 8e08d60ff2a..633dcb485ec 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 165920753cb..5b8d53affaa 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 3dce37cafd9..939c8bd3f30 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 bdf3f55bdc1..1c3375ac4ef 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 22b8bb41fe2..c92e6bd4f45 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 76987421b60..4cf37483055 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 b0e3b74c6d9..a245082bc71 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", () => {