mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0257cd0a | ||
|
|
b276356986 |
@@ -244,12 +244,6 @@ jobs:
|
||||
CI: true
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Verify service worker precaching and upgrades
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_large_paste"
|
||||
const directory = "/repo/large-paste"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_large_paste",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "large-paste",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
const input = page.locator('[data-component="composer-editor"]')
|
||||
await expectAppVisible(input)
|
||||
await expect(input).toBeEditable()
|
||||
await expect
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
await input.click()
|
||||
})
|
||||
|
||||
for (const lines of [6000, 25000]) {
|
||||
test(`keeps a ${lines}-line crash report editable in a new session`, async ({ page }) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(lines) + "End of report"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
const events = await input.evaluateHandle((element) => {
|
||||
const events = { count: 0 }
|
||||
element.addEventListener("input", () => events.count++)
|
||||
return events
|
||||
})
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
"\n\n indented\ttext \n\nlast\n\n",
|
||||
'literal <b>bold</b> & & < > "quotes"\n<script>not code</script>\n<img src="example">',
|
||||
"first\r\nsecond\rthird",
|
||||
]) {
|
||||
test(`preserves text and native undo: ${JSON.stringify(text)}`, async ({ page }) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
const expected = text.replace(/\r\n?/g, "\n")
|
||||
await expect.poll(() => input.innerText()).toBe(expected)
|
||||
await expect(input.locator("b, script, img")).toHaveCount(0)
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toBeEmpty()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+Z")
|
||||
await expect.poll(() => input.innerText()).toBe(expected)
|
||||
})
|
||||
}
|
||||
|
||||
test("replaces only the selected text and leaves the caret after the paste", async ({ page }) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await page.evaluate(() => navigator.clipboard.writeText("one\ntwo"))
|
||||
await page.keyboard.type("before replace after")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const word = await input.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(element.firstChild!, 7)
|
||||
range.setEnd(element.firstChild!, 14)
|
||||
const rect = range.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
|
||||
})
|
||||
await page.mouse.move(word.x, word.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("replace")
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.keyboard.press("ControlOrMeta+Shift+Z")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo! after")
|
||||
})
|
||||
@@ -1,178 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:\\OpenCode\\main"
|
||||
const workspace = "C:\\OpenCode\\worktree"
|
||||
const projectID = "proj_mcp_workspace"
|
||||
const sessionID = "ses_mcp_workspace"
|
||||
const title = "Workspace MCP routing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
for (const shared of [true, false]) {
|
||||
test(`toggles the workspace MCP when the default location ${shared ? "has" : "does not have"} the server`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const connected = new Set<string>()
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "mcp-workspace",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [workspace],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/mcp/figma-desktop/connect") {
|
||||
connected.add(target)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
|
||||
connected.delete(target)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: !shared && target !== workspace
|
||||
? []
|
||||
: [{ name: "figma-desktop", status: { status: connected.has(target) ? "connected" : "disabled" } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+;")
|
||||
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
const toggle = dialog.getByRole("switch")
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
|
||||
await dialog.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(connected).toEqual(new Set([workspace]))
|
||||
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/connect", directory: workspace })
|
||||
expect(requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
|
||||
expect(requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
await testInfo.attach("workspace-connected", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
requests.length = 0
|
||||
await dialog.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(connected.size).toBe(0)
|
||||
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/disconnect", directory: workspace })
|
||||
expect(requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
for (const surface of ["popover", "dialog"] as const) {
|
||||
test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }, testInfo) => {
|
||||
const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found"
|
||||
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "mcp-workspace",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [workspace],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/mcp/figma-desktop/connect") {
|
||||
state.status = state.fail ? "failed" : "connected"
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "figma-desktop",
|
||||
status: { status: target === workspace ? state.status : "connected", error },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
const panel =
|
||||
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
const toggle = panel.getByRole("switch")
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
const toast = page
|
||||
.getByRole("listitem", { includeHidden: true })
|
||||
.filter({ has: page.getByText("Request failed", { exact: true }) })
|
||||
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
|
||||
])
|
||||
expect(requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
await expect(toast).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("mcp-connection-error", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
if (surface === "popover") await page.keyboard.press("Escape")
|
||||
if (surface === "dialog") await panel.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(panel).toBeHidden()
|
||||
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
|
||||
await expect(toast).toBeHidden()
|
||||
state.fail = false
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
await expect(toggle).toBeEnabled()
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(toast).toBeHidden()
|
||||
})
|
||||
}
|
||||
@@ -261,7 +261,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
const transcript = page.locator("[data-timeline-virtual-content]")
|
||||
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
|
||||
await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible()
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(thinking).toBeVisible()
|
||||
await expect(view.input).toBeEditable()
|
||||
await view.input.fill(followUp)
|
||||
await view.input.press("Enter")
|
||||
@@ -274,14 +274,12 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
const queued = view.rows.filter({ hasText: followUp })
|
||||
await expect(queued).toBeVisible()
|
||||
await expect(pending).toHaveCount(0)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await queued.hover()
|
||||
await queued.getByRole("button", { name: "Steer", exact: true }).click()
|
||||
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
|
||||
}
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(pending).toContainText(followUp)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
|
||||
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
|
||||
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
|
||||
@@ -310,7 +308,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toContainText(/Used\s*Read, Grep/)
|
||||
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(thinking).toBeVisible()
|
||||
await expect(pending).toBeVisible()
|
||||
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
|
||||
{ id: inboxID, delivery: "steer" },
|
||||
@@ -318,21 +316,27 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/Used\s*Read, Grep/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(tools.or(thinking).or(pending))
|
||||
.toHaveText([/Used\s*Read, Grep/, /Thinking/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
await expect
|
||||
.configure({ soft: true })
|
||||
.poll(async () => {
|
||||
const boxes = await Promise.all([tools.boundingBox(), pending.boundingBox()])
|
||||
return boxes.every((box) => box !== null) && boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y
|
||||
const boxes = await Promise.all([tools.boundingBox(), thinking.boundingBox(), pending.boundingBox()])
|
||||
return (
|
||||
boxes.every((box) => box !== null) &&
|
||||
boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y &&
|
||||
boxes[1]!.y + boxes[1]!.height <= boxes[2]!.y
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
mock.rows.splice(0, 1)
|
||||
mock.emit("session.inbox.delivered", { sessionID, inboxID })
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(thinking).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(pending).toHaveCount(1)
|
||||
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
|
||||
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
|
||||
@@ -348,11 +352,11 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
.locator('[data-timeline-row="AssistantPart"]')
|
||||
.filter({ hasText: "A3: Now checking the retry path for U2." })
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
await expect(tools.or(pending).or(response).or(thinking)).toHaveText([
|
||||
/Used\s*Read, Grep/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
/Thinking/,
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, ShellInfo } from "@opencode-ai/client/promise"
|
||||
import { directory, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const shell = {
|
||||
id: "sh_background",
|
||||
status: "running",
|
||||
command: "bun run check",
|
||||
cwd: directory,
|
||||
shell: "bash",
|
||||
file: "/tmp/check.out",
|
||||
metadata: { sessionID },
|
||||
time: { started: 2 },
|
||||
} satisfies ShellInfo
|
||||
|
||||
for (const grouped of [false, true]) {
|
||||
for (const status of ["exited", "killed", "timeout"] as const) {
|
||||
test(`stops ${grouped ? "grouped" : "standalone"} background shell shimmer when ${status}`, async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const message: SessionMessageAssistant = {
|
||||
id: "msg_background",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [shell.id, "sh_other"].map((id) => ({
|
||||
type: "tool",
|
||||
id: `call_${id}`,
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: shell.command },
|
||||
content: [{ type: "text", text: "Command moved to the background." }],
|
||||
metadata: { shellID: id, status: "running" },
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
})),
|
||||
time: { created: 2, completed: 3 },
|
||||
}
|
||||
if (grouped)
|
||||
message.content.unshift({
|
||||
type: "tool",
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "package.json" },
|
||||
content: [{ type: "text", text: "{}" }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
})
|
||||
const timeline = await setupTimeline(page, {
|
||||
viewport: { width: grouped ? 390 : 1400, height: 900 },
|
||||
settings: { shellToolPartsExpanded: !grouped },
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
sessionMessages: [
|
||||
{ id: "msg_user", type: "user", text: "Run two independent checks.", time: { created: 1 } },
|
||||
message,
|
||||
],
|
||||
})
|
||||
const state = { finished: false, requests: 0 }
|
||||
await page.route("**/api/shell?*", (route) =>
|
||||
route.fulfill({
|
||||
json: { location: { directory }, data: [...(state.finished ? [] : [shell]), { ...shell, id: "sh_other" }] },
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/shell/*/output?*", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.pathname.includes(`/${shell.id}/`)
|
||||
if (target) state.requests++
|
||||
const output = target && state.finished ? "Checking project\nCheck finished\n" : "Checking project\n"
|
||||
const cursor = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const end = Math.min(output.length, cursor + 17)
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: {
|
||||
output: output.slice(cursor, end),
|
||||
cursor: end,
|
||||
size: output.length,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.clock.install()
|
||||
await page.reload()
|
||||
await timeline.transport.waitForConnection()
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const groupTrigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
if (grouped) {
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "call_read,call_sh_background,call_sh_other")
|
||||
await expect(groupTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await groupTrigger.click()
|
||||
}
|
||||
const card = page.locator(`[data-timeline-part-id="call_${shell.id}"]`)
|
||||
const shimmer = card.locator('[data-component="text-shimmer"]')
|
||||
const other = page.locator('[data-timeline-part-id="call_sh_other"] [data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(other).toHaveAttribute("data-active", "true")
|
||||
if (grouped) await card.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
|
||||
|
||||
state.finished = true
|
||||
await timeline.transport.send({
|
||||
id: "evt_shell_exited",
|
||||
created: 4,
|
||||
type: "shell.exited",
|
||||
location: { directory },
|
||||
data: { id: shell.id, status, exit: status === "exited" ? 0 : 1 },
|
||||
})
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(other).toHaveAttribute("data-active", "true")
|
||||
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project\nCheck finished")
|
||||
await expect(card.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await page.locator("[data-timeline-virtual-content]").screenshot({ path: info.outputPath("shell-finished.png") })
|
||||
|
||||
const requests = state.requests
|
||||
await page.clock.fastForward(5_000)
|
||||
expect(state.requests).toBe(requests)
|
||||
|
||||
await page.reload()
|
||||
if (grouped) await groupTrigger.click()
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(other).toHaveAttribute("data-active", "true")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("shows the authoritative foreground result after streaming shell output", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
sessionMessages: [
|
||||
{ id: "msg_user", type: "user", text: "Run the check.", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_foreground",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_foreground",
|
||||
name: "shell",
|
||||
state: { status: "running", input: { command: shell.command }, metadata: { shellID: shell.id } },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.route("**/api/shell/*/output?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: {
|
||||
output: Number(new URL(route.request().url()).searchParams.get("cursor")) === 0 ? "Checking project\n" : "",
|
||||
cursor: 17,
|
||||
size: 17,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
await timeline.transport.waitForConnection()
|
||||
const card = page.locator('[data-timeline-part-id="call_foreground"]')
|
||||
const shimmer = card.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(card.locator('[data-slot="bash-result"]')).toHaveText("Checking project")
|
||||
await timeline.transport.send({
|
||||
id: "evt_foreground_complete",
|
||||
created: 3,
|
||||
type: "session.tool.success",
|
||||
durable: { aggregateID: sessionID, seq: 0, version: 2 },
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_foreground",
|
||||
id: "call_foreground",
|
||||
executed: true,
|
||||
content: [{ type: "text", text: "Checking project\nCheck finished\nCommand exited with code 0." }],
|
||||
metadata: { status: "completed", exit: 0 },
|
||||
},
|
||||
})
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(card.locator('[data-slot="bash-result"]')).toHaveText(
|
||||
"Checking project\nCheck finished\nCommand exited with code 0.",
|
||||
)
|
||||
})
|
||||
@@ -84,39 +84,6 @@ const assistantMessage = {
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
test.describe("regression: session timeline local row state", () => {
|
||||
test("preserves a patch file choice as new calls join its Used group", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
const part = { ...editPart, tool: "patch" }
|
||||
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
|
||||
await configurePage(page, false)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "Used Patch", exact: true })
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
})
|
||||
const wrapper = group.locator('[data-disclosure-probe="existing"]')
|
||||
const trigger = wrapper.locator('[data-scope="apply-patch"] button')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const original = await wrapper.elementHandle()
|
||||
|
||||
for (const count of [2, 3]) {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "true")
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await mockServer(page, events)
|
||||
@@ -241,19 +208,19 @@ test.describe("regression: session timeline local row state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function configurePage(page: Page, expanded = true) {
|
||||
await page.addInitScript((expanded) => {
|
||||
async function configurePage(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: expanded,
|
||||
shellToolPartsExpanded: expanded,
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, expanded)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectExpanded(locator: Locator, expected: boolean) {
|
||||
|
||||
@@ -109,85 +109,31 @@ test("shimmers and expands a running shell command", async ({ page }) => {
|
||||
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
||||
})
|
||||
|
||||
for (const open of [false, true]) {
|
||||
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const reasoningID = `prt_reasoning_hidden_${open}`
|
||||
const shellID = `prt_reasoning_shell_${open}`
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "false")
|
||||
await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.send(partUpdated(shell(shellID, "running")))
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(thought).toContainText("Thought")
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
await timeline.send(status("idle"))
|
||||
const used = group.getByRole("button", { name: "Used Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
})
|
||||
}
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
for (const transition of ["reasoning-end", "idle", "retry"] as const) {
|
||||
test(`stops active Thinking on ${transition} without a following tool`, async ({ page }) => {
|
||||
const id = `prt_reasoning_stop_${transition}`
|
||||
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(transition === "reasoning-end" ? partUpdated(reasoningPart(id, text)) : status(transition))
|
||||
await expect(trigger).toContainText("Thought")
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(transition === "retry" ? 1 : 0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(part.getByText("The timeline is ready for the next step.", { exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ page }) => {
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
@@ -207,17 +153,18 @@ test("does not infer Thinking from busy, retry, or recovery without reasoning",
|
||||
assistant,
|
||||
],
|
||||
})
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(status("busy"), 140)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"))
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(stepStarted(assistant))
|
||||
await timeline.send(stepStarted(assistant), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")))
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
|
||||
await timeline.send(status("idle"))
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
|
||||
"Recovered response",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
compactionEnded,
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
directory,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
@@ -349,24 +348,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
},
|
||||
})
|
||||
|
||||
await timeline.transport.send({
|
||||
id: "evt_background_shell_created",
|
||||
created: 3,
|
||||
type: "shell.created",
|
||||
location: { directory },
|
||||
data: {
|
||||
info: {
|
||||
id: "shell_backgrounded",
|
||||
status: "running",
|
||||
command: "sleep 120",
|
||||
cwd: directory,
|
||||
shell: "bash",
|
||||
file: "/tmp/background.out",
|
||||
metadata: { sessionID },
|
||||
time: { started: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
|
||||
@@ -4,144 +4,89 @@ import {
|
||||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
reasoningPart(
|
||||
"prt_reasoning_settings",
|
||||
"## Inspecting stability\n\nThe selected mode controls these details.",
|
||||
),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
|
||||
for (const label of ["Full", "Hidden", "Compact"] as const) {
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
|
||||
await expect(select).toHaveAttribute("aria-expanded", "false")
|
||||
await select.click()
|
||||
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
|
||||
await page.getByRole("option", { name: label, exact: true }).click()
|
||||
await expect(select).toHaveText(label)
|
||||
await expect(select).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
|
||||
.toBe(label.toLowerCase())
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
|
||||
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
|
||||
if (label === "Hidden") {
|
||||
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
|
||||
continue
|
||||
}
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
|
||||
if (label === "Full")
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
if (label === "Compact") {
|
||||
await expect(part.getByRole("button")).toContainText("Inspecting stability")
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
|
||||
}
|
||||
}
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(select).toHaveText("Compact")
|
||||
})
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: " ",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "",
|
||||
other: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
// The persisted boolean migrates to compact (false) or full (true).
|
||||
for (const summaries of [false, true]) {
|
||||
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
|
||||
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
...(profile === "none"
|
||||
? []
|
||||
: [
|
||||
reasoningPart(
|
||||
`prt_reasoning_${summaries}_${profile}`,
|
||||
profile === "blank"
|
||||
? " "
|
||||
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
|
||||
),
|
||||
]),
|
||||
...(profile === "tool"
|
||||
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { showReasoningSummaries: summaries },
|
||||
})
|
||||
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
|
||||
profile === "blank" || profile === "heading" ? 1 : 0,
|
||||
)
|
||||
if (profile === "none") {
|
||||
await expect(part).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
if (profile === "blank") {
|
||||
await expect(part).toContainText("Thinking")
|
||||
await expect(part.getByRole("heading")).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
if (profile === "tool") {
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toContainText("UsedSkill")
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
|
||||
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
|
||||
}
|
||||
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
|
||||
const trigger = part.locator('[data-slot="collapsible-trigger"]')
|
||||
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
|
||||
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
|
||||
if (!summaries) {
|
||||
await expect(body).toBeHidden()
|
||||
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
await expect(body).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(body).toBeHidden()
|
||||
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
|
||||
for (const profile of profiles) {
|
||||
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
|
||||
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
|
||||
const parts = [
|
||||
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
|
||||
...(profile.other
|
||||
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts, { completed: false })],
|
||||
settings: { showReasoningSummaries: profile.summaries },
|
||||
})
|
||||
}
|
||||
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="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
|
||||
],
|
||||
settings: { showReasoningSummaries: true },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import pkg from "../../package.json" with { type: "json" }
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
@@ -176,9 +175,6 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
const version = settings.getByRole("tablist").getByText(`v${pkg.version}`, { exact: true })
|
||||
await expect(settings.getByRole("tablist").getByText("OpenCode Desktop", { exact: true })).toBeInViewport()
|
||||
await expect(version).toBeInViewport()
|
||||
await settings.getByRole("tab", { name: "Appearance" }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
|
||||
|
||||
@@ -198,17 +194,6 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
await expect(version).toBeInViewport()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await expect(version).toBeInViewport()
|
||||
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(version).toHaveCSS("direction", "ltr")
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await version.scrollIntoViewIfNeeded()
|
||||
await expect(version).toBeInViewport()
|
||||
})
|
||||
|
||||
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionReady } from "../utils/waits"
|
||||
|
||||
const root = "C:/OpenCode/WorkspaceAccent"
|
||||
const workspace = `${root}/.worktrees/feature`
|
||||
const projectID = "proj_workspace_accent"
|
||||
const sessionID = "ses_workspace_accent"
|
||||
const title = "Workspace accent regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const inventory: WorktreeDirectory[] = [
|
||||
{ directory: root },
|
||||
{ directory: workspace, strategy: "git" },
|
||||
{ directory: "C:/OpenCode/LinkedWorkspace", strategy: "git" },
|
||||
{ directory: "C:/OpenCode/WorkspaceCopy", strategy: "copy" },
|
||||
{ directory: "C:/OpenCode/RegisteredDirectory" },
|
||||
]
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
|
||||
})
|
||||
}
|
||||
|
||||
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
|
||||
page,
|
||||
}) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
canonical: root,
|
||||
worktree: root,
|
||||
vcs: "git",
|
||||
name: "workspace-accent",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "accent-model": { id: "accent-model", name: "Accent Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "accent-model" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
model: { id: "accent-model", providerID: "opencode" },
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
id: "msg_workspace_accent",
|
||||
type: "user",
|
||||
text: "Check this fixture workspace.",
|
||||
time: { created: 1700000000000 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
// Keep authoritative inventory independent of the raw project's empty sandboxes.
|
||||
await page.route(`**/api/worktree/${projectID}`, (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "light")
|
||||
})
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
expect((await loaded).ok()).toBe(true)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expectAppVisible(composer)
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(input).toBeEditable()
|
||||
await expect(composer.locator('[data-action="composer-model"]')).toHaveText("Accent Model")
|
||||
return { composer, input, send: composer.getByRole("button", { name: "Send", exact: true }), events, worktrees }
|
||||
}
|
||||
|
||||
async function expectBackground(element: Locator, token: string, property = "background-image") {
|
||||
const color = await element.evaluate((element, token) => {
|
||||
// Resolve semantic colors through the browser, without reproducing the button's gradient.
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
probe.style.backgroundColor = `var(--v2-background-bg-${token})`
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).backgroundColor
|
||||
probe.remove()
|
||||
return color
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
}
|
||||
@@ -1,124 +1,60 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
|
||||
import { createServer, type ServerResponse } from "node:http"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { once } from "node:events"
|
||||
import { createHash } from "node:crypto"
|
||||
import { join, extname, relative, sep } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { build } from "vite"
|
||||
import { serviceWorker } from "../../vite.pwa"
|
||||
|
||||
type Site = {
|
||||
url: string
|
||||
deploy: (fault?: "failed" | "html" | "corrupt" | "mixed-html" | "blocked") => void
|
||||
legacy: () => void
|
||||
requests: string[]
|
||||
release: () => void
|
||||
}
|
||||
const legacy = `
|
||||
self.addEventListener("install", event => event.waitUntil(
|
||||
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache =>
|
||||
cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"])
|
||||
)
|
||||
))
|
||||
self.addEventListener("fetch", event => {
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(caches.match("/index.html"))
|
||||
return
|
||||
}
|
||||
event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)))
|
||||
})
|
||||
`
|
||||
|
||||
const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<string, Buffer>> }>({
|
||||
builds: [
|
||||
async ({}, use) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-precache-"))
|
||||
const builds: Record<string, Record<string, Buffer>> = {}
|
||||
try {
|
||||
for (const version of ["old", "new"]) {
|
||||
const root = join(directory, version)
|
||||
const outDir = join(root, "dist")
|
||||
await mkdir(join(root, "public", "nested"), { recursive: true })
|
||||
await Promise.all(
|
||||
Object.entries({
|
||||
"index.html": `<html><head></head><body><h1>Loading</h1><label>Draft<textarea></textarea></label><button>Load lazy</button><output></output><script type="module" src="/main.js"></script></body></html>`,
|
||||
"main.js": `document.querySelector("h1").textContent = "${version}";
|
||||
document.querySelector("button").onclick = async () => {
|
||||
document.querySelector("output").textContent = await (await import("./lazy.js")).load()
|
||||
};`,
|
||||
"lazy.js": `export async function load() { return (await import("./nested.js")).value }`,
|
||||
"nested.js": `export const value = "${version} nested lazy loaded"`,
|
||||
"public/nested/data.json": JSON.stringify({ version }),
|
||||
"public/nested/font.woff2": `font-${version}`,
|
||||
"public/nested/module.wasm": Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]),
|
||||
"public/large.bin": Buffer.alloc(2 * 1024 * 1024 + 1, version === "old" ? 1 : 2),
|
||||
"public/_headers": "/*\n Cache-Control: no-cache",
|
||||
"public/_redirects": "/* /index.html 200",
|
||||
}).map(([path, contents]) => writeFile(join(root, path), contents)),
|
||||
)
|
||||
await build({
|
||||
configFile: false,
|
||||
root,
|
||||
logLevel: "silent",
|
||||
build: { outDir, assetsDir: "_assets", sourcemap: true },
|
||||
plugins: serviceWorker(outDir),
|
||||
})
|
||||
builds[version] = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await readdir(outDir, { recursive: true, withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile())
|
||||
.map(async (entry) => {
|
||||
const path = join(entry.parentPath, entry.name)
|
||||
return ["/" + relative(outDir, path).split(sep).join("/"), await readFile(path)]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
await use(builds)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
site: async ({ builds }, use) => {
|
||||
const state = { version: "old", fault: "", legacy: false }
|
||||
const requests: string[] = []
|
||||
const blocked: ServerResponse[] = []
|
||||
const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"]))
|
||||
const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({
|
||||
site: async ({}, use) => {
|
||||
const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8")
|
||||
const state = { version: "old", repaired: false }
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://localhost").pathname
|
||||
requests.push(path)
|
||||
const pathname = new URL(request.url ?? "/", "http://localhost").pathname
|
||||
const prefix = state.version === "old" ? "/assets" : "/_assets"
|
||||
response.setHeader("cache-control", "no-store")
|
||||
if (path === "/observer.html")
|
||||
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
|
||||
if (path === "/api/health")
|
||||
return void response.writeHead(200, { "content-type": "application/json" }).end('{"healthy":true}')
|
||||
if (path === "/sw.js" && state.legacy && state.version === "old") {
|
||||
// Model the shipped worker's shared precache name and cache-first navigation behavior.
|
||||
const urls = Object.keys(builds.old).filter(
|
||||
(path) => path === "/index.html" || (path.startsWith("/_assets/") && path.endsWith(".js")),
|
||||
)
|
||||
if (pathname === "/sw.js") {
|
||||
response.setHeader("content-type", "text/javascript")
|
||||
return void response.end(`
|
||||
self.addEventListener("install", event => event.waitUntil(
|
||||
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache => cache.addAll(${JSON.stringify(urls)}))
|
||||
));
|
||||
self.addEventListener("fetch", event => event.respondWith(
|
||||
caches.match(event.request.mode === "navigate" ? "/index.html" : event.request)
|
||||
.then(response => response || fetch(event.request))
|
||||
));
|
||||
response.end(state.version === "old" ? legacy : worker)
|
||||
return
|
||||
}
|
||||
if (pathname === `${prefix}/app-${state.version}.js`) {
|
||||
response.setHeader("content-type", "text/javascript")
|
||||
response.end(`import "${prefix}/startup-${state.version}.js"`)
|
||||
return
|
||||
}
|
||||
if (pathname === `${prefix}/startup-${state.version}.js`) {
|
||||
response.setHeader("content-type", "text/javascript")
|
||||
response.end(`
|
||||
document.getElementById("root").innerHTML = '<h1>${state.version}</h1><label>Draft<input></label><button>Load older chunk</button><output></output>'
|
||||
document.querySelector("button").onclick = () => import("/assets/lazy-old.js")
|
||||
`)
|
||||
return
|
||||
}
|
||||
if (path === "/index.html" && state.fault === "mixed-html")
|
||||
return void response.writeHead(200, { "content-type": "text/html" }).end(builds.old["/index.html"])
|
||||
if (path === "/large.bin" && state.fault && state.fault !== "mixed-html") {
|
||||
if (state.fault === "blocked") return void blocked.push(response)
|
||||
if (state.fault === "failed") return void response.writeHead(503).end("Unavailable")
|
||||
if (state.fault === "html")
|
||||
return void response.writeHead(200, { "content-type": "text/html" }).end("<html>Wrong fallback</html>")
|
||||
return void response.end("Incorrect bytes with a successful status")
|
||||
if (
|
||||
(pathname === "/assets/lazy-old.js" && state.version === "old") ||
|
||||
(pathname === "/_assets/retry.js" && state.repaired)
|
||||
) {
|
||||
response.setHeader("content-type", "text/javascript")
|
||||
response.end('document.querySelector("output").textContent = "Older chunk loaded"')
|
||||
return
|
||||
}
|
||||
const file = builds[state.version][path]
|
||||
const types: Record<string, string> = {
|
||||
".js": "text/javascript",
|
||||
".html": "text/html",
|
||||
".json": "application/json",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream")
|
||||
if (file) return void response.end(file)
|
||||
if (extname(path)) return void response.writeHead(404).end("Not found")
|
||||
// Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself.
|
||||
response.setHeader("content-type", "text/html")
|
||||
response.end(builds[state.version]["/index.html"])
|
||||
response.end(`<div id="root"></div><script type="module" src="${prefix}/app-${state.version}.js"></script>`)
|
||||
})
|
||||
server.listen(0, "127.0.0.1")
|
||||
await once(server, "listening")
|
||||
@@ -127,265 +63,75 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
|
||||
try {
|
||||
await use({
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
deploy: (fault = undefined) => {
|
||||
state.version = "new"
|
||||
state.fault = fault ?? ""
|
||||
},
|
||||
legacy: () => {
|
||||
state.legacy = true
|
||||
},
|
||||
requests,
|
||||
release,
|
||||
upgrade: () => (state.version = "new"),
|
||||
repair: () => (state.repaired = true),
|
||||
})
|
||||
} finally {
|
||||
release()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function install(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => {
|
||||
await page.goto(site.url)
|
||||
await expect(page.getByRole("heading")).toHaveText("old")
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.register("/sw.js")
|
||||
await navigator.serviceWorker.ready
|
||||
})
|
||||
await page.reload()
|
||||
await expect.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.state)).toBe("activated")
|
||||
await page.goto(site.url)
|
||||
await expect(page.getByRole("heading")).toHaveText("old")
|
||||
}
|
||||
await page.getByLabel("Draft").fill("Keep this unsent prompt")
|
||||
|
||||
async function update(page: Page) {
|
||||
return page.evaluateHandle(async () => {
|
||||
site.upgrade()
|
||||
await page.evaluate(async () => {
|
||||
const cache = await caches.open("opencode-assets")
|
||||
await cache.put(
|
||||
"/_assets/startup-new.js",
|
||||
new Response("<html>stale fallback</html>", {
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
)
|
||||
const changed = new Promise<void>((resolve) =>
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }),
|
||||
)
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) throw new Error("Missing installed worker")
|
||||
const found = new Promise<ServiceWorker>((resolve) =>
|
||||
registration.addEventListener(
|
||||
"updatefound",
|
||||
() => {
|
||||
if (!registration.installing) throw new Error("Missing installing worker")
|
||||
resolve(registration.installing)
|
||||
},
|
||||
{ once: true },
|
||||
if (!registration) throw new Error("Missing legacy worker")
|
||||
await registration.update()
|
||||
await changed
|
||||
})
|
||||
|
||||
await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
|
||||
await page.getByRole("button", { name: "Load older chunk" }).click()
|
||||
await expect(page.getByRole("status")).toHaveText("Older chunk loaded")
|
||||
|
||||
await page.goto(`${site.url}/workspace/example`)
|
||||
await expect(page.getByRole("heading")).toHaveText("new")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () =>
|
||||
(await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"),
|
||||
),
|
||||
)
|
||||
await registration.update()
|
||||
return found
|
||||
.toBe("text/javascript")
|
||||
})
|
||||
|
||||
fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => {
|
||||
site.upgrade()
|
||||
await page.goto(site.url)
|
||||
await expect(page.getByRole("heading")).toHaveText("new")
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.register("/sw.js")
|
||||
await navigator.serviceWorker.ready
|
||||
})
|
||||
}
|
||||
|
||||
async function waiting(page: Page) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting?.state))
|
||||
.toBe("installed")
|
||||
}
|
||||
|
||||
fixture(
|
||||
"opens an uncached route offline and executes never-used nested lazy chunks",
|
||||
async ({ page, context, site }) => {
|
||||
await install(page, site.url)
|
||||
await expect(page.getByRole("status")).toBeEmpty()
|
||||
await context.setOffline(true)
|
||||
await page.goto(`${site.url}/workspace/never-visited`)
|
||||
await expect(page.getByRole("heading")).toHaveText("old")
|
||||
await page.getByRole("button", { name: "Load lazy" }).click()
|
||||
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
|
||||
},
|
||||
)
|
||||
|
||||
fixture(
|
||||
"precaches public files of every type and size, excluding deployment metadata and source maps",
|
||||
async ({ page, site, builds, context }) => {
|
||||
await install(page, site.url)
|
||||
const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"]
|
||||
await context.setOffline(true)
|
||||
for (const path of files) {
|
||||
const digest = await page.evaluate(
|
||||
async (path) =>
|
||||
Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await (await fetch(path)).arrayBuffer()))),
|
||||
path,
|
||||
)
|
||||
expect(Buffer.from(digest)).toEqual(createHash("sha256").update(builds.old[path]).digest())
|
||||
}
|
||||
expect(site.requests).not.toContain("/_headers")
|
||||
expect(site.requests).not.toContain("/_redirects")
|
||||
expect(site.requests.filter((path) => path.endsWith(".map"))).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
fixture(
|
||||
"keeps drafts and removed old lazy chunks until every controlled tab closes",
|
||||
async ({ page, context, site, builds }) => {
|
||||
await install(page, site.url)
|
||||
const second = await context.newPage()
|
||||
await second.goto(site.url)
|
||||
await expect(second.getByRole("heading")).toHaveText("old")
|
||||
await second.getByLabel("Draft").fill("Keep this unsent prompt")
|
||||
|
||||
site.deploy()
|
||||
const created = context.waitForEvent("serviceworker")
|
||||
const worker = await update(page)
|
||||
const replacement = await created
|
||||
await waiting(page)
|
||||
expect(await worker.evaluate((worker) => worker.state)).toBe("installed")
|
||||
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
|
||||
await page.close()
|
||||
await waiting(second)
|
||||
await expect(second.getByRole("heading")).toHaveText("old")
|
||||
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
|
||||
|
||||
const removed = Object.keys(builds.old).find((path) => path.includes("/nested-") && path.endsWith(".js"))
|
||||
expect(removed).toBeDefined()
|
||||
expect((await second.request.get(`${site.url}${removed}`)).status()).toBe(404)
|
||||
await second.getByRole("button", { name: "Load lazy" }).click()
|
||||
await expect(second.getByRole("status")).toHaveText("old nested lazy loaded")
|
||||
await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
|
||||
await second.close()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
replacement.evaluate(() => {
|
||||
const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration
|
||||
return { waiting: !!registration.waiting, active: registration.active?.state }
|
||||
}),
|
||||
)
|
||||
.toEqual({ waiting: false, active: "activated" })
|
||||
await context.setOffline(true)
|
||||
const observer = await context.newPage()
|
||||
await observer.goto(`${site.url}/workspace/reopened`)
|
||||
await expect(observer.getByRole("heading")).toHaveText("new")
|
||||
await observer.getByRole("button", { name: "Load lazy" }).click()
|
||||
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
|
||||
},
|
||||
)
|
||||
|
||||
for (const fault of ["failed", "html", "corrupt", "mixed-html"] as const) {
|
||||
fixture(`retains the old complete build when a precache download is ${fault}`, async ({ page, context, site }) => {
|
||||
await install(page, site.url)
|
||||
await page.getByLabel("Draft").fill("Still editing")
|
||||
site.deploy(fault)
|
||||
const worker = await update(page)
|
||||
await expect.poll(() => worker.evaluate((worker) => worker.state)).toBe("redundant")
|
||||
expect(await page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting)).toBeNull()
|
||||
await expect(page.getByLabel("Draft")).toHaveValue("Still editing")
|
||||
await context.setOffline(true)
|
||||
await page.goto(`${site.url}/workspace/after-failure`)
|
||||
await expect(page.getByRole("heading")).toHaveText("old")
|
||||
await page.getByRole("button", { name: "Load lazy" }).click()
|
||||
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
|
||||
})
|
||||
}
|
||||
|
||||
fixture("does not expose new HTML while a precache download is blocked", async ({ page, context, site }) => {
|
||||
await install(page, site.url)
|
||||
site.requests.length = 0
|
||||
site.deploy("blocked")
|
||||
const worker = await update(page)
|
||||
await expect.poll(() => site.requests.includes("/large.bin")).toBe(true)
|
||||
expect(await worker.evaluate((worker) => worker.state)).toBe("installing")
|
||||
const second = await context.newPage()
|
||||
await second.goto(`${site.url}/workspace/during-install`)
|
||||
await expect(second.getByRole("heading")).toHaveText("old")
|
||||
site.release()
|
||||
await waiting(page)
|
||||
await second.reload()
|
||||
await expect(second.getByRole("heading")).toHaveText("old")
|
||||
})
|
||||
|
||||
fixture("upgrades the legacy shared precache only after old tabs close", async ({ page, context, site, builds }) => {
|
||||
site.legacy()
|
||||
const observer = await context.newPage()
|
||||
await observer.goto(`${site.url}/observer.html`)
|
||||
await install(page, site.url)
|
||||
await page.getByLabel("Draft").fill("Legacy unsent prompt")
|
||||
// A stale runtime-cache HTML response must not contaminate the new generated precache.
|
||||
const entry = Object.keys(builds.new).find((path) => path.includes("/index-") && path.endsWith(".js"))
|
||||
expect(entry).toBeDefined()
|
||||
await page.evaluate(async (entry) => {
|
||||
await (
|
||||
await caches.open("opencode-assets")
|
||||
).put(entry!, new Response("<html>stale fallback</html>", { headers: { "content-type": "text/html" } }))
|
||||
}, entry)
|
||||
site.deploy()
|
||||
await update(page)
|
||||
await waiting(page)
|
||||
await expect(page.getByLabel("Draft")).toHaveValue("Legacy unsent prompt")
|
||||
await page.getByRole("button", { name: "Load lazy" }).click()
|
||||
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
|
||||
await page.close()
|
||||
await expect
|
||||
.poll(() => observer.evaluate(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting))
|
||||
.toBe(false)
|
||||
await context.setOffline(true)
|
||||
await observer.goto(`${site.url}/workspace/legacy-upgraded`)
|
||||
await expect(observer.getByRole("heading")).toHaveText("new")
|
||||
await observer.getByRole("button", { name: "Load lazy" }).click()
|
||||
await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded")
|
||||
})
|
||||
|
||||
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
|
||||
await install(page, site.url)
|
||||
const api = await page.goto(`${site.url}/api/health`)
|
||||
expect(await api?.json()).toEqual({ healthy: true })
|
||||
expect(api?.fromServiceWorker()).toBe(false)
|
||||
const asset = await page.goto(`${site.url}/_assets/missing.js`)
|
||||
expect(asset?.status()).toBe(404)
|
||||
expect(await asset?.text()).toBe("Not found")
|
||||
})
|
||||
|
||||
test("the production build precaches every deployable file", async ({ page, context }) => {
|
||||
const directory = new URL("../../dist/", import.meta.url)
|
||||
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
|
||||
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
|
||||
expect(files.length).toBeGreaterThan(1)
|
||||
const server = createServer(async (request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://localhost").pathname
|
||||
response.setHeader("cache-control", "no-store")
|
||||
if (path === "/probe.html")
|
||||
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Precache probe</title>")
|
||||
const bytes = await readFile(new URL(`.${path}`, directory)).catch(() => undefined)
|
||||
if (!bytes) return void response.writeHead(404).end("Not found")
|
||||
if (path.endsWith(".js")) response.setHeader("content-type", "text/javascript")
|
||||
if (path.endsWith(".html")) {
|
||||
response.setHeader("content-type", "text/html")
|
||||
// Inspect the real cached HTML without executing the app or contacting a backend.
|
||||
response.setHeader("content-security-policy", "default-src 'none'")
|
||||
}
|
||||
response.end(bytes)
|
||||
})
|
||||
server.listen(0, "127.0.0.1")
|
||||
await once(server, "listening")
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
|
||||
const url = `http://127.0.0.1:${address.port}`
|
||||
try {
|
||||
await page.goto(`${url}/probe.html`)
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.register("/sw.js")
|
||||
await navigator.serviceWorker.ready
|
||||
})
|
||||
const cached = await page.evaluate(async () =>
|
||||
(
|
||||
await Promise.all(
|
||||
(await caches.keys()).map(async (name) =>
|
||||
(await (await caches.open(name)).keys()).map((request) => new URL(request.url).pathname),
|
||||
),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort(),
|
||||
)
|
||||
expect(cached).toEqual(files.sort())
|
||||
await context.setOffline(true)
|
||||
const response = await page.goto(`${url}/workspace/offline-probe`)
|
||||
expect(response?.fromServiceWorker()).toBe(true)
|
||||
expect(await response?.text()).toBe(await readFile(new URL("index.html", directory), "utf8"))
|
||||
} finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
||||
}
|
||||
await page.goto(site.url)
|
||||
await expect(page.getByRole("heading")).toHaveText("new")
|
||||
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
|
||||
"text/html",
|
||||
)
|
||||
site.repair()
|
||||
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
|
||||
"text/javascript",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
// Tiny fixture builds do not need a Rolldown thread for every host CPU.
|
||||
process.env.RAYON_NUM_THREADS ??= "2"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.spec.ts",
|
||||
outputDir: "../test-results/service-worker",
|
||||
timeout: 60_000,
|
||||
workers: 1,
|
||||
expect: { timeout: 15_000 },
|
||||
timeout: 30_000,
|
||||
use: { browserName: "chromium" },
|
||||
})
|
||||
|
||||
@@ -384,18 +384,10 @@ export function createComposerEditor(input: {
|
||||
void attachments.handlePaste(event)
|
||||
return
|
||||
}
|
||||
const text = clipboard?.getData("text/plain").replace(/\r\n?/g, "\n")
|
||||
const text = clipboard?.getData("text/plain")
|
||||
if (!text) return
|
||||
event.preventDefault()
|
||||
// insertText emits input events per line, repeatedly parsing and saving the draft.
|
||||
// Escaped HTML inserts multiline text once and preserves native selection and undo.
|
||||
const multiline = text.includes("\n")
|
||||
const value = multiline ? text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") : text
|
||||
if (
|
||||
typeof document.execCommand === "function" &&
|
||||
document.execCommand(multiline ? "insertHTML" : "insertText", false, value)
|
||||
)
|
||||
return
|
||||
if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
|
||||
const target = event.currentTarget
|
||||
const selection = window.getSelection()
|
||||
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
|
||||
|
||||
@@ -25,7 +25,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = useMcpToggle(() => sdk().directory)
|
||||
const toggle = useMcpToggle()
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
||||
@@ -40,9 +40,6 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
data.location.mcp.resource.invalidate(ref)
|
||||
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
// A successful HTTP response can still leave the MCP connection in a failed state.
|
||||
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
|
||||
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
|
||||
},
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
|
||||
@@ -983,11 +983,6 @@ export const dict = {
|
||||
"settings.general.row.followUpBehavior.steer": "Steer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.reasoningMode.title": "Model reasoning",
|
||||
"settings.general.row.reasoningMode.description": "Choose how model reasoning is displayed in the timeline",
|
||||
"settings.general.row.reasoningMode.hidden": "Hidden",
|
||||
"settings.general.row.reasoningMode.compact": "Compact",
|
||||
"settings.general.row.reasoningMode.full": "Full",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Show shell tool parts expanded by default in the timeline",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { ServerApiConnection } from "@/runtime/server/api"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -74,6 +75,9 @@ type PlatformBase = {
|
||||
/** Fetch override */
|
||||
fetch?: typeof fetch
|
||||
|
||||
/** Optional owned server transport; browser web defaults to HTTP. */
|
||||
createServerApi?(server: ServerConnection.HttpBase): ServerApiConnection
|
||||
|
||||
/** Get the configured default server URL (platform-specific) */
|
||||
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||
|
||||
|
||||
@@ -36,3 +36,8 @@ export function createApiForServer(input: {
|
||||
}
|
||||
|
||||
export type ServerApi = OpenCodeClient
|
||||
|
||||
export type ServerApiConnection = {
|
||||
readonly api: ServerApi
|
||||
readonly dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { OpenCode, type OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource, createServerTransport } from "./client"
|
||||
|
||||
@@ -99,7 +99,7 @@ test("rotates HTTP and PTY clients together", async () => {
|
||||
const initialPty = transport.pty
|
||||
|
||||
await transport.api.health.get()
|
||||
const replacement = transport.update({
|
||||
const replacement = await transport.update({
|
||||
url: "http://127.0.0.1:4200",
|
||||
username: "opencode",
|
||||
password: "second",
|
||||
@@ -120,3 +120,68 @@ test("rotates HTTP and PTY clients together", async () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses the platform transport and disposes it before rotating credentials", async () => {
|
||||
const lifecycle: string[] = []
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100", password: "first" },
|
||||
fetch: (async (_input: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
||||
throw new Error("The injected transport must not fall back to HTTP")
|
||||
}) as typeof fetch,
|
||||
createApi: (http) => {
|
||||
lifecycle.push(`create:${http.password}`)
|
||||
return {
|
||||
api: OpenCode.make({ baseUrl: http.url }),
|
||||
dispose: async () => {
|
||||
lifecycle.push(`dispose:${http.password}`)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
const first = transport.api
|
||||
const pty = transport.pty
|
||||
await transport.update({ url: "http://127.0.0.1:4200", password: "second" })
|
||||
expect(transport.api).not.toBe(first)
|
||||
expect(transport.pty).not.toBe(pty)
|
||||
expect(transport.http.password).toBe("second")
|
||||
await transport.dispose()
|
||||
await transport.dispose()
|
||||
expect(lifecycle).toEqual(["create:first", "dispose:first", "create:second", "dispose:second"])
|
||||
})
|
||||
|
||||
test("cannot reopen a disposed owner while reconnection is resolving", async () => {
|
||||
const released = Promise.withResolvers<void>()
|
||||
const clients: string[] = []
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100" },
|
||||
createApi: (http) => {
|
||||
clients.push(http.url)
|
||||
return { api: OpenCode.make({ baseUrl: http.url }), dispose: () => released.promise }
|
||||
},
|
||||
})
|
||||
const update = transport.update({ url: "http://127.0.0.1:4200" })
|
||||
const result = update.catch((error) => error)
|
||||
const disposal = transport.dispose()
|
||||
released.resolve()
|
||||
await disposal
|
||||
expect(await result).toMatchObject({ name: "AbortError" })
|
||||
expect(clients).toEqual(["http://127.0.0.1:4100"])
|
||||
})
|
||||
|
||||
test("an aborted reconnect does not create another client", async () => {
|
||||
const abort = new AbortController()
|
||||
const clients: string[] = []
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100" },
|
||||
createApi: (http) => {
|
||||
clients.push(http.url)
|
||||
return { api: OpenCode.make({ baseUrl: http.url }), dispose: () => Promise.resolve() }
|
||||
},
|
||||
})
|
||||
abort.abort()
|
||||
await expect(transport.update({ url: "http://127.0.0.1:4200" }, abort.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
})
|
||||
expect(clients).toEqual(["http://127.0.0.1:4100"])
|
||||
await transport.dispose()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, createPtyClient, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { createApiForServer, type ServerApi, type ServerApiConnection } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
import { createRefCountMap } from "@/runtime/server/refcount"
|
||||
@@ -72,12 +72,20 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
const transport = createServerTransport({
|
||||
http: server.http,
|
||||
fetch: platform.fetch,
|
||||
createApi: platform.createServerApi,
|
||||
})
|
||||
onCleanup(() => void transport.dispose())
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
reconnect:
|
||||
reconnect || platform.createServerApi
|
||||
? async (signal) => transport.update(reconnect ? await reconnect(signal) : transport.http, signal)
|
||||
: undefined,
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
@@ -108,22 +116,44 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
|
||||
update(http: ServerConnection.HttpBase): ServerApi
|
||||
export function createServerTransport(input: {
|
||||
http: ServerConnection.HttpBase
|
||||
fetch?: typeof globalThis.fetch
|
||||
createApi?: (http: ServerConnection.HttpBase) => ServerApiConnection
|
||||
}): {
|
||||
update(http: ServerConnection.HttpBase, signal?: AbortSignal): Promise<ServerApi>
|
||||
dispose(): Promise<void>
|
||||
readonly http: ServerConnection.HttpBase
|
||||
readonly url: string
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
const connection = input.createApi?.(http) ?? {
|
||||
api: createApiForServer({ server: http, fetch: input.fetch }),
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
return { http, ...connection, pty: createPtyClient(connection.api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
const state = { current: build(input.http), disposed: false }
|
||||
return {
|
||||
update(http: ServerConnection.HttpBase) {
|
||||
async update(http: ServerConnection.HttpBase, signal?: AbortSignal) {
|
||||
signal?.throwIfAborted()
|
||||
if (state.disposed) throw new DOMException(undefined, "AbortError")
|
||||
await state.current.dispose()
|
||||
signal?.throwIfAborted()
|
||||
if (state.disposed) throw new DOMException(undefined, "AbortError")
|
||||
state.current = build(http)
|
||||
return state.current.api
|
||||
},
|
||||
dispose() {
|
||||
if (state.disposed) return Promise.resolve()
|
||||
state.disposed = true
|
||||
return state.current.dispose()
|
||||
},
|
||||
get http() {
|
||||
return state.current.http
|
||||
},
|
||||
get url() {
|
||||
return state.current.http.url
|
||||
},
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useFile } from "@/workspaces/files/model"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -91,16 +90,7 @@ export function useSessionModel() {
|
||||
isDesktop,
|
||||
workspace: {
|
||||
directory: createMemo(() => info()?.location.directory ?? location().directory),
|
||||
current: createMemo(() => {
|
||||
const current = info()
|
||||
const directory = current?.location.directory ?? location().directory
|
||||
// Global sync enriches projects with discovered worktrees; raw project metadata does not.
|
||||
const projects = server.ctx.sync.data.project
|
||||
const value = current
|
||||
? projectForSession(current, projects)
|
||||
: projects.find((item) => isProjectDirectory(item, directory))
|
||||
return isWorkspaceDirectory(value, directory)
|
||||
}),
|
||||
current: createMemo(() => isWorkspaceDirectory(project(), info()?.location.directory ?? location().directory)),
|
||||
},
|
||||
identity: {
|
||||
params: layout.params,
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("visibleTimelineMessages", () => {
|
||||
time: { created: 5, completed: 6 },
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
test("keeps work above an undelivered steer without adding a thinking row", () => {
|
||||
test("keeps work and thinking above an undelivered steer", () => {
|
||||
const source = [...messages.slice(0, 3), work]
|
||||
const visible = visibleTimelineMessages(source, [steer])
|
||||
expect(visible.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3"])
|
||||
@@ -60,7 +60,7 @@ describe("visibleTimelineMessages", () => {
|
||||
const projection = createTimelineProjection({
|
||||
sessionMessages: () => visible,
|
||||
status: () => ({ type: "busy" }),
|
||||
reasoningMode: () => "compact",
|
||||
showReasoningSummaries: () => false,
|
||||
shellToolDefaultOpen: () => false,
|
||||
editToolDefaultOpen: () => false,
|
||||
pendingUserMessageIDs: () => new Set([steer.id]),
|
||||
@@ -69,6 +69,7 @@ describe("visibleTimelineMessages", () => {
|
||||
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
|
||||
["UserMessage", "msg_1"],
|
||||
["AssistantPart", "msg_1"],
|
||||
["Thinking", "msg_1"],
|
||||
["TurnGap", "msg_3"],
|
||||
["UserMessage", "msg_3"],
|
||||
])
|
||||
|
||||
@@ -104,7 +104,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
const projection = createTimelineProjection({
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: settings.general.editToolPartsExpanded,
|
||||
pendingUserMessageIDs,
|
||||
@@ -235,7 +235,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
childTitle,
|
||||
showHeader,
|
||||
projection,
|
||||
reasoningMode: settings.general.reasoningMode,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
|
||||
editToolPartsExpanded: settings.general.editToolPartsExpanded,
|
||||
},
|
||||
|
||||
@@ -458,7 +458,7 @@ function MessageTimelineView(
|
||||
}
|
||||
},
|
||||
actions: props.actions,
|
||||
reasoningMode: props.data.reasoningMode,
|
||||
showReasoningSummaries: props.data.showReasoningSummaries,
|
||||
shellToolDefaultOpen: props.data.shellToolPartsExpanded,
|
||||
editToolDefaultOpen: props.data.editToolPartsExpanded,
|
||||
disclosure: virtualized.disclosure,
|
||||
@@ -480,7 +480,6 @@ function MessageTimelineView(
|
||||
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
reuseTimelineRows,
|
||||
Timeline,
|
||||
TimelineRow,
|
||||
type ReasoningMode,
|
||||
} from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { reuseTimelineRows, Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
|
||||
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
|
||||
@@ -12,7 +7,7 @@ export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
|
||||
export function createTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
|
||||
@@ -85,7 +80,7 @@ export function createTimelineProjection(input: {
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
input.reasoningMode() !== "hidden",
|
||||
input.showReasoningSummaries(),
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs(),
|
||||
input.shellToolDefaultOpen(),
|
||||
|
||||
@@ -62,7 +62,6 @@ type Input = {
|
||||
type ViewProps = {
|
||||
header: JSX.Element
|
||||
bottomSpacer?: JSX.Element
|
||||
workspaceSession: Accessor<boolean>
|
||||
deferred: (row: TimelineRow.TimelineRow) => boolean
|
||||
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
|
||||
}
|
||||
@@ -401,7 +400,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
@@ -184,34 +183,6 @@ const FollowUpBehaviorSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningModeSetting: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const options = createMemo((): { value: ReasoningMode; label: string }[] => [
|
||||
{ value: "hidden", label: language.t("settings.general.row.reasoningMode.hidden") },
|
||||
{ value: "compact", label: language.t("settings.general.row.reasoningMode.compact") },
|
||||
{ value: "full", label: language.t("settings.general.row.reasoningMode.full") },
|
||||
])
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningMode.title")}
|
||||
description={language.t("settings.general.row.reasoningMode.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-reasoning-mode"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.general.reasoningMode())}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && settings.general.setReasoningMode(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -360,7 +331,17 @@ export const SettingsGeneral: Component<{
|
||||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<ReasoningModeSetting />
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { migrateSettings, monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings reasoning mode migration", () => {
|
||||
test.each([
|
||||
[true, "full"],
|
||||
[false, "compact"],
|
||||
])("maps persisted reasoning summaries %s to %s", (showReasoningSummaries, reasoningMode) => {
|
||||
const value = { general: { showReasoningSummaries, showTerminal: true }, appearance: { fontSize: 16 } }
|
||||
expect(migrateSettings(value)).toEqual({
|
||||
...value,
|
||||
general: { ...value.general, reasoningMode },
|
||||
})
|
||||
expect(value.general).not.toHaveProperty("reasoningMode")
|
||||
})
|
||||
|
||||
test.each(["hidden", "compact", "full"])(
|
||||
"preserves an explicit %s mode over either legacy value",
|
||||
(reasoningMode) => {
|
||||
;[true, false].forEach((showReasoningSummaries) => {
|
||||
const value = { general: { reasoningMode, showReasoningSummaries } }
|
||||
expect(migrateSettings(value)).toBe(value)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test.each([undefined, null, {}, { general: {} }])("leaves missing legacy settings to the defaults: %j", (value) => {
|
||||
expect(migrateSettings(value)).toBe(value)
|
||||
})
|
||||
})
|
||||
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
|
||||
|
||||
describe("settings font families", () => {
|
||||
test("defaults normal text to Inter", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ReasoningMode } from "@opencode-ai/session-ui/timeline/projection"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
@@ -36,7 +35,7 @@ export interface Settings {
|
||||
showStatus: boolean
|
||||
showProjectIcon: boolean
|
||||
showTerminal: boolean
|
||||
reasoningMode: ReasoningMode
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
@@ -125,7 +124,7 @@ const defaultSettings: Settings = {
|
||||
showStatus: false,
|
||||
showProjectIcon: false,
|
||||
showTerminal: false,
|
||||
reasoningMode: "compact",
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
showCustomAgents: false,
|
||||
@@ -167,26 +166,11 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
export function migrateSettings(value: unknown) {
|
||||
if (!value || typeof value !== "object" || !("general" in value)) return value
|
||||
const general = value.general
|
||||
if (!general || typeof general !== "object") return value
|
||||
if ("reasoningMode" in general && general.reasoningMode !== undefined) return value
|
||||
if (!("showReasoningSummaries" in general) || typeof general.showReasoningSummaries !== "boolean") return value
|
||||
return {
|
||||
...value,
|
||||
general: { ...general, reasoningMode: general.showReasoningSummaries ? "full" : "compact" },
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const [store, setStore, , ready] = persisted(
|
||||
{ key: "settings.v3", migrate: migrateSettings },
|
||||
createStore<Settings>(defaultSettings),
|
||||
)
|
||||
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -239,9 +223,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowTerminal(value: boolean) {
|
||||
setStore("general", "showTerminal", value)
|
||||
},
|
||||
reasoningMode: withFallback(() => store.general?.reasoningMode, defaultSettings.general.reasoningMode),
|
||||
setReasoningMode(value: ReasoningMode) {
|
||||
setStore("general", "reasoningMode", value)
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
),
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
|
||||
@@ -54,26 +54,10 @@
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-nav-footer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-block: 20px 4px;
|
||||
padding-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--v2-text-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Component, createEffect, createMemo, createSignal, onCleanup, onMount,
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsGeneral } from "./general/general"
|
||||
import { SettingsAppearance } from "./appearance/appearance"
|
||||
import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
@@ -27,7 +26,6 @@ export const SettingsScreen: Component<{
|
||||
defaultValue?: string
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const surface = useSettingsSurface()
|
||||
@@ -163,12 +161,6 @@ export const SettingsScreen: Component<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>
|
||||
<bdi dir="ltr">v{platform.version}</bdi>
|
||||
</span>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
|
||||
@@ -55,7 +55,6 @@ export function SessionUIProvider(
|
||||
data={sessionUIData()}
|
||||
directory={directory()}
|
||||
sessionID={params.id}
|
||||
shellRunning={(id) => !!data.shell.get(id)}
|
||||
shellOutput={(input) => serverSDK.api.shell.output(input)}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
|
||||
@@ -79,8 +79,7 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
</aside>
|
||||
</Show>
|
||||
{/* Size containment collapses percentage-height descendants in WebKit. */}
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-content">
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
hidden={settings.store.open}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
import desktopPlugin from "./vite.js"
|
||||
import { serviceWorker } from "./vite.pwa"
|
||||
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
@@ -22,7 +21,62 @@ const sentry =
|
||||
: false
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [desktopPlugin, serviceWorker(fileURLToPath(new URL("./dist", import.meta.url))), sentry] as any,
|
||||
plugins: [
|
||||
desktopPlugin,
|
||||
VitePWA({
|
||||
strategies: "generateSW",
|
||||
registerType: "prompt",
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
workbox: {
|
||||
clientsClaim: false,
|
||||
skipWaiting: true,
|
||||
inlineWorkboxRuntime: true,
|
||||
// Always fetch the current HTML. Precaching a partial build can strand it without its chunks after an upgrade.
|
||||
navigateFallback: null,
|
||||
globPatterns: [],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) =>
|
||||
url.origin === self.location.origin &&
|
||||
(url.pathname.startsWith("/_assets/") || url.pathname.startsWith("/assets/")),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "opencode-assets",
|
||||
plugins: [
|
||||
{
|
||||
cachedResponseWillBeUsed: async ({ request, cachedResponse }) => {
|
||||
if (
|
||||
cachedResponse?.status === 200 &&
|
||||
!/^(text\/html|application\/xhtml\+xml)\b/i.test(cachedResponse.headers.get("content-type") ?? "")
|
||||
)
|
||||
return cachedResponse
|
||||
// Keep old tabs' precached chunks usable without retaining their stale HTML navigation handler.
|
||||
const response = await caches.match(request, {
|
||||
cacheName: `workbox-precache-v2-${self.location.origin}/`,
|
||||
})
|
||||
return response?.status === 200 &&
|
||||
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
|
||||
? response
|
||||
: null
|
||||
},
|
||||
cacheWillUpdate: async ({ response }) =>
|
||||
response.status === 200 &&
|
||||
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
|
||||
? response
|
||||
: null,
|
||||
},
|
||||
],
|
||||
expiration: {
|
||||
maxEntries: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
sentry,
|
||||
] as any,
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: true,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { resolve } from "node:path"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
|
||||
export function serviceWorker(directory: string) {
|
||||
return VitePWA({
|
||||
strategies: "generateSW",
|
||||
registerType: "prompt",
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
workbox: {
|
||||
globDirectory: directory,
|
||||
clientsClaim: false,
|
||||
// Keep each open tab on its complete build until all old clients close.
|
||||
skipWaiting: false,
|
||||
inlineWorkboxRuntime: true,
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
|
||||
globPatterns: ["**/*"],
|
||||
globIgnores: ["**/*.map", "_headers", "_redirects"],
|
||||
maximumFileSizeToCacheInBytes: Number.MAX_SAFE_INTEGER,
|
||||
manifestTransforms: [
|
||||
async (entries) => ({
|
||||
manifest: await Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
...entry,
|
||||
// A revision labels a cache entry; integrity rejects mixed deployments
|
||||
// and HTML fallback responses instead of installing a broken build.
|
||||
integrity: `sha256-${createHash("sha256")
|
||||
.update(await readFile(resolve(directory, entry.url)))
|
||||
.digest("base64")}`,
|
||||
})),
|
||||
),
|
||||
warnings: [],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,9 @@ Private generation target for clients derived directly from OpenCode's authorita
|
||||
## Entrypoints
|
||||
|
||||
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
|
||||
- `@opencode-ai/client/promise/rpc`: the same Promise DTO surface over a lazy, shared WebSocket (imports Effect).
|
||||
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
|
||||
- `@opencode-ai/client/effect/rpc`: native Effect RPC over a single scoped WebSocket.
|
||||
|
||||
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
||||
|
||||
@@ -25,3 +27,58 @@ yield *
|
||||
})
|
||||
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
|
||||
```
|
||||
|
||||
## WebSocket RPC
|
||||
|
||||
Promise consumers can keep their existing method calls and wire DTOs:
|
||||
|
||||
```ts
|
||||
import { OpenCodeRpc } from "@opencode-ai/client/promise/rpc"
|
||||
|
||||
const client = OpenCodeRpc.make({
|
||||
baseUrl: "https://opencode.example",
|
||||
headers: { authorization: `Basic ${token}` },
|
||||
})
|
||||
try {
|
||||
const session = await client.session.create()
|
||||
// Numeric timestamps, not Effect DateTime values.
|
||||
console.log(session.time.created)
|
||||
} finally {
|
||||
await client.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
`make` returns synchronously without opening a socket. The first call or stream iteration opens one shared connection. Basic authorization is used only for the upgrade's `auth_token`; other default and per-call headers travel as RPC frame metadata. Per-call headers override endpoint headers, which override facade defaults. Binary `file.read` results remain `Uint8Array`, and declared errors remain plain wire objects accepted by the Promise error guards.
|
||||
|
||||
Breaking or returning from an async iterator cancels its subscription. `RequestOptions.signal` cancels one call or stream without closing unrelated work. `dispose()` is idempotent, closes the connection, and rejects subsequent calls. A failed connection is never retried and in-flight requests are never replayed: dispose the failed facade and create a new one to reconnect. The Promise root stays zero-Effect; only the explicit `/promise/rpc` entrypoint imports the native RPC runtime.
|
||||
|
||||
The additive `/api/rpc` transport derives its operation contracts from Protocol's HTTP schemas; existing HTTP clients are unchanged. Use operation identifiers directly, with decoded `params`, `query`, and `payload` fields where declared. Numeric queries are numbers, not HTTP strings. Optional `location: { directory, workspace? }` selects per-call location context; session-specific operations retain their existing session location rules.
|
||||
|
||||
```ts
|
||||
import { OpenCodeRpc } from "@opencode-ai/client/effect/rpc"
|
||||
import { Effect, Redacted, Stream } from "effect"
|
||||
|
||||
declare const token: string // Base64 of "opencode:<server password>", not the raw password.
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCodeRpc.make({
|
||||
url: "wss://opencode.example/api/rpc",
|
||||
authToken: Redacted.make(token),
|
||||
})
|
||||
const events = yield* client["event.subscribe"]({}).pipe(
|
||||
Stream.runForEach((event) => Effect.log(event.type)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const sessions = yield* client["session.list"]({ query: {} })
|
||||
// Both requests share the connection; interrupting events cancels only that subscription.
|
||||
return sessions
|
||||
})
|
||||
|
||||
Effect.runPromise(Effect.scoped(program))
|
||||
```
|
||||
|
||||
Keep the scope open while consuming streams. Scope closure closes the socket and cancels outstanding work. Connection failures are surfaced rather than replaying potentially mutating requests; create a new scoped client to reconnect. Browsers use their native WebSocket constructor, with an optional `webSocketConstructor` override for other runtimes. Authentication uses the server's existing `auth_token` upgrade mechanism; treat the resulting URL as sensitive and do not log it.
|
||||
|
||||
Streaming operations return native Effect Streams of the original typed items, not SSE text. No-content operations return `void`. `fs.read` takes `params: { path: "relative/file" }` plus `query: {}` and returns `{ content: Uint8Array, mime: string }`, with bytes encoded as base64 on the wire. Raw `pty.connect` and `persistentPty.connect` remain on their existing WebSocket routes.
|
||||
|
||||
From `packages/server`, run `bun run script/benchmark-rpc.ts` for an isolated loopback comparison of HTTP and RPC session-list calls. It uses an in-memory database, temporary configuration, and schema-decoding clients at concurrency 1 and 16. This measures transport overhead, not end-to-end desktop/web speed. Desktop injects the Promise RPC transport; browser web retains HTTP. Desktop service discovery/readiness checks and raw terminal attachments keep their existing transports.
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
".": "./src/promise/index.ts",
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./promise/rpc": "./src/promise/rpc.ts",
|
||||
"./service": "./src/promise/service.ts",
|
||||
"./solid": "./src/solid/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/api": "./src/effect/api.ts",
|
||||
"./effect/rpc": "./src/effect/rpc.ts",
|
||||
"./effect/service": "./src/effect/service.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export * as OpenCodeRpc from "./rpc.js"
|
||||
|
||||
import { Group, type Rpcs } from "@opencode-ai/protocol/rpc"
|
||||
import { Effect, Redacted, Schedule, Scope } from "effect"
|
||||
import { RpcClient, RpcClientError, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
|
||||
export interface Options {
|
||||
/** Full WebSocket endpoint, for example wss://opencode.example/api/rpc. */
|
||||
readonly url: string | URL
|
||||
/** Existing server auth_token, sent only during the WebSocket upgrade. */
|
||||
readonly authToken?: Redacted.Redacted<string>
|
||||
readonly webSocketConstructor?: Socket.WebSocketConstructor["Service"]
|
||||
}
|
||||
|
||||
export type Client = RpcClient.RpcClient<Rpcs, RpcClientError.RpcClientError>
|
||||
|
||||
/** One scoped connection multiplexes all unary calls and streaming subscriptions. */
|
||||
export const make: (options: Options) => Effect.Effect<Client, never, Scope.Scope> = Effect.fnUntraced(
|
||||
function* (options) {
|
||||
const url = new URL(options.url)
|
||||
if (options.authToken) url.searchParams.set("auth_token", Redacted.value(options.authToken))
|
||||
const socket = yield* Socket.makeWebSocket(url.toString()).pipe(
|
||||
Effect.provideService(
|
||||
Socket.WebSocketConstructor,
|
||||
options.webSocketConstructor ?? ((url, protocols) => new WebSocket(url, protocols)),
|
||||
),
|
||||
)
|
||||
const protocol = yield* RpcClient.makeProtocolSocket({ retryPolicy: Schedule.recurs(0) }).pipe(
|
||||
Effect.provideService(Socket.Socket, socket),
|
||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json),
|
||||
)
|
||||
return yield* RpcClient.make(Group).pipe(Effect.provideService(RpcClient.Protocol, protocol))
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
export * as OpenCodeRpc from "./rpc.js"
|
||||
|
||||
import { ClientApi } from "@opencode-ai/protocol/client"
|
||||
import { Group } from "@opencode-ai/protocol/rpc"
|
||||
import { Effect, Exit, Redacted, Schema, Scope, Stream } from "effect"
|
||||
import type { HttpApi } from "effect/unstable/httpapi"
|
||||
import { RpcClientError, RpcSchema } from "effect/unstable/rpc"
|
||||
import { OpenCodeRpc } from "../effect/rpc.js"
|
||||
import { ClientError } from "./generated/client-error.js"
|
||||
import { OpenCode } from "./generated/index.js"
|
||||
import type { ClientOptions, RequestDescriptor, RequestOptions } from "./generated/client.js"
|
||||
import type { OpenCodeClient } from "./index.js"
|
||||
|
||||
export interface Options extends Pick<ClientOptions, "baseUrl" | "headers"> {
|
||||
readonly webSocketConstructor?: OpenCodeRpc.Options["webSocketConstructor"]
|
||||
}
|
||||
|
||||
export type Client = OpenCodeClient & { readonly dispose: () => Promise<void> }
|
||||
|
||||
const endpoints = new Map(
|
||||
Object.values((ClientApi as unknown as HttpApi.Top).groups).flatMap((group) =>
|
||||
Object.values(group.endpoints).map((endpoint) => [endpoint.identifier, endpoint] as const),
|
||||
),
|
||||
)
|
||||
|
||||
const codecs = new Map<
|
||||
string,
|
||||
{
|
||||
readonly input: Schema.Codec<unknown, unknown>
|
||||
readonly output: Schema.Codec<unknown, unknown>
|
||||
readonly error: Schema.Codec<unknown, unknown>
|
||||
}
|
||||
>()
|
||||
|
||||
function operation(name: string) {
|
||||
const cached = codecs.get(name)
|
||||
if (cached) return cached
|
||||
const endpoint = endpoints.get(name)
|
||||
const rpc = Group.requests.get(name)
|
||||
if (!endpoint || !rpc) throw new ClientError("Transport", { cause: new Error(`Unknown RPC operation: ${name}`) })
|
||||
const payloads = Array.from(endpoint.payload.values()).flatMap((entry) => entry.schemas)
|
||||
const output = RpcSchema.isStreamSchema(rpc.successSchema) ? rpc.successSchema.success : rpc.successSchema
|
||||
// Promise query inputs are decoded values; payloads and path/header inputs are HTTP wire values.
|
||||
const input = Schema.Struct({
|
||||
...(endpoint.params && name !== "fs.read" ? { params: Schema.toCodecJson(endpoint.params) } : {}),
|
||||
...(name === "fs.read" ? { params: Schema.Struct({ path: Schema.String }) } : {}),
|
||||
...(endpoint.query ? { query: Schema.toCodecJson(Schema.toType(endpoint.query)) } : {}),
|
||||
...(endpoint.headers ? { headers: Schema.toCodecJson(endpoint.headers) } : {}),
|
||||
...(payloads.length ? { payload: Schema.toCodecJson(Schema.Union(payloads)) } : {}),
|
||||
})
|
||||
const error = RpcSchema.isStreamSchema(rpc.successSchema)
|
||||
? Schema.Union([rpc.errorSchema, rpc.successSchema.error])
|
||||
: rpc.errorSchema
|
||||
const value = { input: Schema.fromJsonString(input), output, error } as unknown as {
|
||||
input: Schema.Codec<unknown, unknown>
|
||||
output: Schema.Codec<unknown, unknown>
|
||||
error: Schema.Codec<unknown, unknown>
|
||||
}
|
||||
codecs.set(name, value)
|
||||
return value
|
||||
}
|
||||
|
||||
/** A lazy, single-connection Promise facade. Replace it after a disconnect; calls are never replayed. */
|
||||
export function make(options: Options): Client {
|
||||
const scope = Scope.makeUnsafe()
|
||||
let client: Promise<OpenCodeRpc.Client> | undefined
|
||||
let disposal: Promise<void> | undefined
|
||||
const connect = Effect.suspend(() => {
|
||||
if (disposal) return Effect.fail(new ClientError("Transport", { cause: new Error("RPC client disposed") }))
|
||||
return Effect.promise(
|
||||
() =>
|
||||
(client ??= Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const url = new URL("/api/rpc", options.baseUrl)
|
||||
url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:"
|
||||
const authorization = new Headers(options.headers).get("authorization")
|
||||
const token = /^Basic\s+(.+)$/i.exec(authorization ?? "")?.[1]
|
||||
return yield* OpenCodeRpc.make({
|
||||
url,
|
||||
authToken: token ? Redacted.make(token) : undefined,
|
||||
webSocketConstructor: options.webSocketConstructor,
|
||||
})
|
||||
}).pipe(Effect.provideService(Scope.Scope, scope)),
|
||||
)),
|
||||
)
|
||||
})
|
||||
|
||||
const invoke = Effect.fnUntraced(function* (descriptor: RequestDescriptor, requestOptions: RequestOptions) {
|
||||
if (requestOptions.signal?.aborted)
|
||||
return yield* Effect.fail(new ClientError("Transport", { cause: requestOptions.signal.reason }))
|
||||
const codec = operation(descriptor.operation)
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
// Authentication belongs only to the upgrade, not to user-controlled RPC frames.
|
||||
headers.delete("authorization")
|
||||
// Match the Promise wire boundary: omit undefined optional fields before decoding JSON codecs.
|
||||
const input = yield* Schema.decodeUnknownEffect(codec.input)(
|
||||
JSON.stringify({
|
||||
params: descriptor.params,
|
||||
query: descriptor.query ?? {},
|
||||
payload: descriptor.body === undefined ? {} : descriptor.body,
|
||||
headers: Object.fromEntries(headers),
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new ClientError("Transport", { cause })))
|
||||
const api = yield* connect
|
||||
// The generated operation/descriptor and protocol codec jointly establish this dynamic boundary.
|
||||
const call = (
|
||||
api as unknown as Record<
|
||||
string,
|
||||
(
|
||||
input: unknown,
|
||||
options: { headers: Record<string, string> },
|
||||
) => Effect.Effect<unknown, unknown> | Stream.Stream<unknown, unknown>
|
||||
>
|
||||
)[descriptor.operation]!
|
||||
return call(input, { headers: Object.fromEntries(headers) })
|
||||
})
|
||||
|
||||
return Object.assign(
|
||||
OpenCode.make({
|
||||
...options,
|
||||
transport: {
|
||||
request(descriptor, requestOptions) {
|
||||
const codec = operation(descriptor.operation)
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const result = yield* invoke(descriptor, requestOptions)
|
||||
if (Stream.isStream(result)) return yield* Effect.fail(new ClientError("MalformedResponse"))
|
||||
const value = yield* result
|
||||
if (descriptor.empty) return undefined
|
||||
if (descriptor.binary) return (value as { content: Uint8Array }).content
|
||||
return yield* Schema.encodeUnknownEffect(codec.output)(value).pipe(
|
||||
Effect.mapError((cause) => new ClientError("MalformedResponse", { cause })),
|
||||
)
|
||||
}),
|
||||
{ signal: requestOptions.signal },
|
||||
).catch((error) => {
|
||||
throw wireError(error, codec.error)
|
||||
})
|
||||
},
|
||||
stream(descriptor, requestOptions) {
|
||||
const codec = operation(descriptor.operation)
|
||||
const stream = Stream.unwrap(
|
||||
invoke(descriptor, requestOptions).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.isStream(result) ? result : Stream.fail(new ClientError("MalformedResponse")),
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Stream.mapEffect((value) =>
|
||||
Schema.encodeUnknownEffect(codec.output)(value).pipe(
|
||||
Effect.mapError((cause) => new ClientError("MalformedResponse", { cause })),
|
||||
),
|
||||
),
|
||||
)
|
||||
const signal = requestOptions.signal
|
||||
const abort = Effect.callback<never, ClientError>((resume) => {
|
||||
if (!signal) return
|
||||
const fail = () => resume(Effect.fail(new ClientError("Transport", { cause: signal.reason })))
|
||||
if (signal.aborted) return fail()
|
||||
signal.addEventListener("abort", fail, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", fail))
|
||||
})
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
const iterator = Stream.toAsyncIterable(stream.pipe(Stream.interruptWhen(abort)))[Symbol.asyncIterator]()
|
||||
return {
|
||||
next: () =>
|
||||
iterator.next().catch((error) => {
|
||||
throw wireError(error, codec.error)
|
||||
}),
|
||||
return: () => iterator.return!(),
|
||||
throw: (error?: unknown) => iterator.throw!(error),
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
dispose: () =>
|
||||
(disposal ??= (async () => {
|
||||
await client?.catch(() => {})
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void))
|
||||
})()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function wireError(error: unknown, schema: Schema.Codec<unknown, unknown>) {
|
||||
if (error instanceof ClientError) return error
|
||||
if (error instanceof RpcClientError.RpcClientError) return new ClientError("Transport", { cause: error })
|
||||
const encoded = Schema.encodeUnknownExit(schema)(error)
|
||||
return Exit.isSuccess(encoded) ? encoded.value : new ClientError("Transport", { cause: error })
|
||||
}
|
||||
@@ -28,6 +28,18 @@ describe("public import boundaries", () => {
|
||||
expect(within(network, core)).toEqual([])
|
||||
expect(within(network, server)).toEqual([])
|
||||
|
||||
const rpc = await bundleInputs("@opencode-ai/client/effect/rpc", "browser")
|
||||
expect(within(rpc, effect).length).toBeGreaterThan(0)
|
||||
expect(within(rpc, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(rpc, core)).toEqual([])
|
||||
expect(within(rpc, server)).toEqual([])
|
||||
|
||||
const promiseRpc = await bundleInputs("@opencode-ai/client/promise/rpc", "browser")
|
||||
expect(within(promiseRpc, effect).length).toBeGreaterThan(0)
|
||||
expect(within(promiseRpc, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(promiseRpc, core)).toEqual([])
|
||||
expect(within(promiseRpc, server)).toEqual([])
|
||||
|
||||
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
|
||||
|
||||
expect(within(promiseService, effect)).toEqual([])
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createServer } from "node:http"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { ClientApi } from "@opencode-ai/protocol/client"
|
||||
import { SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { fromEndpoint } from "@opencode-ai/protocol/rpc"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { DateTime, Effect, Schema, Stream } from "effect"
|
||||
import { RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { ClientError, isSessionNotFoundError, type OpenCodeClient } from "../src/promise/index.js"
|
||||
import { OpenCodeRpc } from "../src/promise/rpc.js"
|
||||
|
||||
const wire = {
|
||||
id: "ses_test",
|
||||
projectID: "prj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1700000000000, updated: 1700000001000 },
|
||||
location: { directory: "/project" },
|
||||
}
|
||||
const info = Schema.decodeUnknownSync(Schema.toCodecJson(Session.Info))(wire)
|
||||
|
||||
test("Promise RPC preserves DTOs, numeric queries, optional payloads, errors, bytes and frame headers", async () => {
|
||||
await withServer(async (baseUrl, state) => {
|
||||
const sockets: WebSocket[] = []
|
||||
const client = OpenCodeRpc.make({
|
||||
baseUrl,
|
||||
headers: {
|
||||
authorization: "Basic dXNlcjpwYXNz",
|
||||
"x-opencode-directory": "%2Fdefault",
|
||||
"x-opencode-workspace": "wrk_default",
|
||||
},
|
||||
webSocketConstructor: (url, protocols) => {
|
||||
expect(new URL(url).pathname).toBe("/api/rpc")
|
||||
expect(new URL(url).searchParams.get("auth_token")).toBe("dXNlcjpwYXNz")
|
||||
const socket = new WebSocket(url, protocols)
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
},
|
||||
})
|
||||
const api: OpenCodeClient = client
|
||||
expect(sockets).toHaveLength(0)
|
||||
try {
|
||||
const [health, created, sessions, bytes] = await Promise.all([
|
||||
api.health.get(),
|
||||
api.session.create(),
|
||||
api.session.list({ limit: 2, parentID: null }),
|
||||
api.file.read({ path: "dir/space %25.bin", location: { directory: "/explicit", workspace: "wrk_explicit" } }),
|
||||
])
|
||||
expect(health.healthy).toBe(true)
|
||||
expect(created).toEqual(wire)
|
||||
expect(created.time.created).toBeNumber()
|
||||
expect(sessions.data).toEqual([wire])
|
||||
expect(bytes).toEqual(new Uint8Array([0, 255, 128]))
|
||||
expect(await api.session.import({ info: wire, messages: [] })).toEqual(wire)
|
||||
expect(await api.session.rename({ sessionID: wire.id, title: "new title" })).toBeUndefined()
|
||||
expect(
|
||||
await api.form.list(
|
||||
{ sessionID: "global" },
|
||||
{
|
||||
headers: {
|
||||
"x-opencode-directory": "%2Foverride",
|
||||
"x-opencode-workspace": "wrk_override",
|
||||
"x-opencode-ticket": "test-ticket",
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual([])
|
||||
const form = state.requests.find((request) => request.operation === "session.form.list")!
|
||||
expect(form.input).toEqual({ params: { sessionID: "global" } })
|
||||
expect(form.headers["x-opencode-directory"]).toBe("%2Foverride")
|
||||
expect(form.headers["x-opencode-workspace"]).toBe("wrk_override")
|
||||
expect(form.headers["x-opencode-ticket"]).toBe("test-ticket")
|
||||
expect(form.headers.authorization).toBeUndefined()
|
||||
expect(state.requests.find((request) => request.operation === "session.create")?.input).toEqual({ payload: {} })
|
||||
expect(state.requests.find((request) => request.operation === "session.list")?.input).toEqual({
|
||||
query: { limit: 2, parentID: null },
|
||||
})
|
||||
expect(state.requests.find((request) => request.operation === "fs.read")?.input).toEqual({
|
||||
params: { path: "dir/space %25.bin" },
|
||||
query: { location: { directory: "/explicit", workspace: "wrk_explicit" } },
|
||||
})
|
||||
const error = await api.session.get({ sessionID: "ses_missing" }).catch((error: unknown) => error)
|
||||
expect(isSessionNotFoundError(error)).toBe(true)
|
||||
expect(error).toEqual({ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "missing" })
|
||||
expect(error).not.toBeInstanceOf(SessionNotFoundError)
|
||||
expect(sockets).toHaveLength(1)
|
||||
} finally {
|
||||
await client.dispose()
|
||||
}
|
||||
expect(sockets[0]!.readyState).toBe(WebSocket.CLOSED)
|
||||
await expect(client.health.get()).rejects.toBeInstanceOf(ClientError)
|
||||
await client.dispose()
|
||||
expect(sockets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test("iterator return, AbortSignal and disposal cancel only their owned RPC work", async () => {
|
||||
await withServer(async (baseUrl, state) => {
|
||||
const client = OpenCodeRpc.make({ baseUrl })
|
||||
try {
|
||||
const first = client.event.subscribe()[Symbol.asyncIterator]()
|
||||
expect((await first.next()).value?.type).toBe("server.connected")
|
||||
const pending = first.next()
|
||||
await first.return!()
|
||||
expect((await pending).done).toBe(true)
|
||||
await state.streams[0]!.promise
|
||||
expect((await client.health.get()).healthy).toBe(true)
|
||||
|
||||
const abort = new AbortController()
|
||||
const second = client.event.subscribe({ signal: abort.signal })[Symbol.asyncIterator]()
|
||||
await second.next()
|
||||
const blocked = second.next().catch((error: unknown) => error)
|
||||
abort.abort()
|
||||
expect(await blocked).toBeInstanceOf(ClientError)
|
||||
await state.streams[1]!.promise
|
||||
|
||||
const mutationAbort = new AbortController()
|
||||
const mutation = client.session
|
||||
.remove({ sessionID: wire.id }, { signal: mutationAbort.signal })
|
||||
.catch((error: unknown) => error)
|
||||
await state.removed.promise
|
||||
mutationAbort.abort()
|
||||
expect(await mutation).toBeInstanceOf(ClientError)
|
||||
await state.removeStopped.promise
|
||||
expect((await client.health.get()).healthy).toBe(true)
|
||||
|
||||
const third = client.event.subscribe()[Symbol.asyncIterator]()
|
||||
await third.next()
|
||||
const disposed = third.next().catch((error: unknown) => error)
|
||||
await client.dispose()
|
||||
expect(await disposed).toBeInstanceOf(ClientError)
|
||||
await state.streams[2]!.promise
|
||||
await expect(client.health.get()).rejects.toBeInstanceOf(ClientError)
|
||||
} finally {
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("disconnect rejects in-flight mutations without replay; only a new facade reconnects", async () => {
|
||||
await withServer(async (baseUrl, state) => {
|
||||
const sockets: WebSocket[] = []
|
||||
const options = {
|
||||
baseUrl,
|
||||
webSocketConstructor: (url: string, protocols?: string | string[]) => {
|
||||
const socket = new WebSocket(url, protocols)
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
},
|
||||
}
|
||||
const client = OpenCodeRpc.make(options)
|
||||
try {
|
||||
const mutation = client.session.remove({ sessionID: wire.id }).catch((error: unknown) => error)
|
||||
await state.removed.promise
|
||||
sockets[0]!.close(1011, "disconnect")
|
||||
expect(await mutation).toBeInstanceOf(ClientError)
|
||||
await state.removeStopped.promise
|
||||
await expect(client.health.get()).rejects.toBeInstanceOf(ClientError)
|
||||
expect(sockets).toHaveLength(1)
|
||||
const replacement = OpenCodeRpc.make(options)
|
||||
try {
|
||||
expect((await replacement.health.get()).healthy).toBe(true)
|
||||
expect(sockets).toHaveLength(2)
|
||||
expect(state.requests.filter((request) => request.operation === "session.remove")).toHaveLength(1)
|
||||
await client.dispose()
|
||||
expect((await replacement.health.get()).healthy).toBe(true)
|
||||
} finally {
|
||||
await replacement.dispose()
|
||||
}
|
||||
} finally {
|
||||
await client.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("disposing an unused facade and pre-aborted calls never open a socket", async () => {
|
||||
let sockets = 0
|
||||
const options = {
|
||||
baseUrl: "http://localhost:1",
|
||||
webSocketConstructor: (url: string) => {
|
||||
sockets++
|
||||
return new WebSocket(url)
|
||||
},
|
||||
}
|
||||
const unused = OpenCodeRpc.make(options)
|
||||
await unused.dispose()
|
||||
await expect(unused.health.get()).rejects.toBeInstanceOf(ClientError)
|
||||
const aborted = OpenCodeRpc.make(options)
|
||||
try {
|
||||
await expect(aborted.health.get({ signal: AbortSignal.abort() })).rejects.toBeInstanceOf(ClientError)
|
||||
await expect(
|
||||
aborted.event.subscribe({ signal: AbortSignal.abort() })[Symbol.asyncIterator]().next(),
|
||||
).rejects.toBeInstanceOf(ClientError)
|
||||
expect(sockets).toBe(0)
|
||||
} finally {
|
||||
await aborted.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
type State = {
|
||||
requests: Array<{ operation: string; input: unknown; headers: Record<string, string> }>
|
||||
streams: Array<ReturnType<typeof Promise.withResolvers<void>>>
|
||||
removed: ReturnType<typeof Promise.withResolvers<void>>
|
||||
removeStopped: ReturnType<typeof Promise.withResolvers<void>>
|
||||
}
|
||||
|
||||
async function withServer(run: (baseUrl: string, state: State) => Promise<void>) {
|
||||
await Effect.gen(function* () {
|
||||
const state: State = {
|
||||
requests: [],
|
||||
streams: [],
|
||||
removed: Promise.withResolvers(),
|
||||
removeStopped: Promise.withResolvers(),
|
||||
}
|
||||
const record = (operation: string, input: unknown, headers: Record<string, string>) =>
|
||||
state.requests.push({ operation, input, headers })
|
||||
const group = RpcGroup.make(
|
||||
fromEndpoint(ClientApi.groups["server.health"].endpoints["health.get"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.list"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.create"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.import"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.get"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.rename"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.remove"]),
|
||||
fromEndpoint(ClientApi.groups["server.form"].endpoints["session.form.list"]),
|
||||
fromEndpoint(ClientApi.groups["server.event"].endpoints["event.subscribe"]),
|
||||
fromEndpoint(ClientApi.groups["server.fs"].endpoints["fs.read"]),
|
||||
)
|
||||
const app = yield* RpcServer.toHttpEffectWebsocket(group).pipe(
|
||||
Effect.provide(
|
||||
group.toLayer({
|
||||
"health.get": () => Effect.succeed({ healthy: true, version: "test", pid: 0 }),
|
||||
"session.list": (input, options) => {
|
||||
record("session.list", input, options.headers)
|
||||
return Effect.succeed({ data: [info], cursor: {} })
|
||||
},
|
||||
"session.create": (input, options) => {
|
||||
record("session.create", input, options.headers)
|
||||
return Effect.succeed({ data: info })
|
||||
},
|
||||
"session.import": (input) => {
|
||||
expect(DateTime.isDateTime(input.payload.info.time.created)).toBe(true)
|
||||
expect(DateTime.toEpochMillis(input.payload.info.time.created)).toBe(wire.time.created)
|
||||
return Effect.succeed({ data: input.payload.info })
|
||||
},
|
||||
"session.get": ({ params }) =>
|
||||
Effect.fail(new SessionNotFoundError({ sessionID: params.sessionID, message: "missing" })),
|
||||
"session.rename": () => Effect.void,
|
||||
"session.remove": (input, options) =>
|
||||
Effect.gen(function* () {
|
||||
record("session.remove", input, options.headers)
|
||||
state.removed.resolve()
|
||||
return yield* Effect.never
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => state.removeStopped.resolve()))),
|
||||
"session.form.list": (input, options) => {
|
||||
record("session.form.list", input, options.headers)
|
||||
return Effect.succeed({ data: [] })
|
||||
},
|
||||
"fs.read": (input, options) => {
|
||||
record("fs.read", input, options.headers)
|
||||
return Effect.succeed({ content: new Uint8Array([0, 255, 128]), mime: "application/octet-stream" })
|
||||
},
|
||||
"event.subscribe": () => {
|
||||
const stopped = Promise.withResolvers<void>()
|
||||
state.streams.push(stopped)
|
||||
return Stream.make({
|
||||
id: Event.ID.make("evt_connected"),
|
||||
type: "server.connected" as const,
|
||||
data: {},
|
||||
}).pipe(Stream.concat(Stream.never), Stream.ensuring(Effect.sync(() => stopped.resolve())))
|
||||
},
|
||||
}),
|
||||
),
|
||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json),
|
||||
)
|
||||
const server = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
|
||||
yield* server.serve(app)
|
||||
if (server.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP listener")
|
||||
yield* Effect.promise(() =>
|
||||
run(`http://127.0.0.1:${server.address._tag === "TcpAddress" ? server.address.port : 0}`, state),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.timeout("10 seconds"), Effect.runPromise)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createServer } from "node:http"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { ClientApi } from "@opencode-ai/protocol/client"
|
||||
import { SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { Group, fromEndpoint } from "@opencode-ai/protocol/rpc"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Deferred, Effect, Fiber, Redacted, Stream } from "effect"
|
||||
import { RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { OpenCodeRpc } from "../src/effect/rpc.js"
|
||||
|
||||
test("one scoped socket multiplexes unary calls and cancellable typed streams", async () => {
|
||||
const sockets: WebSocket[] = []
|
||||
await Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
const group = RpcGroup.make(
|
||||
fromEndpoint(ClientApi.groups["server.health"].endpoints["health.get"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.list"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.get"]),
|
||||
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.remove"]),
|
||||
fromEndpoint(ClientApi.groups["server.event"].endpoints["event.subscribe"]),
|
||||
fromEndpoint(ClientApi.groups["server.fs"].endpoints["fs.read"]),
|
||||
)
|
||||
const app = yield* RpcServer.toHttpEffectWebsocket(group).pipe(
|
||||
Effect.provide(
|
||||
group.toLayer({
|
||||
"health.get": () => Effect.succeed({ healthy: true, version: "test", pid: 0 }),
|
||||
"session.list": () => Effect.succeed({ data: [], cursor: {} }),
|
||||
"session.get": ({ params }) =>
|
||||
Effect.fail(new SessionNotFoundError({ sessionID: params.sessionID, message: "missing" })),
|
||||
"session.remove": () => Effect.void,
|
||||
"fs.read": () => Effect.succeed({ content: new Uint8Array([0, 255, 128]), mime: "application/octet-stream" }),
|
||||
"event.subscribe": () =>
|
||||
Stream.fromEffect(Deferred.succeed(started, undefined)).pipe(
|
||||
Stream.map(() => ({ id: Event.ID.make("evt_connected"), type: "server.connected" as const, data: {} })),
|
||||
Stream.concat(Stream.never),
|
||||
Stream.ensuring(Deferred.succeed(stopped, undefined)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json),
|
||||
)
|
||||
const server = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
|
||||
yield* server.serve(app)
|
||||
if (server.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP listener")
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* OpenCodeRpc.make({
|
||||
url: `ws://127.0.0.1:${server.address._tag === "TcpAddress" ? server.address.port : 0}/api/rpc`,
|
||||
authToken: Redacted.make("test-token"),
|
||||
webSocketConstructor: (url, protocols) => {
|
||||
expect(new URL(url).searchParams.get("auth_token")).toBe("test-token")
|
||||
const socket = new WebSocket(url, protocols)
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
},
|
||||
})
|
||||
const received = yield* Deferred.make<void>()
|
||||
const events = yield* client["event.subscribe"]({}).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
expect(event.type).toBe("server.connected")
|
||||
return Deferred.succeed(received, undefined)
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
yield* Deferred.await(received)
|
||||
const [health, sessions, file] = yield* Effect.all(
|
||||
[
|
||||
client["health.get"]({}),
|
||||
client["session.list"]({ query: {} }),
|
||||
client["fs.read"]({ params: { path: "a.bin" }, query: {} }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(health).toEqual({ healthy: true, version: "test", pid: 0 })
|
||||
expect(sessions).toEqual({ data: [], cursor: {} })
|
||||
expect(file.content).toEqual(new Uint8Array([0, 255, 128]))
|
||||
const error = yield* client["session.get"]({ params: { sessionID: Session.ID.make("ses_missing") } }).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error).toBeInstanceOf(SessionNotFoundError)
|
||||
expect(yield* client["session.remove"]({ params: { sessionID: Session.ID.make("ses_test") } })).toBeUndefined()
|
||||
yield* Fiber.interrupt(events)
|
||||
yield* Deferred.await(stopped)
|
||||
expect((yield* client["health.get"]({})).healthy).toBe(true)
|
||||
expect(sockets).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.timeout("10 seconds"), Effect.runPromise)
|
||||
expect(sockets[0]!.readyState).toBe(WebSocket.CLOSED)
|
||||
})
|
||||
|
||||
// The derived client keeps operation-specific request and response types.
|
||||
type Client = Effect.Success<ReturnType<typeof OpenCodeRpc.make>>
|
||||
type HasRawPty = "pty.connect" extends keyof Client ? true : false
|
||||
const noRawPty: HasRawPty = false
|
||||
type Tags = RpcGroup.Rpcs<typeof Group>["_tag"]
|
||||
const fileTag: Tags = "fs.read"
|
||||
test("raw PTY is not part of the typed client", () => {
|
||||
expect(noRawPty).toBe(false)
|
||||
expect(fileTag).toBe("fs.read")
|
||||
})
|
||||
@@ -358,10 +358,15 @@ export const layer = Layer.effect(
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (input) =>
|
||||
tools
|
||||
.execute({ ...input, definitions: hooked })
|
||||
const executeTool: Prepared["executeTool"] = (input) => {
|
||||
const tool = hooked.get(input.call.name)
|
||||
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
||||
if (!tool && registry.has(input.call.name))
|
||||
return new Tool.Error({ message: `Tool is not available for this request: ${input.call.name}` })
|
||||
return tools
|
||||
.execute(tool ? { ...input, call: { ...input.call, name: tool.name } } : input)
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
}
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
|
||||
+26
-32
@@ -49,8 +49,6 @@ export interface Snapshot {
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
|
||||
readonly definitions?: ReadonlyMap<string, ToolDefinition>
|
||||
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
|
||||
}
|
||||
|
||||
@@ -92,23 +90,23 @@ const layer = Layer.effect(
|
||||
]
|
||||
})
|
||||
|
||||
const beforeExecute = (name: string, input: unknown, context: Tool.Context) =>
|
||||
hooks.trigger("tool", "execute.before", {
|
||||
tool: name,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
input,
|
||||
})
|
||||
|
||||
const executeTool = Effect.fn("Tool.execute")(function* (
|
||||
tool: Tool.Info,
|
||||
name: string,
|
||||
input: unknown,
|
||||
context: Tool.Context,
|
||||
) {
|
||||
const execution = yield* execute(tool, input, context).pipe(
|
||||
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
|
||||
tool: name,
|
||||
inputSchema: definition(tool).inputSchema,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
input,
|
||||
}
|
||||
yield* hooks.trigger("tool", "execute.before", beforeEvent)
|
||||
const execution = yield* execute(tool, beforeEvent.input, context).pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })),
|
||||
)
|
||||
@@ -118,7 +116,7 @@ const layer = Layer.effect(
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
input,
|
||||
input: beforeEvent.input,
|
||||
}
|
||||
if ("failure" in execution) {
|
||||
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
||||
@@ -214,11 +212,7 @@ const layer = Layer.effect(
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) =>
|
||||
beforeExecute(name, input, context).pipe(
|
||||
Effect.flatMap((event) => executeTool(tool, name, event.input, context)),
|
||||
),
|
||||
)
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
@@ -229,7 +223,13 @@ const layer = Layer.effect(
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
@@ -237,18 +237,12 @@ const layer = Layer.effect(
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
const event = yield* beforeExecute(input.call.name, input.call.input, context)
|
||||
const requested = input.definitions?.get(event.tool)
|
||||
// Preserve session context removal and alias resolution, now after the repair hook.
|
||||
if (!requested && input.definitions && (direct.has(event.tool) || codemodeTool?.name === event.tool))
|
||||
return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` })
|
||||
const name = requested?.name ?? event.tool
|
||||
if (name === "execute" && codemodeTool)
|
||||
return yield* executeTool(codemodeTool, name, event.input, context)
|
||||
const tool = direct.get(name)
|
||||
if (tool) return yield* executeTool(tool, name, event.input, context)
|
||||
return yield* new Tool.Error({ message: `Unknown tool: ${name}` })
|
||||
}),
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -580,7 +580,7 @@ describe("Plugin", () => {
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: { input: unknown; tool: string }
|
||||
before?: { input: unknown; inputSchema: unknown }
|
||||
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
|
||||
} = {}
|
||||
|
||||
@@ -605,9 +605,7 @@ describe("Plugin", () => {
|
||||
yield* ctx.tool
|
||||
.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event).not.toHaveProperty("inputSchema")
|
||||
seen.before = { input: event.input, tool: event.tool }
|
||||
event.tool = "echo"
|
||||
seen.before = { input: event.input, inputSchema: event.inputSchema }
|
||||
event.input = { text: "before-mutated" }
|
||||
}),
|
||||
)
|
||||
@@ -650,12 +648,17 @@ describe("Plugin", () => {
|
||||
sessionID: Session.ID.make("ses_hooks"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_hooks"),
|
||||
call: { type: "tool-call", id: "call-hooks", name: "misspelled", input: { text: "original" } },
|
||||
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
|
||||
expect(seen.before).toEqual({
|
||||
input: { text: "original" },
|
||||
tool: "misspelled",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
@@ -709,7 +712,7 @@ describe("Plugin", () => {
|
||||
sessionID: Session.ID.make("ses_hook_reject"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_hook_reject"),
|
||||
call: { type: "tool-call", id: "call-hook-reject", name: "missing", input: { text: "original" } },
|
||||
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
|
||||
@@ -611,11 +611,6 @@ describe("fromPromise", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
await ctx.tool.hook("execute.before", (event) => {
|
||||
expect(event.tool).toBe("helllo")
|
||||
expect(event).not.toHaveProperty("inputSchema")
|
||||
event.tool = "hello"
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -629,7 +624,7 @@ describe("fromPromise", () => {
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_tool"),
|
||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||
call: { type: "tool-call", id: "call_promise_tool", name: "helllo", input: { name: "world" } },
|
||||
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
output: "Hello, world!",
|
||||
|
||||
@@ -6,10 +6,6 @@ import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { route } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
@@ -42,9 +38,7 @@ const imageStore = Layer.mock(Image.Service, {
|
||||
})
|
||||
},
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node, SessionModelRequest.node]), [
|
||||
[Image.node, imageStore],
|
||||
])
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [[Image.node, imageStore]])
|
||||
const it = testEffect(registryLayer)
|
||||
const identity = {
|
||||
agent: Agent.ID.make("build"),
|
||||
@@ -92,116 +86,6 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs names and inputs before lookup using the captured request tool set", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* transform(service, { echo: constant("captured"), hidden: make() }, { codemode: false })
|
||||
const snapshot = yield* service.snapshot()
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
const echo = event.tools.echo
|
||||
if (!echo) throw new Error("Expected echo definition")
|
||||
event.tools.alias = echo
|
||||
delete event.tools.echo
|
||||
delete event.tools.hidden
|
||||
}),
|
||||
)
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
scope: {
|
||||
session: Schema.decodeUnknownSync(Session.Info)({
|
||||
id: sessionID,
|
||||
projectID: "project",
|
||||
location: { directory: "/test" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}),
|
||||
agentID: identity.agent,
|
||||
model: SessionRunnerModel.resolved(LanguageModel.make({ id: "test", provider: "test", route }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}),
|
||||
tools: snapshot,
|
||||
},
|
||||
transcript: { system: [], messages: [] },
|
||||
})
|
||||
expect(prepared.request.tools.map((tool) => tool.name)).toEqual(["execute", "alias"])
|
||||
yield* transform(service, { echo: constant("new") }, { codemode: false })
|
||||
const before: string[] = []
|
||||
const after: string[] = []
|
||||
yield* hooks.register("tool", "execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event).not.toHaveProperty("inputSchema")
|
||||
before.push(event.tool)
|
||||
event.tool = event.tool === "typo" ? "alias" : event.tool
|
||||
event.input = { text: "corrected" }
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("tool", "execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
after.push(event.tool)
|
||||
expect(event.input).toEqual({ text: "corrected" })
|
||||
}),
|
||||
)
|
||||
expect((yield* prepared.executeTool(call("typo"))).output).toEqual({ text: "captured" })
|
||||
expect(before).toEqual(["typo"])
|
||||
expect(after).toEqual(["echo"])
|
||||
expect(yield* prepared.executeTool(call("hidden")).pipe(Effect.flip)).toMatchObject({
|
||||
message: "Tool is not available for this request: hidden",
|
||||
})
|
||||
expect(yield* prepared.executeTool(call("echo")).pipe(Effect.flip)).toMatchObject({
|
||||
message: "Tool is not available for this request: echo",
|
||||
})
|
||||
expect(yield* prepared.executeTool(call("missing")).pipe(Effect.flip)).toMatchObject({
|
||||
message: "Unknown tool: missing",
|
||||
})
|
||||
expect(before).toEqual(["typo", "hidden", "echo", "missing"])
|
||||
expect(after).toEqual(["echo"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("hooks execute and known Code Mode calls once but leaves unknown interpreter paths unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* transform(service, { echo: make() })
|
||||
const seen: string[] = []
|
||||
yield* hooks.register("tool", "execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(event.tool)
|
||||
expect(event).not.toHaveProperty("inputSchema")
|
||||
if (event.tool === "run_code") event.tool = "execute"
|
||||
}),
|
||||
)
|
||||
const snapshot = yield* service.snapshot()
|
||||
const known = yield* snapshot.execute({
|
||||
...call("run_code"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "known",
|
||||
name: "run_code",
|
||||
input: { code: 'return await tools.echo({ text: "hello" })' },
|
||||
},
|
||||
})
|
||||
expect(known.output).toMatchObject({ output: '{\n "text": "hello"\n}' })
|
||||
expect(seen).toEqual(["run_code", "echo"])
|
||||
const unknown = yield* snapshot.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "unknown",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.missing({})" },
|
||||
},
|
||||
})
|
||||
expect(unknown.output).toMatchObject({ error: true })
|
||||
expect(seen).toEqual(["run_code", "echo", "execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { OpenCode, type MigrationV1StatusOutput } from "@opencode-ai/client/promise"
|
||||
import type { MigrationV1StatusOutput } from "@opencode-ai/client/promise"
|
||||
import { createDesktopServerApi } from "./platform/server-client"
|
||||
import { sidecarHttp } from "./startup/initialization"
|
||||
import { useLanguage } from "@opencode-ai/app/desktop"
|
||||
import { Loader } from "@opencode-ai/ui/loader"
|
||||
import { showToast, toaster, Toast } from "@opencode-ai/ui/toast"
|
||||
@@ -11,6 +13,7 @@ export function MigrationStatus(props: { server: ServerReadyData }) {
|
||||
const language = useLanguage()
|
||||
const [progress, setProgress] = createSignal<Progress>()
|
||||
const abort = new AbortController()
|
||||
const client = createDesktopServerApi(sidecarHttp(props.server))
|
||||
let toastID: number | undefined
|
||||
let disposeToast: (() => void) | undefined
|
||||
|
||||
@@ -49,16 +52,9 @@ export function MigrationStatus(props: { server: ServerReadyData }) {
|
||||
await wait(1_000, abort.signal)
|
||||
if (abort.signal.aborted) return
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: props.server.url,
|
||||
headers: props.server.password
|
||||
? { Authorization: `Basic ${btoa(`${props.server.username ?? "opencode"}:${props.server.password}`)}` }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
while (true) {
|
||||
const status = await client.migration.v1.status({ signal: abort.signal })
|
||||
const status = await client.api.migration.v1.status({ signal: abort.signal })
|
||||
setProgress(status.status === "running" ? status.progress : undefined)
|
||||
if (status.status === "running") show()
|
||||
else hide()
|
||||
@@ -66,20 +62,23 @@ export function MigrationStatus(props: { server: ServerReadyData }) {
|
||||
if (status.status === "error") throw new Error(status.error)
|
||||
await wait(1_000, abort.signal)
|
||||
}
|
||||
})().catch((error) => {
|
||||
if (abort.signal.aborted) return
|
||||
hide()
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.migration.failed.title"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
duration: 10_000,
|
||||
})()
|
||||
.catch((error) => {
|
||||
if (abort.signal.aborted) return
|
||||
hide()
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.migration.failed.title"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
duration: 10_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
.finally(() => client.dispose())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
void client.dispose()
|
||||
hide()
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createDesktopFiles } from "./files"
|
||||
import { createDesktopMenuAction } from "./menu"
|
||||
import { createDesktopNotify } from "./notifications"
|
||||
import { createDesktopStorage } from "./storage"
|
||||
import { createDesktopServerApi } from "./server-client"
|
||||
|
||||
export type DesktopWindowState = {
|
||||
id: string
|
||||
@@ -40,6 +41,7 @@ export function createDesktopPlatform(
|
||||
if (input instanceof Request) return fetch(input)
|
||||
return fetch(input, init)
|
||||
},
|
||||
createServerApi: createDesktopServerApi,
|
||||
getDefaultServer: async () => {
|
||||
const url = await api.getDefaultServerUrl().catch(() => null)
|
||||
if (!url) return null
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { OpenCodeRpc } from "@opencode-ai/client/promise/rpc"
|
||||
import type { ServerConnection } from "@opencode-ai/app/desktop"
|
||||
|
||||
export function createDesktopServerApi(server: ServerConnection.HttpBase) {
|
||||
const api = OpenCodeRpc.make({
|
||||
baseUrl: server.url,
|
||||
headers: server.password
|
||||
? { Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}` }
|
||||
: undefined,
|
||||
})
|
||||
return { api, dispose: () => api.dispose() }
|
||||
}
|
||||
@@ -930,13 +930,17 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||
? access(inputs[0].name)
|
||||
: `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`
|
||||
}
|
||||
const params = promiseInput(endpoint).filter((field) => field.source === "params" || field.source === "wildcard")
|
||||
const parts = [
|
||||
params.length === 0 && endpoint.params === undefined
|
||||
? undefined
|
||||
: `params: { ${params.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`,
|
||||
endpoint.query === undefined ? undefined : `query: ${part("query")}`,
|
||||
endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`,
|
||||
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
|
||||
].filter((value): value is string => value !== undefined)
|
||||
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
|
||||
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
|
||||
const descriptor = `{ operation: ${JSON.stringify(endpoint.endpoint.identifier)}, method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
|
||||
if (endpoint.operation.success === "stream") {
|
||||
const success = endpoint.successes[0]
|
||||
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
|
||||
@@ -1224,7 +1228,54 @@ function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Gr
|
||||
const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0]))
|
||||
const usesWildcard = endpoints.some((endpoint) => promiseWildcardInput(endpoint) !== undefined)
|
||||
|
||||
const sseReady = replaceOne(content, "let next: ReadableStreamReadResult<Uint8Array>", "let next")
|
||||
const transportReady = [
|
||||
[
|
||||
"readonly fetch?: typeof globalThis.fetch",
|
||||
"readonly fetch?: typeof globalThis.fetch\n readonly transport?: ClientTransport",
|
||||
],
|
||||
[
|
||||
"interface RequestDescriptor {",
|
||||
`export interface ClientTransport {
|
||||
readonly request: (descriptor: RequestDescriptor, options: RequestOptions) => Promise<unknown>
|
||||
readonly stream: (descriptor: RequestDescriptor, options: RequestOptions) => AsyncIterable<unknown>
|
||||
}
|
||||
|
||||
export interface RequestDescriptor {
|
||||
readonly operation: string
|
||||
readonly params?: Record<string, unknown>`,
|
||||
],
|
||||
[
|
||||
" const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n",
|
||||
" const prepareHeaders = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n",
|
||||
],
|
||||
[
|
||||
' if (descriptor.body !== undefined && !headers.has("content-type"))',
|
||||
` return headers
|
||||
}
|
||||
|
||||
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
||||
const url = new URL(descriptor.path, options.baseUrl)
|
||||
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
|
||||
const headers = prepareHeaders(descriptor, requestOptions)
|
||||
if (descriptor.body !== undefined && !headers.has("content-type"))`,
|
||||
],
|
||||
[
|
||||
" const response = await execute(descriptor, requestOptions)",
|
||||
` if (options.transport) return await options.transport.request(descriptor, {
|
||||
...requestOptions, headers: prepareHeaders(descriptor, requestOptions),
|
||||
}) as A
|
||||
const response = await execute(descriptor, requestOptions)`,
|
||||
],
|
||||
[
|
||||
"const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({",
|
||||
`const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => options.transport
|
||||
? options.transport.stream(descriptor, {
|
||||
...requestOptions, headers: prepareHeaders(descriptor, requestOptions),
|
||||
}) as AsyncIterable<A>
|
||||
: ({`,
|
||||
],
|
||||
].reduce((source, [search, replacement]) => replaceOne(source, search!, replacement!), content)
|
||||
const sseReady = replaceOne(transportReady, "let next: ReadableStreamReadResult<Uint8Array>", "let next")
|
||||
const binaryReady = usesBinary
|
||||
? replaceOne(
|
||||
replaceOne(sseReady, "readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary?: true\n}"),
|
||||
|
||||
@@ -814,6 +814,75 @@ describe("HttpApiCodegen.generate", () => {
|
||||
).toThrow("Unsupported Promise stream: session.events")
|
||||
})
|
||||
|
||||
test("passes operation descriptors and merged options directly to a Promise transport", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
HttpApi.make("test").add(
|
||||
HttpApiGroup.make("session").add(
|
||||
HttpApiEndpoint.post("update", "/session/:sessionID", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: { limit: Schema.NumberFromString },
|
||||
headers: { "x-ticket": Schema.String },
|
||||
payload: Schema.Struct({ title: Schema.String }),
|
||||
success: Schema.Struct({ data: Schema.String }),
|
||||
}),
|
||||
HttpApiEndpoint.get("events", "/events", { success: HttpApiSchema.StreamSse({ data: Schema.String }) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
const signal = new AbortController().signal
|
||||
const iterable = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield "event"
|
||||
},
|
||||
}
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
headers: { "x-default": "default", "x-ticket": "default" },
|
||||
fetch: () => {
|
||||
throw new Error("transport must not fetch")
|
||||
},
|
||||
transport: {
|
||||
request: async (descriptor: unknown, options: { headers: Headers; signal: AbortSignal }) => {
|
||||
expect(descriptor).toMatchObject({
|
||||
operation: "update",
|
||||
params: { sessionID: "a/b" },
|
||||
query: { limit: 2 },
|
||||
body: { title: "title" },
|
||||
})
|
||||
expect(options.signal).toBe(signal)
|
||||
expect(options.headers.get("x-default")).toBe("default")
|
||||
expect(options.headers.get("x-ticket")).toBe("override")
|
||||
return { data: "updated" }
|
||||
},
|
||||
stream: (descriptor: { operation: string }, options: { headers: Headers; signal: AbortSignal }) => {
|
||||
expect(descriptor.operation).toBe("events")
|
||||
expect(options.signal).toBe(signal)
|
||||
expect(options.headers.get("x-default")).toBe("default")
|
||||
return iterable
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(
|
||||
await client.session.update(
|
||||
{ sessionID: "a/b", limit: 2, "x-ticket": "endpoint", title: "title" },
|
||||
{
|
||||
signal,
|
||||
headers: { "x-ticket": "override" },
|
||||
},
|
||||
),
|
||||
).toBe("updated")
|
||||
expect(client.session.events({ signal })).toBe(iterable)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("executes an emitted Promise GET through fetch", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Effect, Types } from "effect"
|
||||
import type { Effect, JsonSchema, Types } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolDraft {
|
||||
@@ -18,7 +18,8 @@ export interface ToolDraft {
|
||||
|
||||
export interface ToolHooks {
|
||||
readonly "execute.before": {
|
||||
tool: string
|
||||
readonly tool: string
|
||||
readonly inputSchema: JsonSchema.JsonSchema
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Types } from "effect"
|
||||
import type { JsonSchema, Types } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolContext extends Omit<Tool.Context, "progress"> {
|
||||
@@ -35,7 +35,8 @@ interface ToolDraft {
|
||||
|
||||
interface ToolHooks {
|
||||
readonly "execute.before": {
|
||||
tool: string
|
||||
readonly tool: string
|
||||
readonly inputSchema: JsonSchema.JsonSchema
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export * as OpenCodeRpc from "./rpc.js"
|
||||
|
||||
import { Predicate, Schema, SchemaAST, Stream } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Rpc, RpcGroup, RpcSchema } from "effect/unstable/rpc"
|
||||
import { ClientApi } from "./client.js"
|
||||
import { LocationQuery } from "./groups/location.js"
|
||||
|
||||
export const omitEndpoints: ReadonlySet<string> = new Set(["pty.connect", "persistentPty.connect"])
|
||||
|
||||
export const FileRead = Schema.Struct({ content: Schema.Uint8ArrayFromBase64, mime: Schema.String })
|
||||
export const FileReadParams = Schema.Struct({ path: Schema.String })
|
||||
|
||||
type Part<K extends string, S extends Schema.Constraint> = [S] extends [never] ? {} : { readonly [P in K]: S["Type"] }
|
||||
|
||||
export type Request<E extends HttpApiEndpoint.ConstraintRequest> = (E["identifier"] extends "fs.read"
|
||||
? { readonly params: typeof FileReadParams.Type }
|
||||
: Part<"params", E["~Params"]>) &
|
||||
Part<"query", E["~Query"]> &
|
||||
Part<"payload", E["~Payload"]> &
|
||||
Part<"headers", E["~Headers"]> & {
|
||||
readonly location?: typeof LocationQuery.Type.location
|
||||
}
|
||||
|
||||
type Success<S extends Schema.Constraint> =
|
||||
S["Type"] extends Stream.Stream<infer A, infer E>
|
||||
? RpcSchema.Stream<Schema.Codec<A, unknown>, Schema.Codec<E, unknown>>
|
||||
: Schema.Codec<S["Type"], unknown>
|
||||
|
||||
export type Endpoint<E extends HttpApiEndpoint.ConstraintRequest> = E extends HttpApiEndpoint.ConstraintRequest
|
||||
? E["identifier"] extends "pty.connect" | "persistentPty.connect"
|
||||
? never
|
||||
: Rpc.Rpc<
|
||||
E["identifier"],
|
||||
Schema.Codec<Request<E>, unknown>,
|
||||
E["identifier"] extends "fs.read" ? typeof FileRead : Success<E["~Success"]>,
|
||||
Schema.Codec<E["~Error"]["Type"] | HttpApiMiddleware.Error<E["~Middleware"]>, unknown>
|
||||
>
|
||||
: never
|
||||
|
||||
type Endpoints<G extends HttpApiGroup.Constraint> =
|
||||
HttpApiGroup.Endpoints<G> extends infer E
|
||||
? E extends HttpApiEndpoint.ConstraintRequest
|
||||
? Endpoint<E>
|
||||
: never
|
||||
: never
|
||||
|
||||
/** Convert HTTP stream declarations to RPC streams without serializing an SSE envelope. */
|
||||
export function successSchema(schema: Schema.Top): Schema.Top {
|
||||
if (RpcSchema.isStreamSchema(schema)) return schema
|
||||
if (!isHttpStream(schema)) return Schema.toCodecJson(schema)
|
||||
if (schema._tag === "StreamUint8Array") {
|
||||
return RpcSchema.Stream(Schema.Uint8ArrayFromBase64, Schema.Unknown)
|
||||
}
|
||||
if (schema.sseMode === "events") {
|
||||
return RpcSchema.Stream(Schema.toCodecJson(schema.events), Schema.toCodecJson(schema.error))
|
||||
}
|
||||
// StreamSse({ data }) stores its original data codec in the event struct's data field.
|
||||
const ast = SchemaAST.toType(schema.events.ast)
|
||||
const data = SchemaAST.isObjects(ast) ? ast.propertySignatures.find((field) => field.name === "data") : undefined
|
||||
if (!data) throw new Error("SSE data schema is missing its data field")
|
||||
return RpcSchema.Stream(Schema.toCodecJson(Schema.make(data.type)), Schema.toCodecJson(schema.error))
|
||||
}
|
||||
|
||||
function isHttpStream(schema: Schema.Top): schema is HttpApiSchema.StreamSchema {
|
||||
return Predicate.hasProperty(schema, "~effect/httpapi/HttpApiSchema/Stream")
|
||||
}
|
||||
|
||||
export function fromEndpoint<E extends HttpApiEndpoint.ConstraintRequest>(input: E): Endpoint<E> {
|
||||
const endpoint = input as unknown as HttpApiEndpoint.Top
|
||||
if (omitEndpoints.has(endpoint.identifier)) throw new Error(`Raw WebSocket endpoint: ${endpoint.identifier}`)
|
||||
const payload = Array.from(endpoint.payload.values()).flatMap((entry) => entry.schemas)
|
||||
const success = endpoint.success.size ? Array.from(endpoint.success) : [HttpApiSchema.NoContent]
|
||||
const middleware = Array.from(endpoint.middlewares) as unknown as HttpApiMiddleware.AnyService[]
|
||||
const errors = [...endpoint.error, ...middleware.flatMap((service) => Array.from(service.error))]
|
||||
if (success.length > 1 && success.some(isHttpStream)) {
|
||||
throw new Error(`Mixed streaming responses are not supported: ${endpoint.identifier}`)
|
||||
}
|
||||
const request = Schema.Struct({
|
||||
...(endpoint.identifier === "fs.read"
|
||||
? { params: FileReadParams }
|
||||
: endpoint.params
|
||||
? { params: Schema.toCodecJson(Schema.toType(endpoint.params)) }
|
||||
: {}),
|
||||
...(endpoint.query ? { query: Schema.toCodecJson(Schema.toType(endpoint.query)) } : {}),
|
||||
...(endpoint.headers ? { headers: Schema.toCodecJson(Schema.toType(endpoint.headers)) } : {}),
|
||||
...(payload.length ? { payload: Schema.toCodecJson(Schema.toType(Schema.Union(payload))) } : {}),
|
||||
location: LocationQuery.fields.location,
|
||||
})
|
||||
return Rpc.make(endpoint.identifier, {
|
||||
payload: request,
|
||||
success:
|
||||
endpoint.identifier === "fs.read"
|
||||
? FileRead
|
||||
: success.length === 1
|
||||
? successSchema(success[0]!)
|
||||
: Schema.Union(success.map(successSchema)),
|
||||
error: Schema.toCodecJson(Schema.Union([...new Set(errors)])),
|
||||
}) as unknown as Endpoint<E>
|
||||
}
|
||||
|
||||
/** Server passes its concrete HttpApi so its middleware error schemas are retained. */
|
||||
export function makeGroup<Id extends string, G extends HttpApiGroup.Constraint>(
|
||||
api: HttpApi.HttpApi<Id, G>,
|
||||
): RpcGroup.RpcGroup<Endpoints<G>> {
|
||||
const groups = Object.values((api as unknown as HttpApi.Top).groups)
|
||||
return RpcGroup.make(
|
||||
...groups.flatMap((group) =>
|
||||
Object.values(group.endpoints)
|
||||
.filter((endpoint) => !omitEndpoints.has(endpoint.identifier))
|
||||
.map(fromEndpoint),
|
||||
),
|
||||
) as unknown as RpcGroup.RpcGroup<Endpoints<G>>
|
||||
}
|
||||
|
||||
export type Rpcs = Endpoints<(typeof ClientApi.groups)[keyof typeof ClientApi.groups]>
|
||||
export const Group: RpcGroup.RpcGroup<Rpcs> = makeGroup(ClientApi)
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { RpcSchema } from "effect/unstable/rpc"
|
||||
import { ClientApi } from "../src/client.js"
|
||||
import { InvalidRequestError, SessionNotFoundError, UnauthorizedError } from "../src/errors.js"
|
||||
import { OpenCodeRpc } from "../src/rpc.js"
|
||||
|
||||
test("every HTTP operation except the raw PTY sockets has a native RPC", () => {
|
||||
const expected = Object.values(ClientApi.groups).flatMap((group) =>
|
||||
Object.values(group.endpoints).map((endpoint) => endpoint.identifier),
|
||||
)
|
||||
expect([...OpenCodeRpc.Group.requests.keys()].sort()).toEqual(
|
||||
expected.filter((name) => !OpenCodeRpc.omitEndpoints.has(name)).sort(),
|
||||
)
|
||||
})
|
||||
|
||||
test("request envelopes preserve decoded numeric queries and validate required params", () => {
|
||||
const list = OpenCodeRpc.fromEndpoint(ClientApi.groups["server.session"].endpoints["session.list"])
|
||||
expect(Schema.decodeUnknownSync(list.payloadSchema)({ query: { limit: 10 } })).toEqual({ query: { limit: 10 } })
|
||||
expect(() => Schema.decodeUnknownSync(list.payloadSchema)({ query: { limit: "10" } })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(list.payloadSchema)({ query: { limit: -1 } })).toThrow()
|
||||
const get = OpenCodeRpc.fromEndpoint(ClientApi.groups["server.session"].endpoints["session.get"])
|
||||
expect(() => Schema.decodeUnknownSync(get.payloadSchema)({})).toThrow()
|
||||
})
|
||||
|
||||
test("SSE streams retain their typed items, without SSE framing", () => {
|
||||
const rpc = OpenCodeRpc.fromEndpoint(ClientApi.groups["server.session"].endpoints["session.log"])
|
||||
expect(RpcSchema.isStreamSchema(rpc.successSchema)).toBe(true)
|
||||
const item = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
||||
expect(Schema.decodeUnknownSync(rpc.successSchema.success)(item)).toEqual(item)
|
||||
expect(() => Schema.decodeUnknownSync(rpc.successSchema.success)({ type: "not-an-event" })).toThrow()
|
||||
const events = OpenCodeRpc.fromEndpoint(ClientApi.groups["server.event"].endpoints["event.subscribe"])
|
||||
expect(
|
||||
Schema.decodeUnknownSync(events.successSchema.success)({ id: "evt_connected", type: "server.connected", data: {} }),
|
||||
).toEqual({ id: Event.ID.make("evt_connected"), type: "server.connected", data: {} })
|
||||
})
|
||||
|
||||
test("binary file reads have explicit path and base64 JSON codecs", () => {
|
||||
const rpc = OpenCodeRpc.fromEndpoint(ClientApi.groups["server.fs"].endpoints["fs.read"])
|
||||
expect(Schema.decodeUnknownSync(rpc.payloadSchema)({ params: { path: "a.bin" }, query: {} })).toEqual({
|
||||
params: { path: "a.bin" },
|
||||
query: {},
|
||||
})
|
||||
const value = { content: new Uint8Array([0, 255, 128]), mime: "application/octet-stream" }
|
||||
const encoded = Schema.encodeSync(rpc.successSchema)(value)
|
||||
expect(encoded).toEqual({ content: "AP+A", mime: "application/octet-stream" })
|
||||
expect(Schema.decodeUnknownSync(rpc.successSchema)(JSON.parse(JSON.stringify(encoded)))).toEqual(value)
|
||||
})
|
||||
|
||||
test("NoContent remains void and endpoint plus middleware errors remain typed", () => {
|
||||
const rpc = OpenCodeRpc.fromEndpoint(HttpApiEndpoint.delete("test.remove", "/test"))
|
||||
expect(Schema.encodeSync(rpc.successSchema)(undefined)).toBeNull()
|
||||
expect(Schema.decodeUnknownSync(rpc.successSchema)(null)).toBeUndefined()
|
||||
const get = OpenCodeRpc.fromEndpoint(ClientApi.groups["server.session"].endpoints["session.get"])
|
||||
for (const error of [
|
||||
new InvalidRequestError({ message: "invalid" }),
|
||||
new UnauthorizedError({ message: "unauthorized" }),
|
||||
new SessionNotFoundError({ sessionID: "ses_test", message: "missing" }),
|
||||
]) {
|
||||
expect(Schema.decodeUnknownSync(get.errorSchema)(Schema.encodeSync(get.errorSchema)(error))).toEqual(error)
|
||||
}
|
||||
})
|
||||
|
||||
test("makeGroup retains concrete server middleware errors and stream errors", () => {
|
||||
class TestError extends Schema.TaggedError<TestError>()("TestError", { message: Schema.String }) {}
|
||||
class Middleware extends HttpApiMiddleware.Service<Middleware>()("test/rpc", { error: TestError }) {}
|
||||
const api = HttpApi.make("test").add(
|
||||
HttpApiGroup.make("test").add(
|
||||
HttpApiEndpoint.get("test.stream", "/test", {
|
||||
success: HttpApiSchema.StreamSse({ data: Schema.Number, error: TestError }),
|
||||
}).middleware(Middleware),
|
||||
),
|
||||
)
|
||||
const rpc = OpenCodeRpc.makeGroup(api).requests.get("test.stream")!
|
||||
const error = new TestError({ message: "failed" })
|
||||
expect(Schema.decodeUnknownSync(rpc.errorSchema)(Schema.encodeSync(rpc.errorSchema)(error))).toEqual(error)
|
||||
expect(Schema.decodeUnknownSync(rpc.successSchema.error)({ _tag: "TestError", message: "failed" })).toEqual(error)
|
||||
expect(Schema.decodeUnknownSync(rpc.successSchema.success)(42)).toBe(42)
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ClientApi } from "@opencode-ai/protocol/client"
|
||||
import { OpenCodeRpc } from "@opencode-ai/protocol/rpc"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { RpcClient, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
// A loopback transport microbenchmark, not an end-to-end desktop performance claim.
|
||||
// Both clients decode the same schemas and read the same 50-session page.
|
||||
await Effect.gen(function* () {
|
||||
const directory = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-rpc-bench-"))),
|
||||
(directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
|
||||
)
|
||||
const password = crypto.randomUUID()
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password,
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(directory, "config"), project: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false, fff: false },
|
||||
})
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
const http = yield* HttpApiClient.make(ClientApi, {
|
||||
baseUrl: url,
|
||||
transformClient: (client) =>
|
||||
HttpClient.mapRequest(
|
||||
client,
|
||||
HttpClientRequest.setHeader("authorization", `Basic ${btoa(`opencode:${password}`)}`),
|
||||
),
|
||||
}).pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const socket = new URL("/api/rpc", url)
|
||||
socket.protocol = "ws:"
|
||||
socket.searchParams.set("auth_token", btoa(`opencode:${password}`))
|
||||
const protocol = yield* Layer.build(
|
||||
RpcClient.layerProtocolSocket({ retryTransientErrors: false }).pipe(
|
||||
Layer.provide(RpcSerialization.layerJson),
|
||||
Layer.provide(Socket.layerWebSocket(socket.href).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))),
|
||||
),
|
||||
)
|
||||
const rpc = yield* RpcClient.make(OpenCodeRpc.Group).pipe(Effect.provideContext(protocol))
|
||||
yield* Effect.forEach(
|
||||
Array.from({ length: 50 }, (_, index) => index),
|
||||
(index) =>
|
||||
rpc["session.create"]({
|
||||
payload: { title: `Benchmark ${index}`, location: { directory: AbsolutePath.make(directory) } },
|
||||
}),
|
||||
)
|
||||
const calls: ReadonlyArray<{ transport: string; call: Effect.Effect<unknown, unknown> }> = [
|
||||
{ transport: "HTTP", call: http["server.session"]["session.list"]({ query: {} }) },
|
||||
{ transport: "RPC", call: rpc["session.list"]({ query: {} }) },
|
||||
]
|
||||
for (const concurrency of [1, 16]) {
|
||||
for (const { transport, call } of calls) {
|
||||
yield* Effect.forEach(Array.from({ length: 30 }), () => call, { concurrency })
|
||||
const start = performance.now()
|
||||
const samples = yield* Effect.forEach(
|
||||
Array.from({ length: 300 }),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const start = performance.now()
|
||||
yield* call
|
||||
return performance.now() - start
|
||||
}),
|
||||
{ concurrency },
|
||||
)
|
||||
const total = performance.now() - start
|
||||
samples.sort((a, b) => a - b)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
transport,
|
||||
concurrency,
|
||||
requests: samples.length,
|
||||
elapsedMs: Math.round(total),
|
||||
requestsPerSecond: Math.round((samples.length / total) * 1000),
|
||||
p50Ms: Number(samples[Math.floor(samples.length * 0.5)]!.toFixed(2)),
|
||||
p95Ms: Number(samples[Math.floor(samples.length * 0.95)]!.toFixed(2)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.timeout("60 seconds"), Effect.scoped, Effect.runPromise)
|
||||
@@ -22,6 +22,7 @@ export type Error = SubscriberOverflowError | EncodingError
|
||||
|
||||
export interface Interface {
|
||||
readonly subscribe: Effect.Effect<Stream.Stream<string, Error>, never, Scope.Scope>
|
||||
readonly subscribeEvents: Effect.Effect<Stream.Stream<OpenCodeEvent, Error>, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {}
|
||||
@@ -37,6 +38,7 @@ export const make = Effect.fn("EventFeed.make")(function* (
|
||||
const capacity = options?.capacity ?? SubscriberCapacity
|
||||
const render = options?.encode ?? frame
|
||||
const subscribers = new Set<Queue.Queue<string, Error>>()
|
||||
const events = new Set<Queue.Queue<OpenCodeEvent, Error>>()
|
||||
|
||||
const fail = (error: Error) =>
|
||||
Effect.sync(() => {
|
||||
@@ -47,6 +49,11 @@ export const make = Effect.fn("EventFeed.make")(function* (
|
||||
|
||||
const publish = Effect.fnUntraced(function* (event: Event.Payload) {
|
||||
if (!isOpenCodeEvent(event)) return
|
||||
for (const subscriber of events) {
|
||||
if (Queue.offerUnsafe(subscriber, event)) continue
|
||||
events.delete(subscriber)
|
||||
Queue.failCauseUnsafe(subscriber, Cause.fail(new SubscriberOverflowError({ capacity })))
|
||||
}
|
||||
if (subscribers.size === 0) return
|
||||
const encoded = yield* Effect.try({
|
||||
try: () => render(event),
|
||||
@@ -72,6 +79,10 @@ export const make = Effect.fn("EventFeed.make")(function* (
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
return Service.of({
|
||||
subscribeEvents: Effect.acquireRelease(
|
||||
Queue.dropping<OpenCodeEvent, Error>(capacity).pipe(Effect.tap((queue) => Effect.sync(() => events.add(queue)))),
|
||||
(queue) => Effect.sync(() => events.delete(queue)).pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid),
|
||||
).pipe(Effect.map(Stream.fromQueue)),
|
||||
subscribe: Effect.acquireRelease(
|
||||
Queue.dropping<string, Error>(capacity).pipe(Effect.tap((queue) => Effect.sync(() => subscribers.add(queue)))),
|
||||
(queue) =>
|
||||
|
||||
@@ -6,17 +6,19 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
export const readFile = Effect.fn("Server.readFile")(function* (path: RelativePath) {
|
||||
const fs = yield* FileSystem.Service
|
||||
return yield* fs.read({ path })
|
||||
})
|
||||
|
||||
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handleRaw("fs.read", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.Service
|
||||
const file = yield* fs.read({
|
||||
path: RelativePath.make(
|
||||
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
|
||||
),
|
||||
})
|
||||
const file = yield* readFile(
|
||||
RelativePath.make(decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))),
|
||||
)
|
||||
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -36,6 +36,8 @@ import { Context, Effect, Layer, Option } from "effect"
|
||||
import { Api } from "./api"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { handlers } from "./handlers"
|
||||
import { rpcRoutes } from "./rpc"
|
||||
import { EventFeed } from "./event-feed"
|
||||
import { authorizationLayer } from "./middleware/authorization"
|
||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
import { PtyEnvironment } from "./pty-environment"
|
||||
@@ -147,7 +149,8 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
),
|
||||
ServerInfo.layer(serviceURLs, options.app),
|
||||
)
|
||||
const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
const api = Layer.merge(HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }), rpcRoutes).pipe(
|
||||
Layer.provide(EventFeed.layer.pipe(Layer.provide(services))),
|
||||
Layer.provide(handlers.pipe(Layer.provide(services))),
|
||||
Layer.provide(formLocationLayer),
|
||||
Layer.provide(sessionLocationLayer),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Context, Effect, Layer, Scope, Stream } from "effect"
|
||||
import { Headers, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiGroup, HttpApiEndpoint } from "effect/unstable/httpapi"
|
||||
import { Rpc, RpcGroup, RpcSchema, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { OpenCodeRpc } from "@opencode-ai/protocol/rpc"
|
||||
import { Api } from "./api"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { CorsConfig, isAllowedRequestOrigin } from "./cors"
|
||||
import { EventFeed } from "./event-feed"
|
||||
import type { handlers } from "./handlers"
|
||||
import { readFile } from "./handlers/fs"
|
||||
import { LocationMiddleware } from "./location"
|
||||
import { Authorization, authorizedRequest } from "./middleware/authorization"
|
||||
import { FormLocationMiddleware } from "./middleware/form-location"
|
||||
import { SessionLocationMiddleware } from "./middleware/session-location"
|
||||
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||
|
||||
type Services =
|
||||
| Layer.Success<typeof handlers>
|
||||
| ServerAuth.Config
|
||||
| EventFeed.Service
|
||||
| LocationMiddleware
|
||||
| FormLocationMiddleware
|
||||
| SessionLocationMiddleware
|
||||
| Authorization
|
||||
| SchemaErrorMiddleware
|
||||
|
||||
type Input = {
|
||||
readonly params?: Readonly<Record<string, string>>
|
||||
readonly query?: { readonly location?: { readonly directory?: string; readonly workspace?: string } }
|
||||
readonly payload?: unknown
|
||||
readonly headers?: Readonly<Record<string, string | undefined>>
|
||||
readonly location?: { readonly directory?: string; readonly workspace?: string }
|
||||
}
|
||||
|
||||
type Middleware = (
|
||||
effect: Effect.Effect<unknown, unknown, unknown>,
|
||||
options: { readonly group: HttpApiGroup.Top; readonly endpoint: HttpApiEndpoint.Top },
|
||||
) => Effect.Effect<unknown, unknown, unknown>
|
||||
|
||||
type Handler = {
|
||||
readonly endpoint: HttpApiEndpoint.Top
|
||||
readonly uninterruptible: boolean
|
||||
readonly handler: (
|
||||
input: Input & {
|
||||
readonly request: HttpServerRequest.HttpServerRequest
|
||||
readonly group: HttpApiGroup.Top
|
||||
readonly endpoint: HttpApiEndpoint.Top
|
||||
},
|
||||
) => Effect.Effect<unknown, unknown, unknown>
|
||||
}
|
||||
|
||||
export const rpcRoutes = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const services = yield* Effect.context<Services>()
|
||||
const config = yield* ServerAuth.Config
|
||||
const feed = yield* EventFeed.Service
|
||||
const group = OpenCodeRpc.makeGroup(Api)
|
||||
// HttpApiBuilder stores its decoded handlers beside the built routes. Keep this
|
||||
// dependency on Effect's handler registry here, rather than duplicating handlers
|
||||
// or routing RPC calls through HTTP serialization and parsing.
|
||||
const entries = (Object.values(Api.groups) as unknown as ReadonlyArray<HttpApiGroup.Top>).flatMap((definition) => {
|
||||
const implementation = services.mapUnsafe.get(definition.key) as {
|
||||
readonly handlers: ReadonlyMap<string, Handler>
|
||||
}
|
||||
return Array.from(implementation.handlers.values(), (handler) => ({ definition, ...handler }))
|
||||
})
|
||||
yield* router.add(
|
||||
"GET",
|
||||
"/api/rpc",
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (!(yield* authorizedRequest(request, config)))
|
||||
return HttpServerResponse.empty({
|
||||
status: 401,
|
||||
headers: { "www-authenticate": 'Basic realm="Secure Area"' },
|
||||
})
|
||||
const cors = yield* CorsConfig
|
||||
if (!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors))
|
||||
return HttpServerResponse.empty({ status: 403 })
|
||||
|
||||
// Handler resources belong to an RPC request, not the WebSocket upgrade.
|
||||
const context = Context.merge(services, yield* Effect.context<never>()).pipe(Context.omit(Scope.Scope))
|
||||
const implementations = Object.fromEntries(
|
||||
entries.flatMap((entry) => {
|
||||
const rpc = group.requests.get(entry.endpoint.identifier)
|
||||
if (!rpc) return []
|
||||
const streaming = RpcSchema.isStreamSchema(rpc.successSchema)
|
||||
const invoke = (input: Input, options: { readonly headers: Headers.Headers }) => {
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
url.pathname = entry.endpoint.path
|
||||
const location = input.location ?? input.query?.location
|
||||
if (location) {
|
||||
if (location.directory) url.searchParams.set("location[directory]", location.directory)
|
||||
if (location.workspace) url.searchParams.set("location[workspace]", location.workspace)
|
||||
else url.searchParams.delete("location[workspace]")
|
||||
}
|
||||
// RPC metadata carries contextual headers (global forms and PTY tickets);
|
||||
// the authenticated upgrade remains authoritative for its own headers.
|
||||
const headers = Headers.merge(
|
||||
Headers.merge(Headers.fromInput(input.headers), options.headers),
|
||||
request.headers,
|
||||
)
|
||||
const current = request.modify({
|
||||
url: `${url.pathname}${url.search}`,
|
||||
headers: location ? Headers.remove(headers, "x-opencode-workspace") : headers,
|
||||
})
|
||||
const run = Effect.gen(function* () {
|
||||
if (rpc._tag === "event.subscribe") {
|
||||
const live = yield* feed.subscribeEvents
|
||||
return Stream.make({ id: Event.ID.create(), type: "server.connected" as const, data: {} }).pipe(
|
||||
Stream.concat(live),
|
||||
Stream.orDie,
|
||||
)
|
||||
}
|
||||
if (rpc._tag === "fs.read") return yield* readFile(RelativePath.make(input.params?.path ?? ""))
|
||||
const result = yield* entry.handler({
|
||||
...input,
|
||||
request: current,
|
||||
group: entry.definition,
|
||||
endpoint: entry.endpoint,
|
||||
})
|
||||
// Streams are consumed after middleware returns; retain the location
|
||||
// services selected for this call, not the connection's last location.
|
||||
if (Stream.isStream(result)) return Stream.provideContext(result, yield* Effect.context<never>())
|
||||
return result
|
||||
}) as Effect.Effect<unknown, unknown, unknown>
|
||||
const wrapped = Array.from(entry.endpoint.middlewares).reduce((effect, key) => {
|
||||
// Authentication belongs to the upgrade, not client-supplied frame headers.
|
||||
if (key.key === Authorization.key) return effect
|
||||
const middleware = Context.getUnsafe(context, key) as Middleware
|
||||
if (typeof middleware !== "function") throw new Error(`Unsupported RPC middleware: ${key.key}`)
|
||||
return middleware(effect, { group: entry.definition, endpoint: entry.endpoint })
|
||||
}, run)
|
||||
const effect = (entry.uninterruptible ? Effect.uninterruptible(wrapped) : wrapped).pipe(
|
||||
Effect.provideService(HttpServerRequest.HttpServerRequest, current),
|
||||
Effect.provideService(HttpRouter.RouteContext, {
|
||||
params: input.params ?? {},
|
||||
route: HttpRouter.route(
|
||||
entry.endpoint.method,
|
||||
entry.endpoint.path as HttpRouter.PathInput,
|
||||
HttpServerResponse.empty(),
|
||||
),
|
||||
}),
|
||||
Effect.provideContext(context as Context.Context<unknown>),
|
||||
)
|
||||
return streaming
|
||||
? Stream.unwrap(effect as Effect.Effect<Stream.Stream<unknown, unknown>, unknown>)
|
||||
: effect
|
||||
}
|
||||
return [[rpc._tag, invoke]]
|
||||
}),
|
||||
)
|
||||
const runtime = group as unknown as RpcGroup.RpcGroup<Rpc.Any>
|
||||
const implementation = yield* runtime.toHandlers(implementations as unknown as RpcGroup.HandlersFrom<Rpc.Any>)
|
||||
const websocket = yield* RpcServer.toHttpEffectWebsocket(runtime, { disableFatalDefects: true }).pipe(
|
||||
Effect.provideContext(implementation),
|
||||
Effect.provide(RpcSerialization.layerJson),
|
||||
)
|
||||
return yield* websocket
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -39,6 +39,40 @@ function makeSource() {
|
||||
}
|
||||
|
||||
describe("EventFeed", () => {
|
||||
it.effect("delivers typed RPC events without SSE encoding and filters internal events", () =>
|
||||
Effect.gen(function* () {
|
||||
const source = makeSource()
|
||||
const feed = yield* EventFeed.make(source.observe, {
|
||||
capacity: 1,
|
||||
encode: () => {
|
||||
throw new Error("RPC-only subscribers must not encode SSE")
|
||||
},
|
||||
})
|
||||
const stream = yield* feed.subscribeEvents
|
||||
yield* source.publish(internal("one"))
|
||||
yield* source.publish(internal("two"))
|
||||
const payload = event("rpc")
|
||||
yield* source.publish(payload)
|
||||
expect(yield* stream.pipe(Stream.take(1), Stream.runCollect)).toEqual([payload])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bounds RPC subscriber lag independently of HTTP subscribers", () =>
|
||||
Effect.gen(function* () {
|
||||
const source = makeSource()
|
||||
const feed = yield* EventFeed.make(source.observe, { capacity: 1 })
|
||||
const slow = yield* feed.subscribeEvents
|
||||
yield* source.publish(event("one"))
|
||||
const http = yield* feed.subscribe
|
||||
const payload = event("two")
|
||||
yield* source.publish(payload)
|
||||
const exit = yield* slow.pipe(Stream.runCollect, Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBeTrue()
|
||||
expect(Option.getOrUndefined(Exit.findErrorOption(exit))).toBeInstanceOf(EventFeed.SubscriberOverflowError)
|
||||
expect(yield* http.pipe(Stream.take(1), Stream.runCollect)).toEqual([EventFeed.frame(payload)])
|
||||
}),
|
||||
)
|
||||
|
||||
test("preserves the public SSE frame encoding", () => {
|
||||
const payload = event("wire")
|
||||
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { OpenCodeRpc } from "../../client/src/promise/rpc"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live(
|
||||
"serves the desktop Promise client over native RPC, including location and ticket headers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir("desktop-rpc-")),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "test-password",
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: `${tmp.path}/config`, project: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false, fff: false },
|
||||
})
|
||||
const api = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
OpenCodeRpc.make({
|
||||
baseUrl: HttpServer.formatAddress(server.address),
|
||||
headers: { authorization: `Basic ${btoa("opencode:test-password")}` },
|
||||
}),
|
||||
),
|
||||
(api) => Effect.promise(() => api.dispose()),
|
||||
)
|
||||
const headers = { "x-opencode-directory": encodeURIComponent(tmp.path) }
|
||||
expect((yield* Effect.promise(() => api.location.get(undefined, { headers }))).directory).toBe(tmp.path)
|
||||
|
||||
const events = api.event.subscribe()[Symbol.asyncIterator]()
|
||||
expect(yield* Effect.promise(() => events.next())).toMatchObject({ value: { type: "server.connected" } })
|
||||
const created = yield* Effect.promise(() =>
|
||||
api.session.create({ title: "Desktop RPC", location: { directory: tmp.path } }),
|
||||
)
|
||||
expect(typeof created.time.created).toBe("number")
|
||||
expect((yield* Effect.promise(() => api.session.list({ limit: 1 }))).data[0]?.id).toBe(created.id)
|
||||
const event = yield* Effect.promise(async () => {
|
||||
for (let item = await events.next(); !item.done; item = await events.next()) {
|
||||
if (item.value.type === "session.created") return item.value
|
||||
}
|
||||
})
|
||||
expect(event).toMatchObject({ type: "session.created", data: { sessionID: created.id } })
|
||||
yield* Effect.promise(() => events.return!())
|
||||
expect(
|
||||
yield* Effect.promise(() => api.session.rename({ sessionID: created.id, title: "Renamed" })),
|
||||
).toBeUndefined()
|
||||
expect((yield* Effect.promise(() => api.session.get({ sessionID: created.id }))).title).toBe("Renamed")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(`${tmp.path}/file #%.txt`, "Desktop bytes"))
|
||||
expect(
|
||||
yield* Effect.promise(() => api.file.read({ path: "file #%.txt", location: { directory: tmp.path } })),
|
||||
).toEqual(new TextEncoder().encode("Desktop bytes"))
|
||||
const ticket = yield* Effect.promise(() =>
|
||||
api.pty.connect
|
||||
.token({ ptyID: "pty_missing" }, { headers: { ...headers, "x-opencode-ticket": "1" } })
|
||||
.catch((error) => error),
|
||||
)
|
||||
expect(ticket).toMatchObject({ _tag: "PtyNotFoundError" })
|
||||
expect((yield* Effect.promise(() => api.health.get())).healthy).toBe(true)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
import { expect } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { OpenCodeRpc } from "@opencode-ai/protocol/rpc"
|
||||
import { SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
const authorization = `Basic ${btoa("opencode:secret")}`
|
||||
|
||||
const fixture = Effect.fn(function* <R extends Rpc.Any>(
|
||||
group: RpcGroup.RpcGroup<R>,
|
||||
transform?: ServerProcess.Transform,
|
||||
) {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir("opencode-rpc-")),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const global = path.join(tmp.path, "config")
|
||||
const directories = [path.join(tmp.path, "first"), path.join(tmp.path, "second")]
|
||||
yield* Effect.promise(() => Promise.all([global, ...directories].map((dir) => fs.mkdir(dir))))
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
events: { persist: true },
|
||||
config: { directory: global, project: false },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false, fff: false },
|
||||
},
|
||||
undefined,
|
||||
transform,
|
||||
)
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
const sockets: WebSocket[] = []
|
||||
const protocol = yield* Layer.build(
|
||||
RpcClient.layerProtocolSocket({ retryTransientErrors: false }).pipe(
|
||||
Layer.provide(RpcSerialization.layerJson),
|
||||
Layer.provide(
|
||||
Socket.layerWebSocket(new URL("/api/rpc", url).href.replace("http:", "ws:")).pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Socket.WebSocketConstructor, (url) => {
|
||||
const socket = new NodeSocket.NodeWS.WebSocket(url, {
|
||||
headers: { authorization, origin: "http://localhost:3000" },
|
||||
}) as unknown as WebSocket
|
||||
sockets.push(socket)
|
||||
return socket
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const rpc = yield* RpcClient.make(group).pipe(Effect.provideContext(protocol))
|
||||
const request = (pathname: string, init?: RequestInit) =>
|
||||
Effect.promise(() =>
|
||||
fetch(new URL(pathname, url), {
|
||||
...init,
|
||||
headers: { authorization, "content-type": "application/json", ...init?.headers },
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rpc,
|
||||
request,
|
||||
url,
|
||||
sockets,
|
||||
first: { directory: AbsolutePath.make(directories[0]!) },
|
||||
second: { directory: AbsolutePath.make(directories[1]!) },
|
||||
}
|
||||
})
|
||||
|
||||
it.live(
|
||||
"multiplexes concurrent RPC calls and shares session state with HTTP",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { rpc, request, first, sockets } = yield* fixture(OpenCodeRpc.Group)
|
||||
const sessions = yield* Effect.all(
|
||||
Array.from({ length: 8 }, (_, index) =>
|
||||
rpc["session.create"]({ payload: { title: `parallel-${index}`, location: first } }),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(new Set(sessions.map((session) => session.data.id)).size).toBe(8)
|
||||
expect(sessions.map((session) => session.data.title)).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => `parallel-${index}`),
|
||||
)
|
||||
const sessionID = sessions[0]!.data.id
|
||||
const http = yield* request(`/api/session/${sessionID}`)
|
||||
expect(http.status).toBe(200)
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Schema.Struct({ data: Session.Info }))(yield* Effect.promise(() => http.json())),
|
||||
).toEqual(yield* rpc["session.get"]({ params: { sessionID } }))
|
||||
|
||||
expect(
|
||||
yield* rpc["session.rename"]({ params: { sessionID }, payload: { title: "renamed by RPC" } }),
|
||||
).toBeUndefined()
|
||||
const renamed = yield* request(`/api/session/${sessionID}`)
|
||||
expect(yield* Effect.promise(() => renamed.json())).toMatchObject({ data: { title: "renamed by RPC" } })
|
||||
|
||||
expect(
|
||||
(yield* request(`/api/session/${sessionID}/rename`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: "renamed by HTTP" }),
|
||||
})).status,
|
||||
).toBe(204)
|
||||
expect((yield* rpc["session.get"]({ params: { sessionID } })).data.title).toBe("renamed by HTTP")
|
||||
expect(yield* rpc["session.remove"]({ params: { sessionID } })).toBeUndefined()
|
||||
const missing = yield* rpc["session.get"]({ params: { sessionID } }).pipe(
|
||||
Effect.catchTag("SessionNotFoundError", Effect.succeed),
|
||||
)
|
||||
expect(missing).toBeInstanceOf(SessionNotFoundError)
|
||||
expect(missing).toMatchObject({ _tag: "SessionNotFoundError", sessionID })
|
||||
const absent = yield* request(`/api/session/${sessionID}`)
|
||||
expect(absent.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => absent.json())).toMatchObject({ _tag: "SessionNotFoundError", sessionID })
|
||||
expect(sockets).toHaveLength(1)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reads binary files and MIME types at two locations over one RPC connection",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { rpc, request, first, second, sockets } = yield* fixture(OpenCodeRpc.Group)
|
||||
const filename = "space # percent% question? plus+.png"
|
||||
const firstBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 255, 128])
|
||||
const secondBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 127, 1, 254])
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(path.join(first.directory, filename), firstBytes),
|
||||
Bun.write(path.join(second.directory, filename), secondBytes),
|
||||
]),
|
||||
)
|
||||
const files = yield* Effect.all(
|
||||
[first, second].map((location) => rpc["fs.read"]({ params: { path: filename }, query: {}, location })),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(files.map((file) => file.content)).toEqual([firstBytes, secondBytes])
|
||||
expect(files.map((file) => file.mime)).toEqual(["image/png", "image/png"])
|
||||
const query = new URLSearchParams({ "location[directory]": first.directory })
|
||||
const http = yield* request(`/api/fs/read/${encodeURIComponent(filename)}?${query}`)
|
||||
expect(http.status).toBe(200)
|
||||
expect(http.headers.get("content-type")).toBe(files[0]!.mime)
|
||||
expect(Array.from(new Uint8Array(yield* Effect.promise(() => http.arrayBuffer())))).toEqual(
|
||||
Array.from(files[0]!.content),
|
||||
)
|
||||
const lists = yield* Effect.all(
|
||||
[first, second].map((location) => rpc["fs.list"]({ query: {}, location })),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(lists.map((list) => list.location.directory)).toEqual([first.directory, second.directory])
|
||||
expect(sockets).toHaveLength(1)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"streams the connected event and later domain events alongside unary RPC calls",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { rpc, first, sockets } = yield* fixture(OpenCodeRpc.Group)
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* rpc["event.subscribe"]({}, { asQueue: true })
|
||||
expect(yield* Queue.take(events)).toMatchObject({ type: "server.connected", data: {} })
|
||||
const created = yield* rpc["session.create"]({ payload: { title: "observed", location: first } })
|
||||
const received = yield* Stream.fromQueue(events).pipe(
|
||||
Stream.filter((event) => event.type === "session.created"),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
)
|
||||
expect(received).toMatchObject([{ type: "session.created", data: { sessionID: created.data.id } }])
|
||||
}).pipe(Effect.scoped)
|
||||
expect(yield* rpc["health.get"]({})).toMatchObject({ healthy: true, version: "test-version" })
|
||||
expect(sockets).toHaveLength(1)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"cancels event subscriptions without accumulating upgrade finalizers",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const captured = yield* Deferred.make<Scope.Scope>()
|
||||
const { rpc, sockets } = yield* fixture(OpenCodeRpc.Group, (app) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (request.url === "/api/rpc") {
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Deferred.succeed(captured, scope)
|
||||
}
|
||||
return yield* app
|
||||
}),
|
||||
)
|
||||
yield* rpc["health.get"]({})
|
||||
const scope = yield* Deferred.await(captured)
|
||||
const finalizerCount = () => {
|
||||
const state = scope.state
|
||||
if (state._tag !== "Open") throw new Error("WebSocket upgrade scope must remain open")
|
||||
return state.finalizers.size
|
||||
}
|
||||
const baseline = finalizerCount()
|
||||
for (let index = 0; index < 5; index++) {
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* rpc["event.subscribe"]({}, { asQueue: true })
|
||||
expect(yield* Queue.take(events)).toMatchObject({ type: "server.connected" })
|
||||
// The subscription belongs to its RPC scope even while it is active.
|
||||
expect(finalizerCount()).toBe(baseline)
|
||||
}).pipe(Effect.scoped)
|
||||
expect(yield* rpc["health.get"]({})).toMatchObject({ healthy: true, version: "test-version" })
|
||||
expect(finalizerCount()).toBe(baseline)
|
||||
}
|
||||
expect(sockets).toHaveLength(1)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"replays session logs and cancels a live stream without closing the RPC connection",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { rpc, first, sockets } = yield* fixture(OpenCodeRpc.Group)
|
||||
const session = yield* rpc["session.create"]({ payload: { title: "before replay", location: first } })
|
||||
const params = { sessionID: session.data.id }
|
||||
yield* rpc["session.rename"]({ params, payload: { title: "in replay" } })
|
||||
const replay = yield* rpc["session.log"]({ params, query: { follow: false } }).pipe(Stream.runCollect)
|
||||
expect(replay.map((event) => event.type)).toEqual(["session.created", "session.renamed", "log.synced"])
|
||||
expect(replay[1]).toMatchObject({ data: { sessionID: params.sessionID, title: "in replay" } })
|
||||
const watermark = replay.find((event) => event.type === "log.synced")!
|
||||
expect(watermark).toMatchObject({ type: "log.synced", aggregateID: params.sessionID })
|
||||
|
||||
const synced = yield* Deferred.make<void>()
|
||||
const renamed = yield* Deferred.make<void>()
|
||||
const follow = yield* rpc["session.log"]({ params, query: { after: watermark.seq, follow: true } }).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
event.type === "log.synced"
|
||||
? Deferred.succeed(synced, undefined)
|
||||
: event.type === "session.renamed" && event.data.title === "after replay"
|
||||
? Deferred.succeed(renamed, undefined)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Deferred.await(synced)
|
||||
yield* rpc["session.rename"]({ params, payload: { title: "after replay" } })
|
||||
yield* Deferred.await(renamed)
|
||||
yield* Fiber.interrupt(follow)
|
||||
expect((yield* rpc["session.get"]({ params })).data.title).toBe("after replay")
|
||||
expect(yield* rpc["health.get"]({})).toMatchObject({ healthy: true })
|
||||
expect(sockets).toHaveLength(1)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"validates malformed RPC payloads on the server without poisoning the connection",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// The permissive client codec sends invalid data instead of rejecting it before transport.
|
||||
const { rpc, request, first, sockets } = yield* fixture(
|
||||
RpcGroup.make(
|
||||
Rpc.make("session.create", { payload: Schema.Unknown, success: Schema.Unknown, error: Schema.Unknown }),
|
||||
Rpc.make("health.get", { payload: Schema.Unknown, success: Schema.Unknown, error: Schema.Unknown }),
|
||||
),
|
||||
)
|
||||
const invalid = yield* rpc["session.create"]({ payload: { title: 42, location: first } }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(invalid)).toBe(true)
|
||||
if (Exit.isFailure(invalid)) expect(Cause.pretty(invalid.cause)).toContain("title")
|
||||
const http = yield* request("/api/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: 42, location: first }),
|
||||
})
|
||||
expect(http.status).toBe(400)
|
||||
const list = yield* request("/api/session")
|
||||
expect(yield* Effect.promise(() => list.json())).toMatchObject({ data: [] })
|
||||
expect(yield* rpc["health.get"]({})).toMatchObject({ healthy: true })
|
||||
expect(sockets).toHaveLength(1)
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"rejects missing credentials and untrusted browser origins before upgrading RPC",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { rpc, url } = yield* fixture(OpenCodeRpc.Group)
|
||||
const denied = yield* Effect.all(
|
||||
[
|
||||
new Headers(),
|
||||
new Headers({ authorization: `Basic ${btoa("opencode:wrong")}` }),
|
||||
new Headers({ authorization, origin: "https://untrusted.example" }),
|
||||
new Headers({ authorization, origin: "null" }),
|
||||
].map((headers) => Effect.promise(() => fetch(new URL("/api/rpc", url), { headers }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(denied.map((response) => response.status)).toEqual([401, 401, 403, 403])
|
||||
expect(denied.every((response) => !response.headers.has("sec-websocket-accept"))).toBe(true)
|
||||
expect(yield* rpc["health.get"]({})).toMatchObject({ healthy: true })
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"forwards declared PTY ticket headers to the shared business handler",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { rpc, first } = yield* fixture(OpenCodeRpc.Group)
|
||||
const input = { params: { ptyID: Pty.ID.make("pty_missing") }, query: {}, location: first }
|
||||
const denied = yield* rpc["pty.connectToken"]({ ...input, headers: {} }).pipe(
|
||||
Effect.catchTag("ForbiddenError", Effect.succeed),
|
||||
)
|
||||
expect(denied).toMatchObject({ _tag: "ForbiddenError" })
|
||||
const missing = yield* rpc["pty.connectToken"]({ ...input, headers: { "x-opencode-ticket": "1" } }).pipe(
|
||||
Effect.catchTag("PtyNotFoundError", Effect.succeed),
|
||||
)
|
||||
expect(missing).toMatchObject({ _tag: "PtyNotFoundError", ptyID: input.params.ptyID })
|
||||
}).pipe(Effect.timeout("20 seconds")),
|
||||
30_000,
|
||||
)
|
||||
@@ -22,7 +22,7 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
})
|
||||
|
||||
for (const separator of ["shell", "error", "reasoning"]) {
|
||||
for (const separator of ["shell", "error"]) {
|
||||
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
@@ -32,15 +32,3 @@ for (const separator of ["shell", "error", "reasoning"]) {
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator: "reasoning" } })
|
||||
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
})
|
||||
|
||||
@@ -105,67 +105,31 @@ story("shimmers and expands a running shell command", async ({ mount }) => {
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
|
||||
for (const open of [false, true]) {
|
||||
story(
|
||||
`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`,
|
||||
async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
|
||||
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
const thought = reasoning.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "false")
|
||||
await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Start shell" }).click()
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(thought).toContainText("Thought")
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
const used = group.getByRole("button", { name: "Used Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(open),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
if (!open) await thought.click()
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
|
||||
},
|
||||
)
|
||||
}
|
||||
story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
|
||||
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(reasoning).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Start shell" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(reasoning).toHaveCount(0)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
|
||||
story("does not infer Thinking from busy, retry, or recovery without reasoning", async ({ mount }) => {
|
||||
story("moves busy through retry and recovery to final idle content", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } })
|
||||
await expect(timeline.locator('[data-timeline-row="UserMessage"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Retry request" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.getByRole("button", { name: "Recover request" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.getByRole("button", { name: "Finish response" }).click()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText(
|
||||
|
||||
@@ -1,81 +1,66 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const mode of ["hidden", "compact", "full"] as const) {
|
||||
for (const reasoning of ["none", "blank", "heading"] as const) {
|
||||
story(`projects ${mode} mode with ${reasoning} active reasoning`, async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", {
|
||||
args: { scenario: "reasoning", mode, reasoning },
|
||||
})
|
||||
await expect(timeline.locator('[data-timeline-row="UserMessage"]')).toContainText(
|
||||
"Find why the Session header shifts after the first streamed response.",
|
||||
)
|
||||
const active = mode !== "hidden" && reasoning !== "none"
|
||||
const part = timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(active ? 1 : 0)
|
||||
await expect(part).toHaveCount(active ? 1 : 0)
|
||||
if (!active || reasoning !== "heading") {
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = part.getByRole("button")
|
||||
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(mode === "full"))
|
||||
await expect(part.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
if (mode === "compact") {
|
||||
await expect(trigger).toContainText("Inspecting stability")
|
||||
await expect(body).toBeHidden()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
await expect(body).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(body).toBeHidden()
|
||||
await expect(trigger).toContainText("Inspecting stability")
|
||||
})
|
||||
}
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "heading",
|
||||
tool: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
heading: true,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "heading",
|
||||
tool: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
heading: true,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: "blank",
|
||||
tool: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "heading",
|
||||
tool: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "none",
|
||||
tool: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const following of ["tool", "text"] as const) {
|
||||
story(`stops Thinking before ${following} in ${mode} mode`, async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", {
|
||||
args: {
|
||||
scenario: "reasoning",
|
||||
mode,
|
||||
reasoning: "heading",
|
||||
tool: following === "tool",
|
||||
text: following === "text" ? "The timeline is stable" : "",
|
||||
},
|
||||
})
|
||||
const part = timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
|
||||
if (following === "tool") {
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toContainText("UsedSkill")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("1")
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeHidden()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-timeline-part-id="tool_reasoning_projection_skill"]')).toBeVisible()
|
||||
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(mode === "hidden" ? 0 : 1)
|
||||
}
|
||||
if (following === "text")
|
||||
await expect(timeline.getByText("The timeline is stable", { exact: true })).toBeVisible()
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(part).toHaveCount(mode === "hidden" ? 0 : 1)
|
||||
if (mode === "hidden") return
|
||||
const thought = part.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(thought.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Thought")
|
||||
await expect(thought.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("7s")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(mode === "full"))
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(part.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
|
||||
if (mode === "compact") await thought.click()
|
||||
await expect(
|
||||
part.getByText("I will inspect the timeline before changing its state.", { exact: true }),
|
||||
).toBeVisible()
|
||||
for (const profile of profiles) {
|
||||
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
|
||||
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", {
|
||||
args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool },
|
||||
})
|
||||
}
|
||||
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
|
||||
profile.body ? 1 : 0,
|
||||
)
|
||||
if ("heading" in profile) {
|
||||
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
|
||||
|
||||
@@ -3,7 +3,6 @@ import { expect, story } from "../../storybook/playwright/story"
|
||||
story("renders streamed reasoning without starting the app", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
|
||||
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
|
||||
await timeline.locator('[data-component="reasoning-part"] [data-slot="collapsible-trigger"]').click()
|
||||
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const open of [true, false]) {
|
||||
story(
|
||||
`preserves ${open ? "expanded" : "collapsed"} tool choices when calls join the group`,
|
||||
async ({ mount }, info) => {
|
||||
const root = await mount("current-session-file-changes--appending-tool-calls")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
|
||||
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
|
||||
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
})
|
||||
const patch = group.locator('[data-disclosure-probe="existing"]')
|
||||
const first = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
const second = patch.locator('[data-scope="apply-patch"] button').filter({ hasText: "b.ts" })
|
||||
const diff = patch.locator('[data-type="update"]').filter({ hasText: "b.ts" }).locator('[data-component="file"]')
|
||||
await shell.click()
|
||||
await first.click()
|
||||
await second.click()
|
||||
if (!open) {
|
||||
await shell.click()
|
||||
await first.click()
|
||||
}
|
||||
await expect(shell).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(first).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(diff).toBeVisible()
|
||||
const original = await patch.elementHandle()
|
||||
for (const count of [3, 4]) {
|
||||
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(count))
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
.screenshot({ path: info.outputPath(`append-${count}.png`) })
|
||||
await expect(shell).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(first).toHaveAttribute("aria-expanded", String(open))
|
||||
await expect(second).toHaveAttribute("aria-expanded", "true")
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
await expect(group.getByRole("button", { name: "Used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,66 +1,5 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const reasoningDefaultOpen of [false, true]) {
|
||||
story(
|
||||
`keeps ordered thoughts and tool-only counts with reasoning ${reasoningDefaultOpen ? "expanded" : "collapsed"}`,
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.getByRole("button", { name: "Used Read, Skill", exact: true })
|
||||
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
|
||||
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
/Loaded.*opencode.*frontend-design.*skills/,
|
||||
/Thought/,
|
||||
/Loaded.*rtl-aware-development.*skill/,
|
||||
])
|
||||
await expect(
|
||||
group.locator('[data-timeline-part-ids="reasoning_skill_first,reasoning_skill_second"]'),
|
||||
).toBeVisible()
|
||||
await expect(first.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(reasoningDefaultOpen),
|
||||
)
|
||||
await expect(second.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(reasoningDefaultOpen),
|
||||
)
|
||||
await first.getByRole("button", { name: "Thought", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("5")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
/Loaded.*opencode.*frontend-design.*skills/,
|
||||
/Thought/,
|
||||
/Loaded.*rtl-aware-development.*skill/,
|
||||
/Read.*group\.test\.ts/,
|
||||
])
|
||||
await expect(first.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(!reasoningDefaultOpen),
|
||||
)
|
||||
await expect(second.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(reasoningDefaultOpen),
|
||||
)
|
||||
if (reasoningDefaultOpen) {
|
||||
await expect(
|
||||
first.getByText("The renderer groups adjacent tools. Check the relevant skills before changing it."),
|
||||
).toBeHidden()
|
||||
return
|
||||
}
|
||||
await expect(
|
||||
first.getByText("The renderer groups adjacent tools. Check the relevant skills before changing it."),
|
||||
).toBeVisible()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
|
||||
@@ -273,23 +273,11 @@
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 0;
|
||||
margin-top: 16px;
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
color: var(--v2-text-text-muted);
|
||||
@@ -1528,8 +1516,3 @@
|
||||
:root[data-color-scheme="light"] body [data-component="user-message"] [data-slot="user-message-text"] {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
:root body [data-workspace-session] [data-component="user-message"] [data-slot="user-message-text"] {
|
||||
background: var(--v2-background-bg-accent);
|
||||
color: var(--v2-text-text-contrast);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
data: Data
|
||||
directory: string
|
||||
sessionID?: string
|
||||
shellRunning?: (id: string) => boolean
|
||||
shellOutput?: (input: ShellOutputInput) => Promise<ShellOutputOutput>
|
||||
onNavigateToSession?: NavigateToSessionFn
|
||||
onSessionHref?: SessionHrefFn
|
||||
@@ -63,7 +62,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
navigateToSession: props.onNavigateToSession,
|
||||
sessionHref: props.onSessionHref,
|
||||
shellRunning: props.shellRunning,
|
||||
shellOutput: props.shellOutput,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,12 +6,7 @@ import type {
|
||||
import { Match, Switch } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
|
||||
import {
|
||||
CurrentContextToolGroup,
|
||||
CurrentFileToolGroup,
|
||||
ToolDisplay,
|
||||
type ContextGroupPart,
|
||||
} from "../tools/tool-renderer"
|
||||
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
|
||||
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
|
||||
|
||||
export type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
@@ -47,7 +42,6 @@ export function SessionAssistantContent(props: {
|
||||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number | null
|
||||
defaultOpen?: boolean
|
||||
reasoningDefaultOpen?: boolean
|
||||
toolOpen?: boolean
|
||||
onToolOpenChange?: (open: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
@@ -69,12 +63,8 @@ export function SessionAssistantContent(props: {
|
||||
{(content) => (
|
||||
<AssistantReasoningContent
|
||||
id={props.contentID}
|
||||
content={content()}
|
||||
streaming={false}
|
||||
defaultOpen={props.reasoningDefaultOpen}
|
||||
open={props.toolOpen}
|
||||
onOpenChange={props.onToolOpenChange}
|
||||
onContentRendered={props.onContentRendered}
|
||||
text={content().text}
|
||||
streaming={typeof props.message.time.completed !== "number"}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
@@ -102,10 +92,7 @@ export function SessionAssistantContent(props: {
|
||||
}
|
||||
|
||||
export function SessionContextToolGroup(props: {
|
||||
parts: ContextGroupPart[]
|
||||
reasoningDefaultOpen?: boolean
|
||||
reasoningOpen?: (id: string) => boolean | undefined
|
||||
onReasoningOpenChange?: (id: string, open: boolean) => void
|
||||
tools: SessionMessageAssistantTool[]
|
||||
open: boolean
|
||||
busy: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -113,10 +100,7 @@ export function SessionContextToolGroup(props: {
|
||||
}) {
|
||||
return (
|
||||
<CurrentContextToolGroup
|
||||
parts={props.parts}
|
||||
reasoningDefaultOpen={props.reasoningDefaultOpen}
|
||||
reasoningOpen={props.reasoningOpen}
|
||||
onReasoningOpenChange={props.onReasoningOpenChange}
|
||||
tools={props.tools}
|
||||
open={props.open}
|
||||
busy={props.busy}
|
||||
onOpenChange={props.onOpenChange}
|
||||
|
||||
@@ -13,16 +13,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { BasicTool } from "../components/basic-tool"
|
||||
import { reasoningHeading } from "../timeline/projection"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import type {
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageCompaction,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
@@ -495,71 +490,12 @@ export function AssistantTextContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantReasoningContent(props: {
|
||||
id: string
|
||||
content: SessionMessageAssistantReasoning
|
||||
streaming: boolean
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const [state, setState] = createStore<{ open?: boolean }>({})
|
||||
const open = () => props.open ?? state.open ?? props.defaultOpen ?? false
|
||||
const heading = createMemo(() => reasoningHeading(props.content.text))
|
||||
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
|
||||
const duration = createMemo(() => {
|
||||
const time = props.content.time
|
||||
if (time?.completed === undefined) return undefined
|
||||
const total = Math.max(0, Math.round((time.completed - time.created) / 1000))
|
||||
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
|
||||
return i18n.t("ui.message.duration.minutesSeconds", {
|
||||
minutes: numfmt().format(Math.floor(total / 60)),
|
||||
seconds: numfmt().format(total % 60),
|
||||
})
|
||||
})
|
||||
export function AssistantReasoningContent(props: { id: string; text: string; streaming: boolean }) {
|
||||
return (
|
||||
<div data-component="reasoning-part" data-timeline-part-id={props.id}>
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status={props.streaming ? "running" : "completed"}
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
hideDetails={!props.content.text.trim()}
|
||||
open={open()}
|
||||
onOpenChange={(value) => {
|
||||
setState("open", value)
|
||||
props.onOpenChange?.(value)
|
||||
props.onContentRendered?.()
|
||||
}}
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought")}
|
||||
active={props.streaming}
|
||||
/>
|
||||
</span>
|
||||
<Show
|
||||
when={props.streaming && !open()}
|
||||
fallback={
|
||||
<Show when={!props.streaming && duration()}>
|
||||
{(value) => <span data-slot="basic-tool-tool-subtitle">{value()}</span>}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<TextReveal text={heading()} />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PacedMarkdown text={props.content.text} cacheKey={props.id} streaming={props.streaming} />
|
||||
</BasicTool>
|
||||
</div>
|
||||
<Show when={props.text}>
|
||||
<div data-component="reasoning-part" data-timeline-part-id={props.id}>
|
||||
<PacedMarkdown text={props.text} cacheKey={props.id} streaming={props.streaming} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,51 +199,6 @@ const fileScenarios = {
|
||||
write: WrittenSource,
|
||||
}
|
||||
|
||||
export const AppendingToolCalls = {
|
||||
render: () => {
|
||||
const [state, setState] = createStore({ calls: 0 })
|
||||
const files = ["src/a.ts", "src/b.ts"].map((file) => ({
|
||||
...storyPatchFile(file),
|
||||
patch: createTwoFilesPatch(file, file, "export const before = true\n", "export const after = true\n"),
|
||||
}))
|
||||
const document = createMemo(() =>
|
||||
storyDocument([
|
||||
storyTool("tool_shell_existing", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
|
||||
storyTool(
|
||||
"tool_patch_existing",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update two files" },
|
||||
{
|
||||
metadata: { files },
|
||||
},
|
||||
),
|
||||
...Array.from({ length: state.calls }, (_, index) =>
|
||||
storyTool(
|
||||
`tool_patch_next_${index}`,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update src/a.ts again" },
|
||||
{
|
||||
metadata: { files: [files[0]] },
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setState("calls", (count) => count + 1)}>
|
||||
Append tool call
|
||||
</button>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<SessionTimeline document={document()} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ChangingFiles = {
|
||||
args: { scenario: "streaming" },
|
||||
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
|
||||
|
||||
@@ -130,11 +130,7 @@ describe("reuseTimelineRows", () => {
|
||||
name: "does not create accidental key collisions",
|
||||
previous: [context("context:a", ["a", "b", "c"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
|
||||
expected: [
|
||||
"assistant-part:context:context:b",
|
||||
"assistant-part:context:context:a",
|
||||
"assistant-part:context:context:c",
|
||||
],
|
||||
expected: ["assistant-part:context:context:b", "assistant-part:context:context:a", "assistant-part:context:context:c"],
|
||||
reused: [],
|
||||
},
|
||||
])("$name", ({ previous, rows, expected, reused }) => {
|
||||
@@ -177,7 +173,7 @@ describe("createTimelineProjection", () => {
|
||||
const result = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "busy" },
|
||||
reasoningMode: "full",
|
||||
showReasoningSummaries: true,
|
||||
})
|
||||
|
||||
expect(result.activeMessageID).toBe("user-2")
|
||||
@@ -211,12 +207,12 @@ describe("createTimelineProjection", () => {
|
||||
const first = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
reasoningMode: "full",
|
||||
showReasoningSummaries: true,
|
||||
})
|
||||
const second = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
reasoningMode: "full",
|
||||
showReasoningSummaries: true,
|
||||
previousRows: first.rows,
|
||||
})
|
||||
|
||||
@@ -248,7 +244,7 @@ describe("createTimelineProjection", () => {
|
||||
const result = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
reasoningMode: "full",
|
||||
showReasoningSummaries: true,
|
||||
})
|
||||
|
||||
expect(result.assistantMessagesByParent.get("assistant-1")?.map((message) => message.id)).toEqual([
|
||||
|
||||
@@ -13,8 +13,6 @@ import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "
|
||||
|
||||
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
export type ReasoningMode = "hidden" | "compact" | "full"
|
||||
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
@@ -26,7 +24,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unkno
|
||||
export type TimelineProjectionInput = {
|
||||
sessionMessages: SessionMessageInfo[]
|
||||
status: SessionStatus
|
||||
reasoningMode: ReasoningMode
|
||||
showReasoningSummaries: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
pendingUserMessageIDs?: ReadonlySet<string>
|
||||
@@ -37,7 +35,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
const sessionMessageByID = new Map(input.sessionMessages.map((message) => [message.id, message] as const))
|
||||
const projection = Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages,
|
||||
input.reasoningMode !== "hidden",
|
||||
input.showReasoningSummaries,
|
||||
input.status,
|
||||
input.pendingUserMessageIDs,
|
||||
input.shellToolDefaultOpen ?? false,
|
||||
@@ -72,7 +70,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
export function createReactiveTimelineProjection(input: {
|
||||
sessionMessages: Accessor<SessionMessageInfo[]>
|
||||
status: Accessor<SessionStatus>
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
shellToolDefaultOpen?: Accessor<boolean>
|
||||
editToolDefaultOpen?: Accessor<boolean>
|
||||
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
|
||||
@@ -85,7 +83,7 @@ export function createReactiveTimelineProjection(input: {
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
input.reasoningMode() !== "hidden",
|
||||
input.showReasoningSummaries(),
|
||||
input.status(),
|
||||
input.pendingUserMessageIDs?.(),
|
||||
input.shellToolDefaultOpen?.() ?? false,
|
||||
@@ -240,16 +238,14 @@ export namespace Timeline {
|
||||
const lastAssistant = assistantMessages.at(-1)
|
||||
const previousUserMessage = index > 0
|
||||
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
|
||||
const lastContent = lastAssistant?.content.at(-1)
|
||||
const thinking =
|
||||
showReasoning &&
|
||||
isActive &&
|
||||
status.type === "busy" &&
|
||||
lastAssistant?.time.completed === undefined &&
|
||||
!lastAssistant?.error &&
|
||||
!lastAssistant?.retry &&
|
||||
lastContent?.type === "reasoning" &&
|
||||
lastContent.time?.completed === undefined
|
||||
const delegating = assistantMessages.some((message) =>
|
||||
message.content.some(
|
||||
(content) =>
|
||||
content.type === "tool" &&
|
||||
content.name === "subagent" &&
|
||||
(content.state.status === "streaming" || content.state.status === "running"),
|
||||
),
|
||||
)
|
||||
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
|
||||
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
|
||||
@@ -261,7 +257,7 @@ export namespace Timeline {
|
||||
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
|
||||
const refs = messages.flatMap((message, messageIndex) =>
|
||||
contentEntries(message)
|
||||
.filter((entry) => renderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
|
||||
.filter((entry) => renderable(entry.content, showReasoning))
|
||||
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
|
||||
)
|
||||
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
|
||||
@@ -270,7 +266,7 @@ export namespace Timeline {
|
||||
const appendGroups = (items: typeof refs) => {
|
||||
let offset = 0
|
||||
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
|
||||
const tool = group.type !== "part" || items[offset]?.content.type !== "text"
|
||||
const tool = group.type !== "part" || items[offset]?.content.type === "tool"
|
||||
offset += group.type === "part" ? 1 : group.refs.length
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
@@ -313,13 +309,22 @@ export namespace Timeline {
|
||||
})
|
||||
appendAssistantSegment(assistantSegment)
|
||||
|
||||
if (thinking && lastAssistant) {
|
||||
rows.push(
|
||||
new TimelineRow.Thinking({
|
||||
userMessageID: turnID,
|
||||
ref: { messageID: lastAssistant.id, partID: contentEntries(lastAssistant).at(-1)!.id },
|
||||
}),
|
||||
)
|
||||
if (
|
||||
isActive &&
|
||||
status.type === "busy" &&
|
||||
!lastAssistant?.error &&
|
||||
!lastAssistant?.retry &&
|
||||
!delegating &&
|
||||
(showReasoning
|
||||
? !assistantMessages.some((message) => message.content.some((content) => renderable(content, true)))
|
||||
: true)
|
||||
) {
|
||||
const heading = assistantMessages
|
||||
.flatMap((message) => message.content)
|
||||
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
|
||||
.find((value): value is string => !!value)
|
||||
|
||||
rows.push(new TimelineRow.Thinking({ userMessageID: turnID, reasoningHeading: heading }))
|
||||
}
|
||||
|
||||
return rows
|
||||
@@ -485,18 +490,11 @@ function groupContent(
|
||||
editToolDefaultOpen: boolean,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
if (!first) return
|
||||
if (!current.tools) {
|
||||
groups.push(
|
||||
...current.refs.map((ref) => ({ type: "part" as const, key: `part:${ref.messageID}:${ref.partID}`, ref })),
|
||||
)
|
||||
adjacent = undefined
|
||||
return
|
||||
}
|
||||
groups.push({
|
||||
type: current.type === "context" ? "context" : "file",
|
||||
key:
|
||||
@@ -511,19 +509,11 @@ function groupContent(
|
||||
items.forEach((item) => {
|
||||
const type =
|
||||
item.content.type === "tool"
|
||||
? toolGroupType(
|
||||
item.content,
|
||||
shellToolDefaultOpen,
|
||||
editToolDefaultOpen,
|
||||
adjacent?.type === "context" && adjacent.tools,
|
||||
)
|
||||
: item.content.type === "reasoning"
|
||||
? "context"
|
||||
: undefined
|
||||
? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen, adjacent?.type === "context")
|
||||
: undefined
|
||||
if (type) {
|
||||
if (adjacent?.type !== type) flush()
|
||||
adjacent ??= { type, refs: [], tools: false }
|
||||
adjacent.tools ||= item.content.type === "tool"
|
||||
adjacent ??= { type, refs: [] }
|
||||
adjacent.refs.push({ messageID: item.messageID, partID: item.partID })
|
||||
return
|
||||
}
|
||||
@@ -570,7 +560,7 @@ function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
|
||||
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
|
||||
}
|
||||
|
||||
export function reasoningHeading(text: string): string | undefined {
|
||||
function reasoningHeading(text: string): string | undefined {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
if (html?.[1]) {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
@@ -37,7 +33,7 @@ describe("current session timeline rows", () => {
|
||||
"assistant-part:part:part:msg_2:msg_2:text:0",
|
||||
"turn-gap:msg_3",
|
||||
"user-message:msg_3",
|
||||
"thinking:msg_3",
|
||||
"assistant-part:part:part:msg_4:msg_4:reasoning:0",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -155,7 +151,7 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("does not infer thinking from an optimistic busy turn", () => {
|
||||
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
|
||||
const source = [
|
||||
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
|
||||
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
|
||||
@@ -163,10 +159,15 @@ describe("current session timeline rows", () => {
|
||||
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
|
||||
|
||||
expect(result.activeMessageID).toBe("msg_a")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual(["user-message:msg_z", "turn-gap:msg_a", "user-message:msg_a"])
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_z",
|
||||
"turn-gap:msg_a",
|
||||
"user-message:msg_a",
|
||||
"thinking:msg_a",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not infer thinking above a queued user message", () => {
|
||||
test("renders thinking above a queued user message", () => {
|
||||
const source = [
|
||||
{ id: "msg_active", type: "user", text: "active", time: { created: 1 } },
|
||||
{ id: "msg_queued", type: "user", text: "queued", time: { created: 2 } },
|
||||
@@ -176,6 +177,7 @@ describe("current session timeline rows", () => {
|
||||
expect(result.activeMessageID).toBe("msg_active")
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_active",
|
||||
"thinking:msg_active",
|
||||
"turn-gap:msg_queued",
|
||||
"user-message:msg_queued",
|
||||
])
|
||||
@@ -207,10 +209,9 @@ describe("current session timeline rows", () => {
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(Timeline.constructSessionMessageRows(source, true, { type: "busy" }).rows.map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"AssistantPart",
|
||||
])
|
||||
expect(Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.map((row) => row._tag)).toEqual(
|
||||
["UserMessage", "AssistantPart"],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -233,98 +234,6 @@ describe("current session timeline rows", () => {
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
|
||||
})
|
||||
|
||||
test.each(["hidden", "compact", "full"] as const)("only shows active reasoning in %s mode", (reasoningMode) => {
|
||||
const active = { type: "reasoning", text: "## Current thought", time: { created: 2 } } as const
|
||||
const cases: { content: SessionMessageAssistant["content"]; thinking: boolean }[] = [
|
||||
{ content: [], thinking: false },
|
||||
{ content: [active], thinking: true },
|
||||
{ content: [{ ...active, text: "" }], thinking: true },
|
||||
{ content: [{ ...active, time: { created: 2, completed: 3 } }], thinking: false },
|
||||
{ content: [active, { type: "text", text: "Answer" }], thinking: false },
|
||||
...(["streaming", "running", "completed", "error"] as const).flatMap((status) =>
|
||||
["shell", "read", "subagent", "question"].map((name) => ({
|
||||
content: [active, storyTool("tool", name, status, {})],
|
||||
thinking: false,
|
||||
})),
|
||||
),
|
||||
]
|
||||
cases.forEach((profile) => {
|
||||
const document = storyDocument(profile.content, true)
|
||||
const result = createTimelineProjection({
|
||||
sessionMessages: document.messages,
|
||||
status: document.status,
|
||||
reasoningMode,
|
||||
})
|
||||
expect(result.rows.some((row) => row._tag === "Thinking")).toBe(reasoningMode !== "hidden" && profile.thinking)
|
||||
})
|
||||
})
|
||||
|
||||
test("stops thinking on idle, message completion, errors and retries", () => {
|
||||
const document = storyDocument([{ type: "reasoning", text: "Current thought" }], true)
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(document.messages, true, { type: "idle" }).rows.map((row) => row._tag),
|
||||
).toEqual(["UserMessage", "AssistantPart"])
|
||||
const endings = [
|
||||
{ time: { created: 1, completed: 2 } },
|
||||
{ error: { type: "Interrupted", message: "Stopped" } },
|
||||
{ retry: { attempt: 1, at: 10, error: { type: "ProviderError", message: "Retry" } } },
|
||||
]
|
||||
endings.forEach((ending) => {
|
||||
const messages = document.messages.map((message) =>
|
||||
message.type === "assistant" ? { ...message, ...ending } : message,
|
||||
)
|
||||
expect(
|
||||
Timeline.constructSessionMessageRows(messages, true, { type: "busy" }).rows.some(
|
||||
(row) => row._tag === "Thinking",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the latest reasoning part and groups earlier thoughts with tools", () => {
|
||||
const document = storyDocument(
|
||||
[
|
||||
{ type: "reasoning", text: "Old thought", time: { created: 1, completed: 2 } },
|
||||
storyTool("read", "read", "completed", {}),
|
||||
{ type: "reasoning", text: "New thought", time: { created: 3 } },
|
||||
],
|
||||
true,
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(document.messages, true, { type: "busy" })
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart", "Thinking"])
|
||||
expect(result.rows[1]).toMatchObject({
|
||||
group: { type: "context", refs: [{ partID: "msg_tool_projection_assistant:reasoning:0" }, { partID: "read" }] },
|
||||
})
|
||||
expect(result.rows[2]).toMatchObject({ ref: { partID: "msg_tool_projection_assistant:reasoning:1" } })
|
||||
})
|
||||
|
||||
test("keeps actual thinking with the active prompt above an undelivered prompt", () => {
|
||||
const document = storyDocument([{ type: "reasoning", text: "Active thought" }], true)
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[...document.messages, { type: "user", id: "queued", text: "Next task", time: { created: 10 } }],
|
||||
true,
|
||||
document.status,
|
||||
new Set(["queued"]),
|
||||
)
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking", "TurnGap", "UserMessage"])
|
||||
expect(result.rows[1].userMessageID).toBe(document.messages[0].id)
|
||||
})
|
||||
|
||||
test.each(["shell", "execute", "subagent"])("does not hide active %s behind a preceding thought", (name) => {
|
||||
const document = storyDocument(
|
||||
[
|
||||
{ type: "reasoning", text: "Finished thought", time: { created: 1, completed: 2 } },
|
||||
storyTool("active", name, "running", {}),
|
||||
],
|
||||
true,
|
||||
)
|
||||
const result = Timeline.constructSessionMessageRows(document.messages, true, document.status)
|
||||
expect(result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([
|
||||
"part",
|
||||
"part",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps assistant errors and retries before later notices", () => {
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
[
|
||||
@@ -820,7 +729,7 @@ describe("current session timeline rows", () => {
|
||||
const initial = createTimelineProjection({
|
||||
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
|
||||
status: { type: "busy" },
|
||||
reasoningMode: "hidden",
|
||||
showReasoningSummaries: false,
|
||||
})
|
||||
const phases = [
|
||||
{ status: "streaming" },
|
||||
@@ -840,7 +749,7 @@ describe("current session timeline rows", () => {
|
||||
.map((message) => ({ ...message, id: "next-step" })),
|
||||
],
|
||||
status: { type: "busy" },
|
||||
reasoningMode: "hidden",
|
||||
showReasoningSummaries: false,
|
||||
previousRows,
|
||||
})
|
||||
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
|
||||
@@ -863,7 +772,7 @@ describe("current session timeline rows", () => {
|
||||
{ name: "execute", expanded: true, types: ["context", "part"] },
|
||||
{ name: "subagent", expanded: true, types: ["context"] },
|
||||
{ name: "shell", separator: "text", types: ["context", "part", "part"] },
|
||||
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context"] },
|
||||
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context", "part", "part"] },
|
||||
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
|
||||
] as const)("respects active tool grouping boundaries: %j", (profile) => {
|
||||
const content = [
|
||||
|
||||
@@ -6,9 +6,12 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import { BasicTool } from "../components/basic-tool"
|
||||
import { useData } from "../context"
|
||||
import { TimelineSeparator } from "../components/timeline-separator"
|
||||
import {
|
||||
@@ -19,16 +22,9 @@ import {
|
||||
SessionUserMessage,
|
||||
currentContentDefaultOpen,
|
||||
} from "../message/current-message"
|
||||
import { AssistantReasoningContent, SessionCompactionMessage } from "../message/message-content"
|
||||
import type { ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { SessionCompactionMessage } from "../message/message-content"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import {
|
||||
createReactiveTimelineProjection,
|
||||
Timeline,
|
||||
TimelineRow,
|
||||
unwrapErrorMessage,
|
||||
type ReasoningMode,
|
||||
} from "./projection"
|
||||
import { createReactiveTimelineProjection, Timeline, TimelineRow, unwrapErrorMessage } from "./projection"
|
||||
|
||||
const emptyAssistantMessages: SessionMessageAssistant[] = []
|
||||
type Projection = ReturnType<typeof createReactiveTimelineProjection>
|
||||
@@ -45,7 +41,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
projection: Projection
|
||||
presentation: (message: SessionMessageUser) => SessionUserPresentation | undefined
|
||||
actions?: SessionUserActions
|
||||
reasoningMode: Accessor<ReasoningMode>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
shellToolDefaultOpen: Accessor<boolean>
|
||||
editToolDefaultOpen: Accessor<boolean>
|
||||
disclosure: {
|
||||
@@ -83,24 +79,19 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
|
||||
const renderAssistant = (row: Accessor<TimelineRow.AssistantPart>, onSizeChange?: () => void) => {
|
||||
if (row().group.type === "context") {
|
||||
const parts = createMemo(() => {
|
||||
const tools = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "context") return []
|
||||
return group.refs.flatMap<ContextGroupPart>((ref) => {
|
||||
return group.refs.flatMap((ref) => {
|
||||
const message = input.projection.messageByID().get(ref.messageID)
|
||||
const content = Timeline.resolveContent(message, ref.partID)
|
||||
if (content?.type === "tool") return [content]
|
||||
if (content?.type === "reasoning") return [{ ...content, id: ref.partID }]
|
||||
return []
|
||||
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
|
||||
})
|
||||
})
|
||||
const key = () => `context:${row().group.key}`
|
||||
return (
|
||||
<SessionContextToolGroup
|
||||
parts={parts()}
|
||||
reasoningDefaultOpen={input.reasoningMode() === "full"}
|
||||
reasoningOpen={(id) => input.disclosure.value(id)}
|
||||
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
|
||||
tools={tools()}
|
||||
open={input.disclosure.value(key()) === true}
|
||||
busy={
|
||||
workingTurn(row().userMessageID) &&
|
||||
@@ -163,10 +154,8 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
const defaultOpen = createMemo(() => {
|
||||
const item = content()
|
||||
if (!item) return undefined
|
||||
if (item.type === "reasoning") return input.reasoningMode() === "full"
|
||||
return currentContentDefaultOpen(item, input.shellToolDefaultOpen(), input.editToolDefaultOpen())
|
||||
})
|
||||
const disclosureKey = () => (content()?.type === "reasoning" ? ref()!.partID : row().group.key)
|
||||
return (
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
@@ -179,8 +168,8 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
showAssistantCopyPartID={copyContentID(row().userMessageID)}
|
||||
turnDurationMs={duration(row().userMessageID)}
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={input.disclosure.value(disclosureKey()) ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => input.disclosure.set(disclosureKey(), open)}
|
||||
toolOpen={input.disclosure.value(row().group.key) ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => input.disclosure.set(row().group.key, open)}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
@@ -486,13 +475,11 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (value._tag !== "AssistantPart") throw new Error("Expected an assistant-part timeline row")
|
||||
return value
|
||||
}
|
||||
// Construct once per row key, not inside JSX that reruns when group refs change.
|
||||
const content = renderAssistant(current, onSizeChange)
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(current().userMessageID)}>
|
||||
{content}
|
||||
{renderAssistant(current, onSizeChange)}
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
@@ -504,28 +491,39 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (value._tag !== "Thinking") throw new Error("Expected a thinking timeline row")
|
||||
return value
|
||||
}
|
||||
const content = createMemo(() => {
|
||||
const ref = current().ref
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
return content?.type === "reasoning" ? content : undefined
|
||||
})
|
||||
const animateHeading = createMemo<boolean>((previous) => previous ?? !current().reasoningHeading)
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<div data-slot="session-turn-thinking-row">
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<AssistantReasoningContent
|
||||
id={current().ref.partID}
|
||||
content={content()}
|
||||
streaming
|
||||
defaultOpen={input.reasoningMode() === "full"}
|
||||
open={input.disclosure.value(current().ref.partID)}
|
||||
onOpenChange={(open) => input.disclosure.set(current().ref.partID, open)}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<BasicTool
|
||||
icon="mcp"
|
||||
status="running"
|
||||
compact
|
||||
locked
|
||||
hideDetails
|
||||
trigger={
|
||||
<div data-slot="session-turn-thinking">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
</span>
|
||||
<Show when={!input.showReasoningSummaries()}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<TextReveal
|
||||
text={current().reasoningHeading}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={animateHeading() ? 25 : 0}
|
||||
duration={animateHeading() ? 700 : 0}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionDocument } from "../document"
|
||||
import type { SessionUserActions } from "../actions"
|
||||
import { createReactiveTimelineProjection, TimelineRow, type ReasoningMode } from "./projection"
|
||||
import { createReactiveTimelineProjection, TimelineRow } from "./projection"
|
||||
import { createSessionTimelineRowRenderer, type SessionUserPresentation } from "./session-timeline-row"
|
||||
|
||||
export type { SessionUserPresentation } from "./session-timeline-row"
|
||||
@@ -11,7 +11,7 @@ export type SessionTimelineProps = {
|
||||
document: SessionDocument
|
||||
presentation?: Record<string, SessionUserPresentation | undefined>
|
||||
actions?: SessionUserActions
|
||||
reasoningMode?: ReasoningMode
|
||||
showReasoningSummaries?: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
class?: string
|
||||
@@ -21,7 +21,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
|
||||
const projection = createReactiveTimelineProjection({
|
||||
sessionMessages: () => props.document.messages,
|
||||
status: () => props.document.status,
|
||||
reasoningMode: () => props.reasoningMode ?? "compact",
|
||||
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
|
||||
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
|
||||
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
|
||||
})
|
||||
@@ -32,7 +32,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
|
||||
projection,
|
||||
presentation: (message) => props.presentation?.[message.id],
|
||||
actions: props.actions,
|
||||
reasoningMode: () => props.reasoningMode ?? "compact",
|
||||
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
|
||||
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
|
||||
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
|
||||
disclosure: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionDocument } from "../document"
|
||||
import { SessionTimeline } from "./session-timeline"
|
||||
import type { ReasoningMode } from "./projection"
|
||||
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
|
||||
import {
|
||||
CURRENT_SESSION_ID,
|
||||
@@ -40,7 +39,14 @@ export default {
|
||||
}
|
||||
|
||||
export const AgentThinking = {
|
||||
render: () => <AgentReasoningStory mode="compact" reasoning="heading" tool={false} text="" />,
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Agent thinking"
|
||||
description="The prompt is admitted and the active turn is waiting for its first visible content."
|
||||
document={thinkingDocument}
|
||||
width="560px"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const StreamingReasoningAndText = {
|
||||
@@ -54,18 +60,15 @@ export const StreamingReasoningAndText = {
|
||||
),
|
||||
}
|
||||
|
||||
function AgentReasoningStory(props: { mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) {
|
||||
function AgentReasoningStory(props: { summaries: boolean; reasoning: string; tool: boolean; text: string }) {
|
||||
const content = [
|
||||
...(props.reasoning === "none"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: "reasoning" as const,
|
||||
text:
|
||||
props.reasoning === "blank"
|
||||
? " "
|
||||
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
|
||||
time: { created: STORY_TIME + 100, ...(props.tool || props.text ? { completed: STORY_TIME + 7100 } : {}) },
|
||||
text: props.reasoning === "blank" ? " " : "## Inspecting stability",
|
||||
time: { created: STORY_TIME + 100 },
|
||||
},
|
||||
]),
|
||||
...(props.tool
|
||||
@@ -75,7 +78,7 @@ function AgentReasoningStory(props: { mode: ReasoningMode; reasoning: string; to
|
||||
id: "tool_reasoning_projection_skill",
|
||||
name: "skill",
|
||||
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
|
||||
time: { created: STORY_TIME + 7200, ran: STORY_TIME + 7250 },
|
||||
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -100,16 +103,16 @@ function AgentReasoningStory(props: { mode: ReasoningMode; reasoning: string; to
|
||||
return (
|
||||
<section class="mx-auto w-full max-w-[720px] p-6">
|
||||
<CurrentSessionProviders document={document}>
|
||||
<SessionTimeline document={document} reasoningMode={props.mode} />
|
||||
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const AgentReasoning = {
|
||||
args: { mode: "compact", reasoning: "heading", tool: false, text: "" },
|
||||
args: { summaries: true, reasoning: "heading", tool: false, text: "" },
|
||||
argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } },
|
||||
render: (args: { mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) => (
|
||||
render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => (
|
||||
<AgentReasoningStory {...args} />
|
||||
),
|
||||
}
|
||||
@@ -171,7 +174,7 @@ function HiddenReasoningStory() {
|
||||
</button>
|
||||
</div>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<SessionTimeline document={document()} reasoningMode="compact" />
|
||||
<SessionTimeline document={document()} showReasoningSummaries={false} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
@@ -589,13 +592,12 @@ const conversationScenarios = {
|
||||
}
|
||||
|
||||
export const Conversation = {
|
||||
args: { scenario: "notices", mode: "compact", reasoning: "heading", tool: false, text: "" },
|
||||
args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" },
|
||||
argTypes: {
|
||||
scenario: { control: "select", options: Object.keys(conversationScenarios) },
|
||||
reasoning: { control: "select", options: ["none", "blank", "heading"] },
|
||||
mode: { control: "select", options: ["hidden", "compact", "full"] },
|
||||
},
|
||||
render: (args: { scenario: string; mode: ReasoningMode; reasoning: string; tool: boolean; text: string }) => {
|
||||
render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => {
|
||||
if (args.scenario === "reasoning") return <AgentReasoningStory {...args} />
|
||||
return conversationScenarios[args.scenario as Exclude<keyof typeof conversationScenarios, "reasoning">].render()
|
||||
},
|
||||
|
||||
@@ -54,7 +54,7 @@ export namespace TimelineRow {
|
||||
|
||||
export class Thinking extends Data.TaggedClass("Thinking")<{
|
||||
userMessageID: string
|
||||
ref: PartRef
|
||||
reasoningHeading?: string
|
||||
}> {}
|
||||
|
||||
export class Error extends Data.TaggedClass("Error")<{
|
||||
@@ -121,7 +121,7 @@ export type TimelineRowMap = {
|
||||
previousAssistantPart: boolean
|
||||
spacing?: "tool" | "content"
|
||||
}
|
||||
Thinking: { userMessageID: string; ref: PartRef }
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
|
||||
import { CurrentContextToolGroup } from "./tool-renderer"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Work/Tool group",
|
||||
@@ -29,48 +29,7 @@ export const MixedTools = {
|
||||
return (
|
||||
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
|
||||
<CurrentSessionProviders document={storyDocument(tools)}>
|
||||
<CurrentContextToolGroup parts={tools} busy={false} open={open()} onOpenChange={setOpen} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const MixedReasoning = {
|
||||
args: { reasoningDefaultOpen: false },
|
||||
render: (args: { reasoningDefaultOpen: boolean }) => {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const [appended, setAppended] = createSignal(false)
|
||||
const parts = createMemo<ContextGroupPart[]>(() => [
|
||||
storyTool("reasoning_read", "read", "completed", { path: "src/group.ts" }),
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "reasoning_first",
|
||||
text: "The renderer groups adjacent tools. Check the relevant skills before changing it.",
|
||||
},
|
||||
storyTool("reasoning_skill_first", "skill", "completed", { id: "opencode" }),
|
||||
storyTool("reasoning_skill_second", "skill", "completed", { id: "frontend-design" }),
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "reasoning_second",
|
||||
text: "Keep these skill groups separate so the reasoning stays in chronological order.",
|
||||
},
|
||||
storyTool("reasoning_skill_third", "skill", "completed", { id: "rtl-aware-development" }),
|
||||
...(appended() ? [storyTool("reasoning_read_next", "read", "completed", { path: "src/group.test.ts" })] : []),
|
||||
])
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setAppended((value) => !value)}>
|
||||
{appended() ? "Remove follow-up read" : "Append follow-up read"}
|
||||
</button>
|
||||
<CurrentSessionProviders document={storyDocument(parts())}>
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
busy={false}
|
||||
open={open()}
|
||||
onOpenChange={setOpen}
|
||||
reasoningDefaultOpen={args.reasoningDefaultOpen}
|
||||
/>
|
||||
<CurrentContextToolGroup tools={tools} busy={false} open={open()} onOpenChange={setOpen} />
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
@@ -79,9 +38,9 @@ export const MixedReasoning = {
|
||||
|
||||
export const PatchFollowUps = {
|
||||
args: { separator: "none" },
|
||||
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
|
||||
argTypes: { separator: { control: "select", options: ["none", "shell", "error"] } },
|
||||
render: (args: { separator: string }) => {
|
||||
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
|
||||
const [state, setState] = createStore({ phase: "initial", open: true })
|
||||
const file = (path: string, before: number, after: number) => ({
|
||||
file: path,
|
||||
status: "modified",
|
||||
@@ -97,7 +56,7 @@ export const PatchFollowUps = {
|
||||
{ context: Infinity },
|
||||
),
|
||||
})
|
||||
const parts = createMemo<ContextGroupPart[]>(() => [
|
||||
const tools = createMemo(() => [
|
||||
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
|
||||
storyTool(
|
||||
"patch_first",
|
||||
@@ -117,15 +76,6 @@ export const PatchFollowUps = {
|
||||
...(args.separator === "error"
|
||||
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
|
||||
: []),
|
||||
...(args.separator === "reasoning" && state.reasoning
|
||||
? [
|
||||
{
|
||||
type: "reasoning" as const,
|
||||
id: "patch_reasoning",
|
||||
text: "The first patch is ready. Now update the remaining files.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
storyTool(
|
||||
"patch_next",
|
||||
"patch",
|
||||
@@ -146,15 +96,10 @@ export const PatchFollowUps = {
|
||||
<button type="button" onClick={() => setState("phase", "completed")}>
|
||||
Finish follow-up patch
|
||||
</button>
|
||||
<Show when={args.separator === "reasoning"}>
|
||||
<button type="button" onClick={() => setState("reasoning", (value) => !value)}>
|
||||
{state.reasoning ? "Hide thoughts" : "Show thoughts"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<CurrentSessionProviders document={storyDocument(parts())}>
|
||||
<CurrentSessionProviders document={storyDocument(tools())}>
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
tools={tools()}
|
||||
busy={state.phase === "running"}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
|
||||
@@ -36,18 +36,14 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
|
||||
import { animate } from "motion"
|
||||
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
|
||||
import type {
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageShell,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
currentToolError,
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
} from "../message/current-tool-state"
|
||||
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
|
||||
import { writeClipboard } from "../message/message-content"
|
||||
|
||||
function ShellSubmessage(props: { text: string; animate?: boolean }) {
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
@@ -473,27 +469,22 @@ function ExaOutput(props: { output?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export type ContextGroupPart = SessionMessageAssistantTool | (SessionMessageAssistantReasoning & { id: string })
|
||||
|
||||
export function CurrentContextToolGroup(props: {
|
||||
parts: ContextGroupPart[]
|
||||
tools: SessionMessageAssistantTool[]
|
||||
busy: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
reasoningDefaultOpen?: boolean
|
||||
reasoningOpen?: (id: string) => boolean | undefined
|
||||
onReasoningOpenChange?: (id: string, open: boolean) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const tools = createMemo(() => props.parts.filter((part) => part.type === "tool"))
|
||||
const pending = createMemo(
|
||||
() => props.busy || tools().some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
() =>
|
||||
props.busy || props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const names = createMemo(() =>
|
||||
[
|
||||
...new Set(
|
||||
tools().map((tool) => {
|
||||
props.tools.map((tool) => {
|
||||
const input = currentToolInput(tool)
|
||||
if (tool.name === "skill") return i18n.t("ui.tool.skill")
|
||||
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
|
||||
@@ -509,40 +500,31 @@ export function CurrentContextToolGroup(props: {
|
||||
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | (SessionMessageAssistantReasoning & { id: string }))[]>(
|
||||
(groups, tool) => {
|
||||
if (tool.type === "reasoning") {
|
||||
groups.push(tool)
|
||||
return groups
|
||||
}
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
if (
|
||||
tool.name === "skill" &&
|
||||
tool.state.status !== "error" &&
|
||||
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "skill" &&
|
||||
previous[0].state.status !== "error" &&
|
||||
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
groups.push([tool])
|
||||
props.tools.reduce<SessionMessageAssistantTool[][]>((groups, tool) => {
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
),
|
||||
}
|
||||
if (
|
||||
tool.name === "skill" &&
|
||||
tool.state.status !== "error" &&
|
||||
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
|
||||
previous?.[0]?.name === "skill" &&
|
||||
previous[0].state.status !== "error" &&
|
||||
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
}
|
||||
groups.push([tool])
|
||||
return groups
|
||||
}, []),
|
||||
)
|
||||
const change = (open: boolean) => {
|
||||
props.onOpenChange(open)
|
||||
@@ -550,7 +532,7 @@ export function CurrentContextToolGroup(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}>
|
||||
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
|
||||
<BasicTool
|
||||
icon="glasses"
|
||||
status={pending() ? "running" : "completed"}
|
||||
@@ -568,162 +550,123 @@ export function CurrentContextToolGroup(props: {
|
||||
<Show when={label().after}>
|
||||
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
|
||||
</Show>
|
||||
<Badge>{tools().length}</Badge>
|
||||
<Badge>{props.tools.length}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-component="context-tool-group-list">
|
||||
<Index each={items()}>
|
||||
{(item) => {
|
||||
const group = createMemo(() => {
|
||||
const value = item()
|
||||
return Array.isArray(value) ? value : undefined
|
||||
})
|
||||
const reasoning = createMemo(() => {
|
||||
const value = item()
|
||||
return Array.isArray(value) ? undefined : value
|
||||
})
|
||||
{(group) => {
|
||||
const tool = createMemo(() => group()[0]!)
|
||||
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
|
||||
const skills = createMemo(() =>
|
||||
group().flatMap((item) => {
|
||||
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
|
||||
return name ? [name] : []
|
||||
}),
|
||||
)
|
||||
const marker = "__OPENCODE_LOADED_SKILL__"
|
||||
const loaded = createMemo(() => i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }))
|
||||
return (
|
||||
<Show
|
||||
when={group()}
|
||||
fallback={
|
||||
<Show when={reasoning()}>
|
||||
{(part) => (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<AssistantReasoningContent
|
||||
id={part().id}
|
||||
content={part()}
|
||||
streaming={false}
|
||||
defaultOpen={props.reasoningDefaultOpen}
|
||||
open={props.reasoningOpen?.(part().id)}
|
||||
onOpenChange={(open) => props.onReasoningOpenChange?.(part().id, open)}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(group) => {
|
||||
const tool = createMemo(() => group()[0]!)
|
||||
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
|
||||
const skills = createMemo(() =>
|
||||
group().flatMap((item) => {
|
||||
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
|
||||
return name ? [name] : []
|
||||
}),
|
||||
)
|
||||
const marker = "__OPENCODE_LOADED_SKILL__"
|
||||
const loaded = createMemo(() =>
|
||||
i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }),
|
||||
)
|
||||
return (
|
||||
<div data-slot="context-tool-group-item">
|
||||
<Show
|
||||
when={
|
||||
tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)
|
||||
}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "patch" && tool().state.status !== "error"}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
tool={tool().name}
|
||||
input={currentToolInput(tool())}
|
||||
metadata={currentToolMetadata(tool())}
|
||||
output={currentToolOutput(tool())}
|
||||
error={currentToolError(tool())}
|
||||
status={tool().state.status}
|
||||
defaultOpen={false}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-component="tool-loaded-item"
|
||||
data-timeline-part-ids={group()
|
||||
.map((item) => item.id)
|
||||
.join(",")}
|
||||
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
|
||||
name: skills().join(", "),
|
||||
})}
|
||||
>
|
||||
<span data-slot="tool-loaded-label" aria-hidden="true">
|
||||
{loaded().split(marker)[0]?.trim()}
|
||||
</span>
|
||||
<span data-slot="tool-loaded-value" aria-hidden="true">
|
||||
<For each={skills()}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>, </Show>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={name}
|
||||
active={["streaming", "running"].includes(group()[index()]!.state.status)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
<Show when={loaded().split(marker)[1]?.trim()}>
|
||||
{(suffix) => (
|
||||
<span data-slot="tool-loaded-kind" aria-hidden="true">
|
||||
{suffix()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
<div data-slot="context-tool-group-item">
|
||||
<Show
|
||||
when={tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "patch" && tool().state.status !== "error"}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
tool={tool().name}
|
||||
input={currentToolInput(tool())}
|
||||
metadata={currentToolMetadata(tool())}
|
||||
output={currentToolOutput(tool())}
|
||||
error={currentToolError(tool())}
|
||||
status={tool().state.status}
|
||||
defaultOpen={false}
|
||||
deferContent
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={props.onSizeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentFileToolGroup tools={group()} onSizeChange={props.onSizeChange} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-component="tool-loaded-item"
|
||||
data-timeline-part-ids={group()
|
||||
.map((item) => item.id)
|
||||
.join(",")}
|
||||
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
|
||||
name: skills().join(", "),
|
||||
})}
|
||||
>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={trigger().title}
|
||||
active={
|
||||
tool().state.status === "streaming" || tool().state.status === "running"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<span data-slot="tool-loaded-label" aria-hidden="true">
|
||||
{loaded().split(marker)[0]?.trim()}
|
||||
</span>
|
||||
<span data-slot="tool-loaded-value" aria-hidden="true">
|
||||
<For each={skills()}>
|
||||
{(name, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>, </Show>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={name}
|
||||
active={["streaming", "running"].includes(group()[index()]!.state.status)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
<Show when={loaded().split(marker)[1]?.trim()}>
|
||||
{(suffix) => (
|
||||
<span data-slot="tool-loaded-kind" aria-hidden="true">
|
||||
{suffix()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer
|
||||
text={trigger().title}
|
||||
active={tool().state.status === "streaming" || tool().state.status === "running"}
|
||||
/>
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
|
||||
</Show>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
{(matches) => (
|
||||
<>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{matches()}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
@@ -739,23 +682,20 @@ export function CurrentFileToolGroup(props: {
|
||||
onFileOpenChange?: (path: string, open: boolean) => void
|
||||
onSizeChange?: () => void
|
||||
}) {
|
||||
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
|
||||
const files = createMemo((previous: { key: string; value: unknown }[]) => {
|
||||
const next = props.tools.flatMap((tool) => {
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (!Array.isArray(files)) return []
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, value }))
|
||||
})
|
||||
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
|
||||
const existing = new Set(previous.map((entry) => entry.key))
|
||||
const owners = new Set(props.tools.map((tool) => tool.id))
|
||||
const result = [
|
||||
...previous
|
||||
.filter((entry) => owners.has(entry.toolID))
|
||||
.map((entry) => {
|
||||
if (!updates.has(entry.key)) return entry
|
||||
const value = updates.get(entry.key)
|
||||
return samePatchFile(value, entry.value) ? entry : { ...entry, value }
|
||||
}),
|
||||
...previous.map((entry) => {
|
||||
if (!updates.has(entry.key)) return entry
|
||||
const value = updates.get(entry.key)
|
||||
return samePatchFile(value, entry.value) ? entry : { key: entry.key, value }
|
||||
}),
|
||||
...next.filter((entry) => !existing.has(entry.key)),
|
||||
]
|
||||
return result.length === previous.length && result.every((entry, index) => entry === previous[index])
|
||||
@@ -1471,37 +1411,28 @@ ToolRegistry.register({
|
||||
const i18n = useI18n()
|
||||
const data = useData()
|
||||
const streaming = () => props.status === "streaming"
|
||||
const pending = () =>
|
||||
streaming() ||
|
||||
props.status === "running" ||
|
||||
(typeof props.metadata.shellID === "string" && data.shellRunning?.(props.metadata.shellID) === true)
|
||||
const pending = () => streaming() || props.status === "running" || props.metadata.status === "running"
|
||||
const sawStreaming = streaming()
|
||||
const [streamed, setStreamed] = createSignal("")
|
||||
createEffect(() => {
|
||||
const id = props.metadata.shellID
|
||||
const shellOutput = data.shellOutput
|
||||
if (typeof id !== "string" || !shellOutput) return
|
||||
if (typeof id !== "string" || !pending() || !shellOutput) return
|
||||
const directory = data.directory
|
||||
const running = pending()
|
||||
let cursor = 0
|
||||
let loading = false
|
||||
let disposed = false
|
||||
const load = async () => {
|
||||
if (loading) return
|
||||
loading = true
|
||||
do {
|
||||
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
|
||||
if (disposed || !response) break
|
||||
setStreamed((output) => (cursor === 0 ? response.data.output : output + response.data.output))
|
||||
if (response.data.cursor <= cursor) break
|
||||
cursor = response.data.cursor
|
||||
if (running || cursor >= response.data.size) break
|
||||
} while (!disposed)
|
||||
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
|
||||
if (disposed) return
|
||||
if (response?.data.output) setStreamed((output) => output + response.data.output)
|
||||
if (response) cursor = response.data.cursor
|
||||
loading = false
|
||||
}
|
||||
void load()
|
||||
// Refresh the final snapshot on exit, but poll only while the shell is live.
|
||||
const interval = running ? setInterval(() => void load(), 1_000) : undefined
|
||||
const interval = setInterval(() => void load(), 1_000)
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
clearInterval(interval)
|
||||
@@ -1512,12 +1443,7 @@ ToolRegistry.register({
|
||||
if (typeof props.metadata.command === "string") return props.metadata.command
|
||||
return ""
|
||||
}
|
||||
const output = createMemo(() =>
|
||||
stripAnsi((typeof props.metadata.shellID === "string" && streamed()) || props.output || "").replace(
|
||||
/\r\n?/g,
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
const output = createMemo(() => stripAnsi((pending() && streamed()) || props.output || "").replace(/\r\n?/g, "\n"))
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
|
||||
@@ -214,7 +214,6 @@ const source = {
|
||||
"ui.message.revertMessage": "Revert message",
|
||||
"ui.message.copyResponse": "Copy response",
|
||||
"ui.message.copied": "Copied",
|
||||
"ui.message.thought": "Thought",
|
||||
"ui.message.duration.seconds": "{{count}}s",
|
||||
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"ui.message.interrupted": "Interrupted",
|
||||
|
||||
Reference in New Issue
Block a user