Compare commits

..

2 Commits

Author SHA1 Message Date
Brendan Allan 4e1fc3e777 test(app): keep e2e mocks stack-compatible 2026-08-04 14:58:11 +08:00
Brendan Allan 022ed7ec68 test(app): migrate e2e fixtures to v2 2026-08-04 13:13:34 +08:00
24 changed files with 680 additions and 747 deletions
@@ -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<string, unknown>
}
>
}
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> = Value extends readonly unknown[]
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
@@ -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,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
protocol: input.protocol,
protocol: "v2",
directory,
project: project(),
provider: provider(),
@@ -235,7 +243,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[] {
@@ -460,7 +468,7 @@ export function toolPart(
input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {},
): Omit<ToolPart, "sessionID" | "messageID"> {
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 {
@@ -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()
@@ -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" }])
@@ -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,
@@ -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" }])
@@ -14,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -127,7 +128,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)
@@ -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,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v1",
protocol: "v2",
directory,
project: {
id: projectID,
@@ -89,22 +78,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(
@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v1",
protocol: "v2",
directory,
project: {
id: projectID,
@@ -62,33 +62,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 +108,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 +126,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 +136,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 +148,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 +171,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 +187,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()
@@ -52,7 +52,7 @@ const editPart = {
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
callID: editPartID,
tool: "edit",
state: {
status: "completed",
@@ -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) {
@@ -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 }) => {
@@ -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()
})
@@ -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 }) => {
@@ -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) =>
@@ -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)
})
@@ -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" }])
@@ -4,12 +4,9 @@ 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, {
protocol: "v1",
directory,
project: {
id: "proj_model_selection_flow",
@@ -46,17 +43,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 +55,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 +79,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()
+359 -75
View File
@@ -1,4 +1,12 @@
import type { Page, Route } from "@playwright/test"
import type {
JsonValue,
PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
} from "@opencode-ai/client/promise"
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
@@ -47,7 +55,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
"/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"
@@ -77,8 +84,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
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") return json(route, providerConfig(config))
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
@@ -134,7 +140,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 +158,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 +199,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 +262,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
@@ -226,12 +282,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
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 (/^\/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,6 +294,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
@@ -252,12 +307,18 @@ 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 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?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: currentMessage(message) })
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
const projectMatch = path.match(/^\/project\/([^/]+)$/)
if (projectMatch) return json(route, config.project)
@@ -300,8 +361,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
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)
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
@@ -317,10 +377,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<string, unknown>
if (permission.action) return permission
@@ -364,65 +489,224 @@ export function currentSession(session: { id: string } & Record<string, unknown>
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { 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<unknown>((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<string, unknown>
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<string, unknown>): 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<string, unknown>): 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<string, unknown> | 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<string, unknown>,
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<string, JsonValue> | 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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({
status,
+38 -205
View File
@@ -1,4 +1,4 @@
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -37,34 +37,6 @@ export type TurnStart =
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
type ChildSessionUpdateBase = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
}
export type ChildSessionUpdate = ChildSessionUpdateBase &
(
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
)
type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
function emptyToolState(): ToolState {
return { name: "tool", input: {}, metadata: {}, content: [] }
}
@@ -78,13 +50,8 @@ export async function streamTurn(input: {
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}): Promise<PromptResponse> {
const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
@@ -95,113 +62,47 @@ export async function streamTurn(input: {
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
let standard = true
let handedOff = false
const notifyChild = async (
child: ChildSession,
value:
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
},
) => {
if (!input.childSessionUpdate) return
await input
.childSessionUpdate({
rootSessionId: input.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
})
.catch(() => {})
}
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
const updateSession = async (value: SessionUpdate, child?: ChildSession) => {
const projected = child ? projectChildUpdate(value, child) : value
if (standard && (!child || !input.childSessionUpdate)) {
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
}
if (child) await notifyChild(child, { type: "update", update: projected })
}
const consume = async (mode: "turn" | "background") => {
const consume = async () => {
while (!streamController.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "session.created") {
const parentID = event.data.info.parentID
if (!parentID) continue
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
if ((mode === "turn" && parentID === input.sessionID) || parent) {
const child = {
id: event.data.sessionID,
parentID,
depth: parent ? parent.depth + 1 : 1,
title: event.data.info.title,
}
children.set(child.id, child)
openChildren.add(child.id)
await notifyChild(child, { type: "status", status: "created" })
}
continue
}
const eventSessionID = sessionID(event)
const child = eventSessionID ? children.get(eventSessionID) : undefined
const send = (update: SessionUpdate) => updateSession(update, child)
if (mode === "background" && !child) continue
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
const tool = event.data.source?.callID
? tools.get(toolKey(event.data.sessionID, event.data.source.callID))
: undefined
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
event,
sessionID: event.data.sessionID,
clientSessionID: input.sessionID,
sessionID: input.sessionID,
cwd: input.cwd,
tool,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
})
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
await input.client.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
continue
}
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
if (matchesStart(event, input.start)) {
started = true
continue
}
if (!started) continue
if (event.type === "session.execution.started") {
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
continue
}
if (event.type === "session.step.started") {
if (!child) assistantMessageID = event.data.assistantMessageID
assistantMessageID = event.data.assistantMessageID
continue
}
if (event.type === "session.text.delta") {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
@@ -209,8 +110,8 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.reasoning.delta") {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
assistantMessageID = event.data.assistantMessageID
await update({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
@@ -218,14 +119,9 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.input.started") {
if (!child) assistantMessageID = event.data.assistantMessageID
tools.set(toolKey(event.data.sessionID, event.data.callID), {
name: event.data.name,
input: {},
metadata: {},
content: [],
})
await send({
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.callID,
@@ -237,12 +133,11 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.called") {
if (!child) assistantMessageID = event.data.assistantMessageID
const key = toolKey(event.data.sessionID, event.data.callID)
const current = tools.get(key) ?? emptyToolState()
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.callID) ?? emptyToolState()
current.input = event.data.input
tools.set(key, current)
await send({
tools.set(event.data.callID, current)
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
@@ -254,10 +149,10 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.sessionID, event.data.callID))
const current = tools.get(event.data.callID)
if (!current) continue
current.metadata = event.data.metadata
await send({
await update({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.callID,
@@ -269,9 +164,8 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.success") {
const key = toolKey(event.data.sessionID, event.data.callID)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
@@ -281,7 +175,7 @@ export async function streamTurn(input: {
toolInput: current.input,
metadata: event.data.metadata ?? {},
}).catch(() => {})
await send({
await update({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.callID,
@@ -294,10 +188,9 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.failed") {
const key = toolKey(event.data.sessionID, event.data.callID)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await send({
const current = tools.get(event.data.callID) ?? emptyToolState()
tools.delete(event.data.callID)
await update({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.callID,
@@ -312,33 +205,13 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.step.ended") {
if (!child) {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
}
continue
}
if (event.type === "session.execution.succeeded") {
if (!child) return "succeeded" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "completed" })
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
continue
}
if (event.type === "session.execution.interrupted") {
if (!child) return "interrupted" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "interrupted" })
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
continue
}
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") {
if (child) {
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
if (mode === "background" && openChildren.size === 0) return "failed" as const
continue
}
executionError = event.data.error
return "failed" as const
}
@@ -346,13 +219,7 @@ export async function streamTurn(input: {
return "interrupted" as const
}
const completed = consume("turn")
const closeStream = async () => {
streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
const completed = consume()
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
@@ -366,14 +233,6 @@ export async function streamTurn(input: {
}
}
const terminal = await completed
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
standard = false
handedOff = true
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
void consume("background")
.catch(() => {})
.finally(closeStream)
}
const assistant = assistantMessageID
? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
@@ -391,37 +250,11 @@ export async function streamTurn(input: {
await completed.catch(() => {})
throw error
} finally {
if (!handedOff) await closeStream()
streamController.abort()
await stream.return?.(undefined).catch(() => {})
}
}
function sessionID(event: EventSubscribeOutput) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function toolKey(sessionID: string, callID: string) {
return `${sessionID}:${callID}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
update._meta = {
...update._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
update.toolCallId = `${child.id}:${update.toolCallId}`
if (update.title && child.title) update.title = `${child.title}: ${update.title}`
}
return update
}
export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
+3 -13
View File
@@ -20,30 +20,20 @@ export async function replyPermission(input: {
readonly connection: Connection
readonly event: PermissionEvent
readonly sessionID: string
readonly clientSessionID?: string
readonly cwd: string
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const result = await input.connection
.requestPermission({
sessionId: input.clientSessionID ?? input.sessionID,
sessionId: input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: [input.toolCallPrefix, input.event.data.source?.callID ?? input.event.data.id]
.filter((value) => value !== undefined)
.join(":"),
toolCallId: input.event.data.source?.callID ?? input.event.data.id,
toolName,
state: {
input: toolInput,
title: [input.titlePrefix, permissionTitle(toolName, toolInput, previews)]
.filter((value) => value !== undefined)
.join(": "),
},
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
+3 -27
View File
@@ -43,21 +43,13 @@ import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
} from "./event"
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
@@ -72,7 +64,6 @@ type Catalog = {
type Attached = {
readonly id: string
readonly cwd: string
readonly abort: AbortController
catalog: Catalog
model: ModelRef
modeID: string
@@ -109,7 +100,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const capabilities = { writeTextFile: false }
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
@@ -130,11 +121,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = {
id: session.id,
cwd,
abort: new AbortController(),
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
@@ -172,7 +161,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {
initialize: async (params) => {
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -190,7 +178,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ChildSessionUpdatesCapability]: true },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
@@ -237,7 +224,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
sessions.get(params.sessionId)?.abort.abort()
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
return {}
@@ -248,7 +234,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
sessions.get(params.sessionId)?.abort.abort()
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
const turn = active.get(params.sessionId)
@@ -311,7 +296,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const extNotification = input.connection.extNotification
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
@@ -321,15 +305,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(capabilities.childSessionUpdates && extNotification
? {
childSessionUpdate: (update: ChildSessionUpdate) =>
extNotification(ChildSessionUpdateMethod, update).then(() => {}),
}
: {}),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
+1 -191
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -191,181 +191,6 @@ describe("acp event behavior", () => {
}
})
test("projects foreground child session updates onto the parent turn", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
info: childSession("ses_child", "ses_parent", "Explore code"),
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
callID: "call_read",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
callID: "call_read",
input: { path: "/workspace/src/index.ts" },
executed: false,
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
callID: "call_read",
metadata: {},
content: [{ type: "text", text: "source" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
})
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
["ses_parent", "tool_call"],
["ses_parent", "tool_call_update"],
["ses_parent", "tool_call_update"],
])
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
"ses_child:call_read",
"ses_child:call_read",
"ses_child:call_read",
])
expect(updates[0]?.update).toMatchObject({
title: "Explore code: read",
_meta: {
"opencode/child-session": {
id: "ses_child",
parentID: "ses_parent",
depth: 1,
title: "Explore code",
},
},
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("continues child extension updates after the parent turn ends", async () => {
const updates: SessionUpdateParams[] = []
const childUpdates: ChildSessionUpdate[] = []
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
info: childSession("ses_background", "ses_parent", "Background research"),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
childSessionUpdate: async (update) => {
childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve()
},
})
expect(response.stopReason).toBe("end_turn")
fixture.send(
durableEvent("session.created", {
sessionID: "ses_future",
info: childSession("ses_future", "ses_parent", "Later turn child"),
}),
)
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
fixture.send(
durableEvent("session.tool.input.started", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
callID: "call_shell",
name: "shell",
}),
)
fixture.send(
durableEvent("session.tool.called", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
callID: "call_shell",
input: { command: "pwd" },
executed: false,
}),
)
fixture.send(
durableEvent("session.tool.success", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
callID: "call_shell",
metadata: { exit: 0 },
content: [{ type: "text", text: "/workspace" }],
executed: true,
}),
)
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
await withTimeout(completed.promise, "background child completion was not delivered")
expect(updates).toEqual([])
expect(
childUpdates.map((update) =>
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
),
).toEqual([
["status", "created"],
["status", "running"],
["update", "tool_call"],
["update", "tool_call_update"],
["update", "tool_call_update"],
["status", "completed"],
])
expect(childUpdates[2]).toMatchObject({
rootSessionId: "ses_parent",
childSessionId: "ses_background",
parentSessionId: "ses_parent",
depth: 1,
title: "Background research",
type: "update",
update: { toolCallId: "ses_background:call_shell" },
})
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
} finally {
await fixture.stop()
}
})
test("streams tool pending, progress, success, and failure updates", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
@@ -731,7 +556,6 @@ function turn(input: {
readonly connection: Connection
readonly sessionID: string
readonly inputID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) {
return streamTurn({
client: input.fixture.client,
@@ -741,25 +565,11 @@ function turn(input: {
start: { type: "input", id: input.inputID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
function childSession(id: string, parentID: string, title: string) {
return {
id,
slug: id,
projectID: "project",
directory: "/workspace",
parentID,
title,
version: "test",
time: { created: 1, updated: 1 },
}
}
function tokens() {
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
}
@@ -153,68 +153,6 @@ describe("acp permission behavior", () => {
}
})
test("routes foreground child permissions through the parent ACP session", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
info: {
id: "ses_child",
slug: "ses_child",
projectID: "project",
directory: "/workspace",
parentID: "ses_parent",
title: "Review code",
version: "test",
time: { created: 1, updated: 1 },
},
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", callID: "call_child" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_parent", "input_parent")
expect(permissionRequests).toHaveLength(1)
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_parent",
toolCall: {
toolCallId: "ses_child:call_child",
title: "Review code: /workspace/child.ts",
},
})
expect(fixture.requests).toContainEqual(
expect.objectContaining({
method: "POST",
path: "/api/session/ses_child/permission/perm_child/reply",
}),
)
} finally {
await fixture.stop()
}
})
test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts")
-7
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => {
@@ -40,17 +39,11 @@ describe("acp service", () => {
})
try {
const initialized = await service.initialize({
protocolVersion: 1,
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
clientInfo: { name: "test", version: "1" },
})
const result = await service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
})
expect(result.sessionId).toBe("ses_acp")
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",