From 35637a15c03945bc1564254a356b25d92bd90bec Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 30 Jul 2026 12:14:46 +0800 Subject: [PATCH] fix(app): migrate v2 test fixtures --- .../performance/timeline-stability/fixture.ts | 2 +- .../e2e/performance/unit/mock-server.test.ts | 117 +++++- .../session-timeline-projection.spec.ts | 17 +- ...sion-timeline-reasoning-projection.spec.ts | 5 +- .../session-todo-dock-navigation.spec.ts | 22 +- .../user-story/model-selection-flow.spec.ts | 38 +- packages/app/e2e/utils/mock-server.ts | 371 +++++++++++++++--- packages/app/src/types.ts | 4 +- 8 files changed, 458 insertions(+), 118 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 1e92439343..3020644d95 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -460,7 +460,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/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts index e7dbbdadfe..8af12a948c 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[] = [] diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index b1aabcc32c..09d132b581 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 7c0864e584..387b713069 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-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 55e7121275..c0fa157c6e 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/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index 22b8bb41fe..c92e6bd4f4 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 4b8a608188..58cfdcea0d 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,10 +1,17 @@ import type { Page, Route } from "@playwright/test" +import type { + JsonValue, + PromptAgentAttachment, + PromptFileAttachment, + SessionMessageAssistant, + SessionMessageInfo, + SessionStructuredError, +} from "@opencode-ai/client/promise" export interface MockServerConfig { provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -69,6 +76,15 @@ 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: [] }) @@ -78,7 +94,12 @@ 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") { @@ -114,25 +135,40 @@ 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/file" && config.fileList) + if (path === "/api/fs/list" && config.fileList) return json(route, { location: location(config), data: await config.fileList(url.searchParams.get("path") ?? ""), }) - if (path === "/api/file/read" && config.fileContent) { - const value = await config.fileContent(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/file/find" && config.findFiles) + 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: 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, - }), + 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 } }) @@ -162,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]) => @@ -234,10 +272,70 @@ 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: [], + 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 @@ -281,65 +379,218 @@ 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) || !Object.values(value).every(jsonValue)) return + return value as Record +} + +function jsonValue(value: unknown): value is JsonValue { + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true + if (Array.isArray(value)) return value.every(jsonValue) + return record(value) && Object.values(value).every(jsonValue) +} + +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/src/types.ts b/packages/app/src/types.ts index 41a7eb1f2d..e348697729 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -2,7 +2,7 @@ import type { EventSubscribeOutput, FileDiffInfo, FileDiffLegacyInfo, - Project, + ProjectListOutput, QuestionAnswer, QuestionInfo, QuestionRequest, @@ -16,7 +16,6 @@ import type { import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export type { - Project, QuestionAnswer, QuestionRequest, ReferenceInfo, @@ -24,6 +23,7 @@ export type { SessionStatus, } +export type Project = Omit & { worktree: string } export type Session = SessionV1Info export type SessionV2Info = SessionInfo export type V2SessionListResponse = SessionsResponse