mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 06:56:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3023c2d995 |
@@ -1,8 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
"@opencode-ai/schema": patch
|
||||
---
|
||||
|
||||
Open durable sessions with in-process model, tool, instruction, and permission capabilities. Live Sources update at safe boundaries through existing instruction epochs, while capability replacement waits for the next busy period. Capability-owned sessions remain pending after restart until their host reopens and drives them.
|
||||
|
||||
Close an open's in-process capabilities after settlement without deleting durable history. Tool executors may yield domain errors, which normalize to tool failures while canonical permission declines retain their interruption behavior.
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/WorkspacePending"
|
||||
const workspace = "C:/OpenCode/pending-workspace"
|
||||
const projectID = "proj_workspace_pending"
|
||||
const draftID = "draft_workspace_pending"
|
||||
const otherID = "ses_workspace_pending_other"
|
||||
const text = "Create the workspace, then explain the pending session."
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionPath = `/server/${base64Encode(server)}/session/`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
|
||||
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await testInfo.attach("creating-worktree", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await page.locator("html").evaluate((element) => {
|
||||
element.dir = "rtl"
|
||||
})
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
|
||||
expect(
|
||||
await page
|
||||
.locator('[data-component="session-preparing"]')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth),
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
if (viewport.name === "desktop") {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(pending.shimmer).toBeHidden()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep focus in this other session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
}
|
||||
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) }])
|
||||
expect(mock.creates).toEqual([
|
||||
expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } }),
|
||||
])
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
|
||||
if (viewport.name === "desktop") {
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toContainText(
|
||||
"Created workspace session",
|
||||
)
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep focus in this other session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(1)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
})
|
||||
}
|
||||
|
||||
test("restores the original draft when worktree creation fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed in the fixture" } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("retains the draft and reuses the created workspace after session creation fails", async ({ page }) => {
|
||||
const mock = await openDraft(page, { failSessionCreate: true })
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.calls).toEqual(["worktree", "session"])
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.creates).toHaveLength(2)
|
||||
expect(mock.creates[1]).toMatchObject({ location: { directory: workspace } })
|
||||
expect(mock.prompts[0]).toMatchObject({ sessionID: mock.creates[1].id, body: { text } })
|
||||
expect(mock.calls).toEqual(["worktree", "session", "session", "prompt"])
|
||||
await expect(page).toHaveURL(`${sessionPath}${mock.creates[1].id}`)
|
||||
await expect(page.locator('[data-component="user-message"] [data-slot="user-message-text"]')).toHaveText(text)
|
||||
})
|
||||
|
||||
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(tab).toHaveCount(1)
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(pending.message).toHaveCount(1)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toBeVisible()
|
||||
await expect(pending.shimmer).toContainText("Creating worktree")
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const project = {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "workspace-pending",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [] as string[],
|
||||
}
|
||||
const sessions = [currentSession({ id: otherID, projectID, title: "Other session" }, directory)]
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "pending-model": { id: "pending-model", name: "Pending Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "pending-model" },
|
||||
},
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
|
||||
if (path === "/api/session") calls.push("session")
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
|
||||
})
|
||||
await page.route(`**/api/worktree/${projectID}`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
// Keep the real HTTP response pending until the test has checked the preview.
|
||||
const response = await worktree.promise
|
||||
if (response.status === 200) project.sandboxes.push(workspace)
|
||||
await route.fulfill({ ...response, headers })
|
||||
})
|
||||
await page.route("**/api/session", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: Record<string, unknown> = route.request().postDataJSON()
|
||||
creates.push(body)
|
||||
if (options?.failSessionCreate && creates.length === 1) {
|
||||
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
|
||||
}
|
||||
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
|
||||
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session }, headers })
|
||||
})
|
||||
await page.route("**/api/location?**", (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: projectID, directory, canonical: directory },
|
||||
},
|
||||
headers,
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
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 },
|
||||
{ type: "session", sessionId: otherID, server },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, otherID, server },
|
||||
)
|
||||
await page.goto(draftPath)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, calls, creates, prompts }
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
|
||||
const url = page.url()
|
||||
const sessionID = new URL(url).pathname.slice(sessionPath.length)
|
||||
const preparing = page.locator('[data-component="session-preparing"]')
|
||||
const message = page.locator('[data-component="user-message"]')
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
await expect(preparing).toBeVisible()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
|
||||
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
|
||||
await expect(shimmer).toBeVisible()
|
||||
await expect(shimmer).toContainText("Creating worktree")
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
return { url, sessionID, messageID, message, shimmer }
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
for (const profile of [
|
||||
{ locale: "en", direction: "ltr" },
|
||||
{ locale: "en", direction: "rtl" },
|
||||
{ locale: "ar", direction: "rtl" },
|
||||
]) {
|
||||
test(`keeps notices on one line: ${profile.locale} ${profile.direction} ${width}`, async ({ page }, info) => {
|
||||
const command =
|
||||
"bun run inspect --target src/renderer/session-timeline.ts --output artifacts/inspection-report.json ".repeat(5)
|
||||
const descriptions = [
|
||||
`${command}--finished`,
|
||||
`Instructions changed\n${command}--updated`,
|
||||
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
|
||||
]
|
||||
await setupTimeline(page, {
|
||||
locale: profile.locale,
|
||||
viewport: { width, height: 900 },
|
||||
sessionMessages: [
|
||||
{
|
||||
id: "msg_notice_user",
|
||||
type: "user",
|
||||
text: "Inspect the project and report completion.",
|
||||
time: { created: 1 },
|
||||
},
|
||||
{
|
||||
id: "msg_notice_shell",
|
||||
type: "synthetic",
|
||||
text: "Complete",
|
||||
description: descriptions[0],
|
||||
metadata: { source: "shell", state: "completed" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{ id: "msg_notice_system", type: "system", text: descriptions[1], time: { created: 3 } },
|
||||
{
|
||||
id: "msg_notice_agent",
|
||||
type: "synthetic",
|
||||
text: "Complete",
|
||||
description: descriptions[2],
|
||||
metadata: { source: "subagent", state: "completed", agent: "general" },
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
})
|
||||
await page
|
||||
.locator("html")
|
||||
.evaluate((element, direction) => element.setAttribute("dir", direction), profile.direction)
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
await expect(notices).toHaveCount(3)
|
||||
await expect(notices).toContainText(descriptions)
|
||||
await page.locator("[data-timeline-virtual-content]").screenshot({ path: info.outputPath("notices.png") })
|
||||
await expect
|
||||
.poll(() =>
|
||||
notices.evaluateAll((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const style = getComputedStyle(node)
|
||||
const element = node as HTMLElement
|
||||
return {
|
||||
direction: style.direction,
|
||||
whiteSpace: style.whiteSpace,
|
||||
textOverflow: style.textOverflow,
|
||||
overflow: style.overflowX,
|
||||
singleLine:
|
||||
Math.abs(
|
||||
element.clientHeight -
|
||||
parseFloat(style.paddingTop) -
|
||||
parseFloat(style.paddingBottom) -
|
||||
parseFloat(style.lineHeight),
|
||||
) <= 1,
|
||||
clipped: element.scrollWidth > element.clientWidth,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toEqual(
|
||||
Array.from({ length: 3 }, () => ({
|
||||
direction: profile.direction,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
singleLine: true,
|
||||
clipped: true,
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,4 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
@@ -79,61 +78,6 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
|
||||
test("combines follow-up patches into one three-file stack inside Used", async ({ page }) => {
|
||||
const file = (path: string, before: number, after: number) => ({
|
||||
file: path,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(
|
||||
path,
|
||||
path,
|
||||
`export const value = ${before}\n`,
|
||||
`export const value = ${after}\n`,
|
||||
"",
|
||||
"",
|
||||
{ context: Infinity },
|
||||
),
|
||||
})
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
shell("patch_shell", "completed"),
|
||||
toolPart(
|
||||
"patch_first",
|
||||
"patch",
|
||||
"completed",
|
||||
{},
|
||||
{
|
||||
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
"patch_next",
|
||||
"patch",
|
||||
"completed",
|
||||
{},
|
||||
{
|
||||
metadata: { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("3")
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||
/>
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/x-icon" href="%OPENCODE_FAVICON%" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%OPENCODE_APPLE_TOUCH_ICON%" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v3.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "OpenCode",
|
||||
"short_name": "OpenCode",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#080808",
|
||||
"background_color": "#080808",
|
||||
"display": "standalone"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/site.webmanifest
|
||||
@@ -103,8 +103,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
|
||||
start: (
|
||||
selection: ComposerSelection,
|
||||
submission: ReturnType<typeof createComposerSubmission>,
|
||||
message: SessionMessageUser,
|
||||
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void>; complete?: () => Promise<void> } | undefined>
|
||||
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
|
||||
}
|
||||
|
||||
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -229,50 +229,6 @@ describe("Composer submission", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("previews the first prompt while starting and hands it off before completing preparation", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture()
|
||||
const preview = Promise.withResolvers<SessionMessageUser>()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
const handoff: SessionMessageUser[] = []
|
||||
const target = session({
|
||||
calls,
|
||||
handoff: { set: (message) => handoff.push(message), clear() {} },
|
||||
prompt: async () => undefined,
|
||||
})
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start(_selection, _submission, message) {
|
||||
preview.resolve(message)
|
||||
await ready.promise
|
||||
return {
|
||||
session: target,
|
||||
cleanupReady: Promise.resolve(),
|
||||
async complete() {
|
||||
expect(handoff).toHaveLength(1)
|
||||
expect(handoff[0]?.id).toBe(message.id)
|
||||
expect(handoff[0]?.text).toBe("prepare my worktree")
|
||||
calls.push("complete")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const submitted = submitInput(adapter).submit(new Event("submit"))
|
||||
expect(await preview.promise).toMatchObject({ type: "user", text: "prepare my worktree" })
|
||||
expect(calls).toEqual([])
|
||||
expect(draft.current()).toMatchObject([{ content: "prepare my worktree" }])
|
||||
ready.resolve()
|
||||
await submitted
|
||||
expect(calls).toContain("complete")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
|
||||
const checked = Promise.withResolvers<void>()
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const started =
|
||||
input.adapter.kind === "active-session"
|
||||
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
|
||||
: await input.adapter.start(value.selection, submission, handoffMessage(value))
|
||||
: await input.adapter.start(value.selection, submission)
|
||||
if (!started) return
|
||||
const session = started.session
|
||||
|
||||
@@ -80,7 +80,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value).then(
|
||||
@@ -88,7 +88,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
await started.cleanupReady
|
||||
await started.complete?.()
|
||||
input.adapter.submitted()
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
@@ -105,7 +104,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
}
|
||||
|
||||
await started.cleanupReady
|
||||
await started.complete?.()
|
||||
input.adapter.submitted()
|
||||
|
||||
if (value.mode === "shell") {
|
||||
@@ -124,6 +122,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
@@ -148,19 +147,6 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
})),
|
||||
metadata: {
|
||||
displayText: value.text,
|
||||
comments: value.context.flatMap((item) =>
|
||||
item.comment?.trim()
|
||||
? [
|
||||
{
|
||||
path: item.path,
|
||||
comment: item.comment.trim(),
|
||||
...(item.selection ? { selection: { ...item.selection } } : {}),
|
||||
...(item.preview !== undefined ? { preview: item.preview } : {}),
|
||||
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { startTransition } from "solid-js"
|
||||
import type { NewSessionComposerAdapter } from "@/composer/adapter"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -44,32 +43,20 @@ export function createNewSessionComposerAdapter(props: {
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted: props.submitted,
|
||||
async start(selection, submission, message) {
|
||||
const draftID = props.draftID
|
||||
async start(selection, submission) {
|
||||
const projectDirectory = location().directory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const id = Session.ID.create()
|
||||
const pending =
|
||||
worktree === "create"
|
||||
? tabs.prepareSession(draftID, { server: server.key, sessionId: id }, { message, selection })
|
||||
: undefined
|
||||
await pending?.ready
|
||||
const sessionDirectory = await resolveSessionDirectory({
|
||||
projectDirectory,
|
||||
worktree,
|
||||
branch,
|
||||
branch: props.branch(),
|
||||
data,
|
||||
serverSDK,
|
||||
language,
|
||||
})
|
||||
if (!sessionDirectory) {
|
||||
await pending?.rollback()
|
||||
return
|
||||
}
|
||||
if (!sessionDirectory) return
|
||||
|
||||
const created = data.session.create({
|
||||
id,
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
@@ -88,13 +75,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
if (pending && !(await creation).ok) {
|
||||
// Keep retries on the worktree that was already created, not another new checkout.
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
return
|
||||
}
|
||||
const afterCreation = async <T>(run: () => Promise<T>) => {
|
||||
const result = await creation
|
||||
if (!result.ok) throw result.error
|
||||
@@ -105,13 +85,13 @@ export function createNewSessionComposerAdapter(props: {
|
||||
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
|
||||
)
|
||||
const cleanupReady = startTransition(() => {
|
||||
if (!pending) tabs.updateDraft(draftID, { worktree: undefined, branch: undefined })
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
|
||||
local.session.promote(sessionDirectory, created.id, {
|
||||
agent: selection.agent,
|
||||
model: selection.model,
|
||||
variant: selection.variant ?? null,
|
||||
})
|
||||
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
|
||||
tabs.promoteDraft(props.draftID, { server: server.key, sessionId: created.id })
|
||||
submission.retarget(
|
||||
prompt.capture(
|
||||
{ dir: base64Encode(sessionDirectory), id: created.id },
|
||||
@@ -122,7 +102,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
|
||||
return {
|
||||
cleanupReady,
|
||||
complete: pending?.complete,
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -734,7 +734,6 @@ export const dict = {
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
"session.new.worktree.creating": "Creating worktree",
|
||||
"session.new.workspace.runIn": "Run session in",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
@@ -984,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { SessionUserMessage } from "@opencode-ai/session-ui/message"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { readPromptPresentation } from "@/composer/comment-note"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { LocationProvider } from "@/workspaces/location"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useNotification } from "@/shell/notifications/notification"
|
||||
import { ComposerPersistenceProvider } from "@/composer/persistence"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
@@ -17,7 +11,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { TerminalProvider } from "@/session/terminal/context"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import { useTabs, type PendingSession } from "@/shell/tabs/tabs"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { useSessionModel } from "./model"
|
||||
import { SessionPanelFrame } from "./session-frame"
|
||||
@@ -30,8 +24,6 @@ import { SessionScreen } from "./screen"
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
|
||||
|
||||
return (
|
||||
@@ -40,55 +32,13 @@ export function TargetSessionRouteContent() {
|
||||
<ModelsProvider directory={directory}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<Show when={tabs.pendingSession(server.key, params.id)} fallback={<ResolvedTargetSessionRoute />}>
|
||||
{(pending) => <PreparingSession sessionID={params.id} pending={pending()} />}
|
||||
</Show>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
</ModelsProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PreparingSession(props: { sessionID: string; pending: PendingSession }) {
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(() => props.pending.draft.directory)
|
||||
return (
|
||||
<SessionStatePanel>
|
||||
<DataProvider
|
||||
directory={props.pending.draft.directory}
|
||||
data={{
|
||||
session: [],
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
provider: { all: providers.all(), default: providers.default(), connected: [] },
|
||||
}}
|
||||
>
|
||||
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pt-5 pb-5 md:px-5">
|
||||
<SessionUserMessage
|
||||
sessionID={props.sessionID}
|
||||
message={props.pending.message}
|
||||
comments={readPromptPresentation(props.pending.message.metadata)?.comments}
|
||||
historicalAgent={props.pending.selection.agent}
|
||||
historicalModel={{
|
||||
id: props.pending.selection.model.modelID,
|
||||
providerID: props.pending.selection.model.providerID,
|
||||
variant: props.pending.selection.variant,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="status"
|
||||
class="mt-3 flex min-h-6 items-center text-[13px] font-medium leading-[var(--line-height-compact)] text-v2-text-text-muted"
|
||||
>
|
||||
<TextShimmer text={language.t("session.new.worktree.creating")} active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DataProvider>
|
||||
</SessionStatePanel>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionSettingsCommand() {
|
||||
useSettingsCommand()
|
||||
return null
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
import type { SessionInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { ComposerSelection } from "@/composer/adapter"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/runtime/persistence/storage"
|
||||
@@ -35,12 +34,6 @@ export type DraftTab = {
|
||||
|
||||
export type Tab = SessionTab | DraftTab
|
||||
|
||||
export type PendingSession = {
|
||||
draft: DraftTab
|
||||
message: SessionMessageUser
|
||||
selection: ComposerSelection
|
||||
}
|
||||
|
||||
export type TabInfo = {
|
||||
title?: string
|
||||
directory?: string
|
||||
@@ -90,7 +83,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
createStore<Record<string, TabInfo>>({}),
|
||||
)
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -279,74 +271,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
memory.remove(`draft:${draftID}`)
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
pendingSession(server: ServerConnection.Key, sessionID: string): PendingSession | undefined {
|
||||
return pending[tabKey({ type: "session", server, sessionId: sessionID })]
|
||||
},
|
||||
prepareSession(
|
||||
draftID: string,
|
||||
session: Omit<SessionTab, "type">,
|
||||
preview: { message: SessionMessageUser; selection: ComposerSelection },
|
||||
) {
|
||||
// Snapshot the draft before replacing its store entry; keep its composer alive for rollback.
|
||||
const draft = { ...actions.draft(draftID) }
|
||||
const next = { type: "session" as const, ...session }
|
||||
const key = tabKey(next)
|
||||
const ready = startTransition(() => {
|
||||
setPending(key, { draft, ...preview })
|
||||
const index = store.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
if (index === -1) return
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs[index] = next
|
||||
}),
|
||||
)
|
||||
if (recentKey() === tabKey(draft)) setRecentKey(key)
|
||||
if (active) navigateTab(next)
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
async complete() {
|
||||
await ready
|
||||
if (!pending[key]) return
|
||||
await startTransition(() => setPending(key, undefined))
|
||||
memory.remove(tabKey(draft))
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
async rollback(worktree?: string) {
|
||||
await ready
|
||||
if (!pending[key]) return
|
||||
await startTransition(() => {
|
||||
const index = store.findIndex((tab) => tabKey(tab) === key)
|
||||
if (index !== -1) {
|
||||
const restored = worktree === undefined ? draft : { ...draft, worktree, branch: undefined }
|
||||
const route = currentRoute()
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs[index] = restored
|
||||
}),
|
||||
)
|
||||
if (recentKey() === key) setRecentKey(tabKey(restored))
|
||||
if (
|
||||
route.type === "session" &&
|
||||
route.server === session.server &&
|
||||
route.sessionId === session.sessionId
|
||||
) {
|
||||
navigateTab(restored)
|
||||
}
|
||||
}
|
||||
setPending(key, undefined)
|
||||
})
|
||||
updateClosed((stack) => removeClosedTabs(stack, session.server, [session.sessionId]))
|
||||
memory.remove(key)
|
||||
removeInfo(key)
|
||||
if (store.some((tab) => tab.type === "draft" && tab.draftID === draftID)) return
|
||||
memory.remove(tabKey(draft))
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
}
|
||||
},
|
||||
removeTab,
|
||||
// User-initiated close: records the tab so it can be reopened.
|
||||
// Cleanup paths (missing sessions, archive, server removal) go through
|
||||
|
||||
@@ -92,12 +92,10 @@ function SessionTabEntry(props: {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||
const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId))
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
if (pending()) return null
|
||||
const ctx = props.serverCtx
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
@@ -107,9 +105,9 @@ function SessionTabEntry(props: {
|
||||
.then(() => ctx.data.session.get(id))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => (pending() ? undefined : (cachedSession() ?? loadedSession())))
|
||||
const missingSession = createMemo(() => !pending() && !!props.serverCtx && !loadedSession.loading && !session())
|
||||
const visible = createMemo(() => !!pending() || !!session() || missingSession() || !!persisted()?.title)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const value = session()
|
||||
@@ -172,11 +170,7 @@ function SessionTabEntry(props: {
|
||||
forceTruncate={props.forceTruncate}
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("command.session.new")
|
||||
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
|
||||
}
|
||||
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
|
||||
onRename={rename}
|
||||
onNavigate={props.onNavigate}
|
||||
onClose={props.onClose}
|
||||
|
||||
@@ -161,7 +161,6 @@ export function Titlebar(props: {
|
||||
() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return undefined
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) return undefined
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
|
||||
},
|
||||
@@ -170,7 +169,6 @@ export function Titlebar(props: {
|
||||
const session = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
|
||||
if (cached) return cached
|
||||
@@ -222,10 +220,6 @@ export function Titlebar(props: {
|
||||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
if (tabs.pendingSession(route.server, route.sessionId)) {
|
||||
tabsStoreActions.addSessionTab({ server: route.server, sessionId: route.sessionId })
|
||||
return
|
||||
}
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
@@ -244,12 +238,6 @@ export function Titlebar(props: {
|
||||
const route = layout.route()
|
||||
switch (route.type) {
|
||||
case "session": {
|
||||
const pending = tabs.pendingSession(route.server, route.sessionId)
|
||||
if (pending) {
|
||||
const model = tabs.stateValue<ComposerState>(pending.draft, "prompt")?.model.current()
|
||||
void tabs.newDraft({ server: route.server, directory: pending.draft.directory }, "", model)
|
||||
return
|
||||
}
|
||||
const activeSession = session()
|
||||
if (!activeSession) return
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import desktopPlugin, { channel } from "./vite.js"
|
||||
import { icons } from "./vite.icons"
|
||||
import { serviceWorker } from "./vite.pwa"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
import desktopPlugin from "./vite.js"
|
||||
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
@@ -25,8 +23,58 @@ const sentry =
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
desktopPlugin,
|
||||
icons(channel),
|
||||
serviceWorker(fileURLToPath(new URL("./dist", import.meta.url))),
|
||||
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: {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fetch } from "bun"
|
||||
import { build, createServer } from "vite"
|
||||
import { icons } from "./vite.icons"
|
||||
import manifest from "./manifest.json" with { type: "json" }
|
||||
|
||||
test.each(["dev", "beta", "prod", "local"])("bundles %s app icons", async (channel) => {
|
||||
const result = await build({
|
||||
root: import.meta.dirname,
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
plugins: [
|
||||
icons(channel),
|
||||
{
|
||||
name: "icons-only-fixture",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: (html) => html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, ""),
|
||||
},
|
||||
},
|
||||
],
|
||||
build: { write: false, copyPublicDir: false },
|
||||
})
|
||||
if (!("output" in result)) throw new Error("Expected a single build output")
|
||||
|
||||
await check(channel === "local" ? "dev" : channel, async (path) => {
|
||||
const file = result.output.find((file) => `/${file.fileName}` === path)
|
||||
if (file?.type !== "asset") throw new Error(`Missing asset: ${path}`)
|
||||
return typeof file.source === "string" ? new TextEncoder().encode(file.source) : file.source
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["dev", "beta", "prod"])("serves %s app icons", async (channel) => {
|
||||
const server = await createServer({
|
||||
root: import.meta.dirname,
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
plugins: [icons(channel)],
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, preTransformRequests: false, watch: null },
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
const address = server.httpServer?.address()
|
||||
if (!address || typeof address === "string") throw new Error("Expected an HTTP port")
|
||||
|
||||
await check(channel, async (path) => {
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}${path}`)
|
||||
expect(response.status).toBe(200)
|
||||
if (path.endsWith(".webmanifest")) expect(response.headers.get("content-type")).toBe("application/manifest+json")
|
||||
if (path.endsWith(".png")) expect(response.headers.get("content-type")).toBe("image/png")
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
})
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
async function check(channel: string, read: (path: string) => Promise<Uint8Array>) {
|
||||
const html = new TextDecoder().decode(await read("/index.html"))
|
||||
const actual: typeof manifest = JSON.parse(new TextDecoder().decode(await read("/site.webmanifest")))
|
||||
expect(actual).toEqual({
|
||||
...manifest,
|
||||
icons: manifest.icons.map((icon) => ({ ...icon, src: `/icons/${channel}${icon.src}` })),
|
||||
})
|
||||
expect(html).toContain(`href="/icons/${channel}/favicon.ico"`)
|
||||
expect(html).toContain(`href="/icons/${channel}/apple-touch-icon.png"`)
|
||||
expect(html).toContain(`href="/site.webmanifest"`)
|
||||
expect(html).not.toContain("%OPENCODE_")
|
||||
|
||||
await Promise.all(
|
||||
Object.entries({
|
||||
"favicon.ico": "icon.ico",
|
||||
"apple-touch-icon.png": "ios/AppIcon-60x60@3x.png",
|
||||
"web-app-manifest-192x192.png": "android/mipmap-xxxhdpi/ic_launcher.png",
|
||||
"web-app-manifest-512x512.png": "icon.png",
|
||||
}).map(async ([name, source]) => {
|
||||
const bytes = await read(`/icons/${channel}/${name}`)
|
||||
expect(bytes).toEqual(await Bun.file(new URL(`../desktop/icons/${channel}/${source}`, import.meta.url)).bytes())
|
||||
if (!name.endsWith(".png")) return
|
||||
const size = name === "apple-touch-icon.png" ? 180 : Number(name.match(/(192|512)/)?.[0])
|
||||
expect(new DataView(bytes.buffer, bytes.byteOffset).getUint32(16)).toBe(size)
|
||||
expect(new DataView(bytes.buffer, bytes.byteOffset).getUint32(20)).toBe(size)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import type { Plugin } from "vite"
|
||||
import manifest from "./manifest.json" with { type: "json" }
|
||||
|
||||
export function icons(channel: string): Plugin {
|
||||
const selected = channel === "beta" || channel === "prod" ? channel : "dev"
|
||||
const prefix = `icons/${selected}`
|
||||
const files = [
|
||||
...Object.entries({
|
||||
"favicon.ico": "icon.ico",
|
||||
"apple-touch-icon.png": "ios/AppIcon-60x60@3x.png",
|
||||
"web-app-manifest-192x192.png": "android/mipmap-xxxhdpi/ic_launcher.png",
|
||||
"web-app-manifest-512x512.png": "icon.png",
|
||||
}).map(([name, source]) => ({
|
||||
fileName: `${prefix}/${name}`,
|
||||
source: readFileSync(new URL(`../desktop/icons/${selected}/${source}`, import.meta.url)),
|
||||
type: name.endsWith(".ico") ? "image/x-icon" : "image/png",
|
||||
})),
|
||||
{
|
||||
fileName: "site.webmanifest",
|
||||
source: JSON.stringify({
|
||||
...manifest,
|
||||
icons: manifest.icons.map((icon) => ({ ...icon, src: `/${prefix}${icon.src}` })),
|
||||
}),
|
||||
type: "application/manifest+json",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
name: "opencode-app:icons",
|
||||
generateBundle() {
|
||||
files.forEach((file) => this.emitFile({ type: "asset", fileName: file.fileName, source: file.source }))
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const file = files.find((file) => `/${file.fileName}` === request.url?.split("?")[0])
|
||||
if (!file) return next()
|
||||
response.setHeader("Content-Type", file.type)
|
||||
response.end(file.source)
|
||||
})
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler(html) {
|
||||
return html
|
||||
.replace("%OPENCODE_FAVICON%", `/${prefix}/favicon.ico`)
|
||||
.replace("%OPENCODE_APPLE_TOUCH_ICON%", `/${prefix}/apple-touch-icon.png`)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
|
||||
}
|
||||
}
|
||||
|
||||
export const channel = (() => {
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -526,7 +526,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/session/import`,
|
||||
body: { info: input["info"], messages: input["messages"], location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 404, 401, 400],
|
||||
declaredStatuses: [409, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// The platform barrel also exposes Redis and its optional native hash loader, which workerd cannot resolve.
|
||||
import { NodeWS } from "@effect/platform-node/NodeSocket"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { HttpProxyAgent } from "http-proxy-agent"
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import { Layer } from "effect"
|
||||
@@ -81,8 +80,8 @@ const layer = Layer.succeed(Socket.WebSocketConstructor, (url, input) => {
|
||||
followRedirects: false,
|
||||
}
|
||||
const socket = config.protocols
|
||||
? new NodeWS.WebSocket(url, config.protocols, native)
|
||||
: new NodeWS.WebSocket(url, native)
|
||||
? new NodeSocket.NodeWS.WebSocket(url, config.protocols, native)
|
||||
: new NodeSocket.NodeWS.WebSocket(url, native)
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ws implements the WebSocket surface consumed by the AI transport.
|
||||
return socket as unknown as globalThis.WebSocket
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Limits, Draft>({
|
||||
|
||||
@@ -402,7 +402,14 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
|
||||
return Array.filterMap(yield* kv.scanAll(backgroundPrefix), (entry) => decodeBackground(entry.value))
|
||||
const recovered: Background[] = []
|
||||
let after: string | undefined
|
||||
do {
|
||||
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
|
||||
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
|
||||
after = page.next
|
||||
} while (after)
|
||||
return recovered
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
|
||||
+20
-33
@@ -29,7 +29,6 @@ export interface Interface {
|
||||
readonly set: (key: string, value: Value) => Effect.Effect<void>
|
||||
readonly remove: (key: string) => Effect.Effect<void>
|
||||
readonly scan: (options: ScanOptions) => Effect.Effect<ScanResult>
|
||||
readonly scanAll: (prefix: string) => Effect.Effect<readonly Entry[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
|
||||
@@ -38,28 +37,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const scan: Interface["scan"] = Effect.fn("KV.scan")(function* (options) {
|
||||
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
|
||||
const end = prefixEnd(options.prefix)
|
||||
const rows = yield* db
|
||||
.select({ key: KVTable.key, value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(
|
||||
and(
|
||||
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
|
||||
end === undefined ? undefined : lt(KVTable.key, end),
|
||||
options.after === undefined ? undefined : gt(KVTable.key, options.after),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(KVTable.key))
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const entries = rows.slice(0, limit)
|
||||
if (rows.length <= limit) return { entries }
|
||||
return { entries, next: entries[entries.length - 1].key }
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("KV.get")(function* (key) {
|
||||
return (yield* db
|
||||
@@ -80,16 +57,26 @@ const layer = Layer.effect(
|
||||
remove: Effect.fn("KV.remove")(function* (key) {
|
||||
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
|
||||
}),
|
||||
scan,
|
||||
scanAll: Effect.fn("KV.scanAll")(function* (prefix) {
|
||||
const entries: Entry[] = []
|
||||
let after: string | undefined
|
||||
do {
|
||||
const page = yield* scan({ prefix, after, limit: 1000 })
|
||||
entries.push(...page.entries)
|
||||
after = page.next
|
||||
} while (after !== undefined)
|
||||
return entries
|
||||
scan: Effect.fn("KV.scan")(function* (options) {
|
||||
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
|
||||
const end = prefixEnd(options.prefix)
|
||||
const rows = yield* db
|
||||
.select({ key: KVTable.key, value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(
|
||||
and(
|
||||
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
|
||||
end === undefined ? undefined : lt(KVTable.key, end),
|
||||
options.after === undefined ? undefined : gt(KVTable.key, options.after),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(KVTable.key))
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const entries = rows.slice(0, limit)
|
||||
if (rows.length <= limit) return { entries }
|
||||
return { entries, next: entries[entries.length - 1].key }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,6 @@ import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
@@ -70,7 +69,6 @@ const locationServiceNodes = [
|
||||
ModelResolver.node,
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginHooks.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
|
||||
@@ -100,10 +100,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export function relevant(input: Pick<Request, "action">, rules: Permission.Ruleset) {
|
||||
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
@@ -165,6 +161,10 @@ const layer = Layer.effect(
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
function relevant(input: AssertInput, rules: Permission.Ruleset) {
|
||||
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
}
|
||||
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export * as Permissions from "./permissions.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Permission } from "./permission.js"
|
||||
import type { SessionSchema } from "./session/schema.js"
|
||||
import { Source } from "./source.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly visibility: Source.Interface<Permission.Ruleset>
|
||||
readonly ask: (
|
||||
session: SessionSchema.Info,
|
||||
request: Omit<Permission.AssertInput, "sessionID" | "agent">,
|
||||
) => Effect.Effect<void, Permission.Error | Permission.DeclinedError>
|
||||
}
|
||||
|
||||
export const allowAll: Interface = {
|
||||
visibility: Source.constant([{ action: "*", resource: "*", effect: "allow" }]),
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
export function rules(source: Source.Value<Permission.Ruleset>): Interface {
|
||||
const visibility = Source.from(source)
|
||||
return {
|
||||
visibility,
|
||||
ask: Effect.fn("Permissions.ask")(function* (session, request) {
|
||||
const rules = yield* visibility.get(session)
|
||||
if (
|
||||
request.resources.every((resource) => Permission.evaluate(request.action, resource, rules).effect === "allow")
|
||||
)
|
||||
return
|
||||
return yield* new Permission.BlockedError({
|
||||
rules: Permission.relevant(request, rules),
|
||||
permission: request.action,
|
||||
resources: request.resources,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const callbacks = new Map<string, Entry[]>()
|
||||
|
||||
@@ -46,15 +46,6 @@ export interface Resolved {
|
||||
readonly vcsBackend?: string
|
||||
}
|
||||
|
||||
export function markerless(directory: AbsolutePath): Resolved {
|
||||
return {
|
||||
id: ID.make(Hash.fast(`directory:${directory}`)),
|
||||
directory,
|
||||
canonical: directory,
|
||||
vcs: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||
export const root = Effect.fn("Project.root")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
@@ -370,7 +361,12 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
return yield* persist(markerless(directory))
|
||||
return yield* persist({
|
||||
id: ID.make(Hash.fast(`directory:${directory}`)),
|
||||
directory,
|
||||
canonical: directory,
|
||||
vcs: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ list, update, resolve })
|
||||
|
||||
+95
-179
@@ -1,6 +1,5 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
export type { OpenInput, Handle } from "./session/capabilities.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
@@ -42,7 +41,6 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Image } from "./image.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { Mime } from "./mime.js"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
@@ -57,8 +55,6 @@ import { fileURLToPath } from "url"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionResolve } from "./session/resolve.js"
|
||||
import type { SessionCapabilities } from "./session/capabilities.js"
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
@@ -191,7 +187,6 @@ export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
export interface Interface {
|
||||
readonly open: (input: SessionCapabilities.OpenInput) => Effect.Effect<SessionCapabilities.Handle>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
readonly data: SessionSchema.Info[]
|
||||
}>
|
||||
@@ -346,7 +341,6 @@ const layer = Layer.effect(
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
@@ -354,9 +348,6 @@ const layer = Layer.effect(
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const resolved = yield* resolve.resolve(session)
|
||||
if (resolved.status === "attached") return yield* resolved.capabilities.transport.close(session.id)
|
||||
if (resolved.status === "owned-detached") return
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
@@ -365,7 +356,7 @@ const layer = Layer.effect(
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
})
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
|
||||
@@ -392,90 +383,61 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const create = Effect.fn("Session.create")(function* (input: CreateInput, resolved?: Project.Resolved) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
|
||||
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = resolved ?? (yield* projects.resolve(location.directory))
|
||||
yield* persistProject(project)
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
SessionEvent.Created,
|
||||
{
|
||||
sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ location },
|
||||
)
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
})
|
||||
const result = Service.of({
|
||||
create,
|
||||
open: Effect.fn("Session.open")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const directory = AbsolutePath.make(process.cwd())
|
||||
// Transitional placement is unused by supplied capabilities, but listings group
|
||||
// these Sessions under the deterministic cwd-derived project. Adoption never moves it.
|
||||
const session = yield* resolve.own(
|
||||
create(
|
||||
{ id: input.id, title: input.title, location: Location.Ref.make({ directory }) },
|
||||
Project.markerless(directory),
|
||||
).pipe(Effect.orDie),
|
||||
)
|
||||
const close = yield* resolve.attach(session.id, input)
|
||||
return {
|
||||
id: session.id,
|
||||
prompt: (prompt) =>
|
||||
Effect.gen(function* () {
|
||||
const admitted = yield* result.prompt({ ...prompt, sessionID: session.id, resume: false })
|
||||
if (prompt.resume !== false) yield* result.resume(session.id)
|
||||
return admitted
|
||||
}),
|
||||
resume: () => result.resume(session.id),
|
||||
interrupt: (options) => result.interrupt(session.id, options),
|
||||
close,
|
||||
} satisfies SessionCapabilities.Handle
|
||||
}),
|
||||
),
|
||||
),
|
||||
create: Effect.fn("Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
const recorded = yield* store.get(sessionID)
|
||||
if (recorded) return recorded
|
||||
const parent = input.parentID ? yield* store.get(input.parentID) : undefined
|
||||
if (input.parentID && parent === undefined) return yield* new NotFoundError({ sessionID: input.parentID })
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
yield* persistProject(project)
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
SessionEvent.Created,
|
||||
{
|
||||
sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ location },
|
||||
)
|
||||
.pipe(
|
||||
Effect.as({ type: "created" } as const),
|
||||
Effect.catchDefect((defect) => {
|
||||
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
|
||||
return Effect.die(defect)
|
||||
}
|
||||
// Concurrent creation lost the projection race. The existing Session identity wins.
|
||||
return store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (projected.type === "existing") return projected.session
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
fork: Effect.fn("Session.fork")(function* (input) {
|
||||
const parent = yield* result.get(input.sessionID)
|
||||
const boundary = yield* db
|
||||
@@ -548,7 +510,6 @@ const layer = Layer.effect(
|
||||
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
||||
yield* environments.clear(sessionID)
|
||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
yield* resolve.remove(sessionID)
|
||||
yield* bus.remove(sessionID)
|
||||
}),
|
||||
list: Effect.fn("Session.list")(function* (input = {}) {
|
||||
@@ -679,52 +640,39 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
prompt: Effect.fn("Session.prompt")((input) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
// A staged revert must be committed before admitting new input so the prompt
|
||||
// continues from the reverted boundary rather than stale post-boundary history.
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
// Resolved lazily so prompt admission only boots location services when an
|
||||
// image attachment actually needs the resizer.
|
||||
const image = Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Image.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Skill.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const prompt = yield* resolvePrompt(
|
||||
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
image,
|
||||
skills,
|
||||
).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* Effect.gen(function* () {
|
||||
const existing = yield* SessionInbox.reconcile(db, {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
if (existing) return existing
|
||||
const resolved = yield* resolve.resolve(session)
|
||||
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
|
||||
if (resolved.status === "owned-detached" && (input.files?.length || input.skills?.length))
|
||||
return yield* SessionResolve.unavailable(session.id)
|
||||
const item = yield* restore(
|
||||
(resolved.status === "unowned"
|
||||
? Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return yield* preparePrompt(
|
||||
input,
|
||||
messageID,
|
||||
Effect.service(Image.Service),
|
||||
Effect.service(Skill.Service),
|
||||
hooks,
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
: preparePrompt(
|
||||
input,
|
||||
messageID,
|
||||
resolved.status === "attached"
|
||||
? Effect.succeed(resolved.capabilities.image)
|
||||
: SessionResolve.unavailable(session.id),
|
||||
Effect.undefined,
|
||||
)
|
||||
).pipe(Effect.provideService(FSUtil.Service, fs)),
|
||||
)
|
||||
// Commit a staged revert only after preparation succeeds, before admitting new work.
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
return yield* SessionInbox.admit(db, bus, {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
item,
|
||||
})
|
||||
const admittedInput = SessionInbox.Item.make({
|
||||
type: "user",
|
||||
payload: { ...prompt, metadata: input.metadata },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
const admitted = yield* SessionInbox.admit(db, bus, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
item: admittedInput,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInbox.LifecycleConflict
|
||||
@@ -742,7 +690,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
return admitted
|
||||
}),
|
||||
).pipe(Effect.scoped),
|
||||
),
|
||||
),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
@@ -830,7 +778,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (resolve.status(session.id) !== "unowned") return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = yield* skills.get(input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
@@ -1071,28 +1018,11 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
|
||||
}
|
||||
}
|
||||
|
||||
const preparePrompt = Effect.fn("Session.preparePrompt")(function* <RImage, RSkills>(
|
||||
request: Parameters<Interface["prompt"]>[0],
|
||||
messageID: SessionMessage.ID,
|
||||
image: Effect.Effect<Image.Interface, never, RImage>,
|
||||
skills: Effect.Effect<Skill.Interface | undefined, never, RSkills>,
|
||||
hooks?: PluginHooks.Interface,
|
||||
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
input: PromptInput.Prompt,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
skills: Effect.Effect<Skill.Interface>,
|
||||
) {
|
||||
const initial: PluginHooks.Domains["session"]["prompt"] = {
|
||||
sessionID: request.sessionID,
|
||||
messageID,
|
||||
prompt: structuredClone({
|
||||
text: request.text,
|
||||
files: request.files?.slice(),
|
||||
agents: request.agents?.slice(),
|
||||
skills: request.skills?.slice(),
|
||||
}),
|
||||
metadata: structuredClone(request.metadata),
|
||||
delivery: request.delivery ?? "steer",
|
||||
}
|
||||
// Supplied capabilities have no configured prompt interceptors; discovery owns those hooks.
|
||||
const event = hooks ? yield* hooks.trigger("session", "prompt", initial) : initial
|
||||
const input = event.prompt
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = input.files
|
||||
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
|
||||
@@ -1101,7 +1031,6 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* <RImage, RSki
|
||||
const selected = yield* Effect.gen(function* () {
|
||||
if (!requested?.length) return undefined
|
||||
const skillService = yield* skills
|
||||
if (!skillService) return yield* new SkillNotFoundError({ skill: requested[0].id })
|
||||
const prepared = new Map<Skill.ID, Skill.Name>()
|
||||
return yield* Effect.forEach(requested, (attachment) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1119,27 +1048,15 @@ const preparePrompt = Effect.fn("Session.preparePrompt")(function* <RImage, RSki
|
||||
}),
|
||||
)
|
||||
})
|
||||
return SessionInbox.Item.make({
|
||||
type: "user",
|
||||
payload: {
|
||||
...Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files,
|
||||
skills: selected?.length ? selected : undefined,
|
||||
}),
|
||||
metadata: event.metadata,
|
||||
},
|
||||
delivery: event.delivery,
|
||||
})
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
|
||||
})
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
const materializeAttachment = Effect.fn("Session.materializeAttachment")(function* <R>(
|
||||
const materializeAttachment = Effect.fn("Session.materializeAttachment")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
input: PromptInput.FileAttachment,
|
||||
image: Effect.Effect<Image.Interface, never, R>,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
) {
|
||||
const resolved = input.uri.startsWith("data:")
|
||||
? {
|
||||
@@ -1179,11 +1096,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
})
|
||||
})
|
||||
|
||||
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* <R>(
|
||||
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
data: string,
|
||||
mime: string,
|
||||
image: Effect.Effect<Image.Interface, never, R>,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const service = yield* image
|
||||
@@ -1284,7 +1201,6 @@ export const node = makeGlobalNode({
|
||||
Project.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
SessionResolve.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export * as SessionCapabilities from "./capabilities.js"
|
||||
|
||||
import type { Effect } from "effect"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
import type { Permissions } from "../permissions.js"
|
||||
import type { Source } from "../source.js"
|
||||
import type { Tool } from "../tool.js"
|
||||
import type { Session } from "../session.js"
|
||||
import type { SessionRunner } from "./runner/index.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
export interface OpenInput {
|
||||
readonly id?: SessionSchema.ID
|
||||
readonly title?: string
|
||||
readonly model: Source.Value<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
|
||||
readonly tools?: Source.Value<ReadonlyArray<Tool.Info>>
|
||||
readonly instructions?: Source.Value<ReadonlyArray<string> | Instructions.Unavailable>
|
||||
readonly permissions?: Permissions.Interface
|
||||
readonly system?: Source.Value<string | Instructions.Unavailable>
|
||||
readonly limits?: Source.Value<{ readonly steps?: number }>
|
||||
/** Called after replacement or host teardown, once all in-flight work has settled. */
|
||||
readonly retire?: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Handle {
|
||||
readonly id: SessionSchema.ID
|
||||
readonly prompt: (
|
||||
input: Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">,
|
||||
) => Effect.Effect<
|
||||
Effect.Success<ReturnType<Session.Interface["prompt"]>>,
|
||||
Effect.Error<ReturnType<Session.Interface["prompt"]>> | SessionRunner.RunError
|
||||
>
|
||||
readonly resume: () => ReturnType<Session.Interface["resume"]>
|
||||
readonly interrupt: (options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
/** Releases this open's capabilities after settlement without interrupting or deleting the Session. */
|
||||
readonly close: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, AIError, LLMEvent, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -9,14 +9,15 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { State } from "../state.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
@@ -77,26 +78,25 @@ export type AutoInput = {
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
/** The runner resolves the conversation agent only when there is history to compact. */
|
||||
readonly context: Effect.Effect<
|
||||
SessionContext.Loaded,
|
||||
AgentNotFoundError | SessionRunnerModel.Error | Instructions.InitializationBlocked
|
||||
>
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
export type ManualInput = Pick<AutoInput, "session" | "messages" | "context" | "prepare"> & {
|
||||
readonly inputID: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
/** Invoked after content planning, not when the caller captures the operation. */
|
||||
readonly resolveModel: SessionContext.Interface["resolveModel"]
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly context: AutoInput["context"]
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly recent: string
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID?: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
@@ -176,10 +176,7 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const select = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
const conversation = messages
|
||||
.filter((message) => message.type !== "compaction" && message.type !== "system")
|
||||
.flatMap((message) => {
|
||||
@@ -201,10 +198,8 @@ const select = (
|
||||
if (latestUser > 0) split = latestUser
|
||||
}
|
||||
return {
|
||||
head: conversation
|
||||
.slice(0, split)
|
||||
.map((item) => item.text)
|
||||
.join("\n\n"),
|
||||
split: messages.indexOf(conversation[split].message),
|
||||
hasHead: split > 0,
|
||||
recent: conversation
|
||||
.slice(split)
|
||||
.map((item) => item.text)
|
||||
@@ -212,14 +207,12 @@ const select = (
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
|
||||
export const buildPrompt = () =>
|
||||
[
|
||||
input.previousSummary
|
||||
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
|
||||
: "Create a new anchored summary from the conversation history.",
|
||||
"Summarize the conversation above so work can continue without the earlier messages.",
|
||||
SUMMARY_TEMPLATE,
|
||||
"The following is the conversation history:",
|
||||
...input.context,
|
||||
"If the history contains a conversation checkpoint, incorporate its summary and recent context. Preserve still-true details, remove stale details, and merge in the new facts.",
|
||||
"Do not continue the task or call tools. Output only the summary.",
|
||||
].join("\n\n")
|
||||
|
||||
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
@@ -229,13 +222,10 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
|
||||
(message): message is SessionMessage.CompactionCompleted =>
|
||||
message.type === "compaction" && message.status === "completed",
|
||||
)
|
||||
const previousRecent = previousSummary?.recent ?? ""
|
||||
const summarizeRecent = !previousRecent && !selected.head
|
||||
const summarizeRecent = !previousSummary?.recent && !selected.hasHead
|
||||
return {
|
||||
prompt: buildPrompt({
|
||||
previousSummary: previousSummary?.summary,
|
||||
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||
}),
|
||||
// Keep the existing checkpoint and chronological updates in their original positions.
|
||||
messages: summarizeRecent ? messages : messages.slice(0, selected.split),
|
||||
recent: summarizeRecent ? "" : selected.recent,
|
||||
}
|
||||
}
|
||||
@@ -262,11 +252,39 @@ const make = (dependencies: Dependencies) => {
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
|
||||
if (
|
||||
!plan.messages.some((message) => message.type !== "compaction" && message.type !== "system" && serialize(message))
|
||||
)
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
const loaded = yield* plan.context.pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: toSessionError(cause),
|
||||
inputID: plan.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in loaded) return loaded
|
||||
const content = planContent(loaded.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
if (!plan.started)
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
recent: content.recent,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
|
||||
@@ -276,33 +294,44 @@ const make = (dependencies: Dependencies) => {
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: content.messages,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript,
|
||||
})
|
||||
const request = LLMRequest.update(prepared.request, {
|
||||
messages: [...prepared.request.messages, Message.user(buildPrompt())],
|
||||
})
|
||||
yield* dependencies.llm.stream(request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.toolCall(event))
|
||||
failure = { type: "compaction.failed", message: "Compaction attempted to call a tool" }
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
const step = SessionUsage.record(event.usage, loaded.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
@@ -317,7 +346,7 @@ const make = (dependencies: Dependencies) => {
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
@@ -332,36 +361,29 @@ const make = (dependencies: Dependencies) => {
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
})
|
||||
}
|
||||
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: loaded.session.id,
|
||||
reason: plan.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
recent: content.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
const compact = Effect.fn("SessionCompaction.compact")((input: AutoInput) =>
|
||||
execute({
|
||||
session: input.session,
|
||||
messages: input.messages,
|
||||
context: input.context,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
@@ -383,36 +405,12 @@ const make = (dependencies: Dependencies) => {
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")((input: ManualInput) =>
|
||||
execute({
|
||||
...input,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionContext from "./context.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
import { CodeModeInstructions } from "../codemode/instructions.js"
|
||||
@@ -17,13 +17,6 @@ import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { ReferenceInstructions } from "../reference/instructions.js"
|
||||
import { SkillInstructions } from "../skill/instructions.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Permissions } from "../permissions.js"
|
||||
import { Image } from "../image.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { Source } from "../source.js"
|
||||
import type { SessionCapabilities } from "./capabilities.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
@@ -38,8 +31,6 @@ export interface Selection {
|
||||
readonly agent: Agent.Selection & { readonly info: Agent.Info }
|
||||
readonly instructions: Instructions.List
|
||||
readonly tools: Tool.Snapshot
|
||||
/** `baseTranscript` uses its default prefix for undefined; "" omits it when system text lives in the instruction epoch. */
|
||||
readonly system?: string
|
||||
}
|
||||
|
||||
export interface Loaded {
|
||||
@@ -49,7 +40,6 @@ export interface Loaded {
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly tools: Tool.Snapshot
|
||||
readonly system?: Selection["system"]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +50,7 @@ export interface Loaded {
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
|
||||
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
readonly select: (sessionID: SessionSchema.ID, agentID?: Agent.ID) => Effect.Effect<Selection, AgentNotFoundError>
|
||||
/** Resolves the model and active history for that selection. */
|
||||
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
|
||||
readonly resolveModel: (
|
||||
@@ -129,7 +119,7 @@ const layer = Layer.effect(
|
||||
return { agent, primary, selected }
|
||||
})
|
||||
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
|
||||
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
@@ -137,8 +127,8 @@ const layer = Layer.effect(
|
||||
|
||||
yield* plugins.flush
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const agent = yield* agents.select(agentID ?? session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(agent.info.permissions),
|
||||
@@ -167,144 +157,23 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
select,
|
||||
load: load(db, resolveModel),
|
||||
resolveModel,
|
||||
selectTitle,
|
||||
prepare: modelRequests.prepare,
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
const model = yield* resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
|
||||
return {
|
||||
session: selection.session,
|
||||
agent: selection.agent,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.entries.map((entry) => entry.message),
|
||||
tools: selection.tools,
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
|
||||
}),
|
||||
)
|
||||
|
||||
/** The values path shares instruction persistence and history assembly with discovery. */
|
||||
export const values = (input: SessionCapabilities.OpenInput) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const store = yield* SessionStore.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const tools = Source.from(input.tools ?? [])
|
||||
const instructions = Source.from(input.instructions ?? [])
|
||||
const limits = Source.from(input.limits ?? {})
|
||||
const permissions = input.permissions ?? Permissions.allowAll
|
||||
const model = Source.from(input.model)
|
||||
const resolveModel: Interface["resolveModel"] = (session) => model.get(session)
|
||||
let cached:
|
||||
| {
|
||||
readonly tools: ReadonlyArray<Tool.Info>
|
||||
readonly rules: Permission.Ruleset
|
||||
readonly snapshot: Tool.Snapshot
|
||||
}
|
||||
| undefined
|
||||
const select: Interface["select"] = Effect.fn("SessionContext.selectValues")(function* (sessionID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const selected = yield* Effect.all(
|
||||
{
|
||||
tools: tools.get(session),
|
||||
rules: permissions.visibility.get(session),
|
||||
limits: limits.get(session),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (cached?.tools !== selected.tools || cached.rules !== selected.rules)
|
||||
cached = {
|
||||
tools: selected.tools,
|
||||
rules: selected.rules,
|
||||
snapshot: yield* Tool.snapshot(selected.tools, selected.rules).pipe(
|
||||
Effect.provideService(PluginHooks.Service, hooks),
|
||||
Effect.provideService(Image.Service, image),
|
||||
),
|
||||
}
|
||||
const snapshot = cached.snapshot
|
||||
const id = session.agent ?? Agent.defaultID
|
||||
return {
|
||||
session,
|
||||
agent: { id, info: { ...Agent.Info.default(id), permissions: selected.rules, steps: selected.limits.steps } },
|
||||
// System text participates in the epoch instead of changing the privileged prefix.
|
||||
system: "",
|
||||
tools: snapshot,
|
||||
instructions: Instructions.combine([
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("session/system"),
|
||||
codec: Schema.String,
|
||||
read:
|
||||
input.system === undefined
|
||||
? Effect.succeed(SessionSystemPrompt.make(snapshot.definitions.map((tool) => tool.name)))
|
||||
: Source.from(input.system)
|
||||
.get(session)
|
||||
.pipe(Effect.map((value) => (value === "" ? Instructions.removed : value))),
|
||||
render: {
|
||||
initial: (value) => value,
|
||||
changed: (_previous, value) =>
|
||||
`The system instructions changed and supersede the previous value:\n${value}`,
|
||||
removed: () => "The previous system instructions no longer apply.",
|
||||
},
|
||||
}),
|
||||
CodeModeInstructions.make(snapshot.codeModeCatalog),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("session/instructions"),
|
||||
codec: Schema.Array(Schema.String),
|
||||
read: instructions
|
||||
.get(session)
|
||||
.pipe(
|
||||
Effect.map((value) =>
|
||||
Array.isArray(value) && !value.some((part) => part.length > 0) ? Instructions.removed : value,
|
||||
),
|
||||
),
|
||||
render: {
|
||||
initial: (value) => value.join("\n\n"),
|
||||
changed: (_previous, value) =>
|
||||
`The session instructions changed and supersede the previous value:\n${value.join("\n\n")}`,
|
||||
removed: () => "The previous session instructions no longer apply.",
|
||||
},
|
||||
}),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("session/permissions"),
|
||||
codec: Schema.toCodecJson(Permission.Ruleset),
|
||||
read: Effect.succeed(selected.rules.length > 0 ? selected.rules : Instructions.removed),
|
||||
render: {
|
||||
initial: (value) => `Permission rules:\n${JSON.stringify(value)}`,
|
||||
changed: (_previous, value) => `Permission rules changed:\n${JSON.stringify(value)}`,
|
||||
removed: () => "The previous permission rules no longer apply.",
|
||||
},
|
||||
}),
|
||||
selected.entries,
|
||||
]),
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
select,
|
||||
load: load(db, resolveModel),
|
||||
resolveModel,
|
||||
selectTitle: () => Effect.undefined,
|
||||
prepare: requests.prepare,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function load(db: Database.Interface["db"], resolveModel: Interface["resolveModel"]): Interface["load"] {
|
||||
return Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
const model = yield* resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
|
||||
return {
|
||||
session: selection.session,
|
||||
agent: selection.agent,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.entries.map((entry) => entry.message),
|
||||
tools: selection.tools,
|
||||
...(selection.system === undefined ? {} : { system: selection.system }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Variant IDs that minimize reasoning output, in preference order. */
|
||||
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { SessionResolve } from "./resolve.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
@@ -53,7 +52,6 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -88,14 +86,10 @@ export const layer = Layer.effect(
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const pinned = resolve.pinned(sessionID)
|
||||
const result = yield* (
|
||||
pinned
|
||||
? pinned.drain({ sessionID, force, continuation, promotable })
|
||||
: SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
@@ -107,14 +101,11 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
eligible: (sessionID) => resolve.status(sessionID) !== "owned-detached",
|
||||
started: (sessionID) => {
|
||||
resolve.pin(sessionID)
|
||||
return reportLifecycle(
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
)
|
||||
},
|
||||
),
|
||||
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
@@ -146,7 +137,7 @@ export const layer = Layer.effect(
|
||||
releaseOnCommit(sessionID),
|
||||
)
|
||||
}),
|
||||
).pipe(Effect.ensuring(resolve.settle(sessionID))),
|
||||
),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -169,12 +160,7 @@ export const layer = Layer.effect(
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
return interrupted
|
||||
}),
|
||||
resume: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
|
||||
if (resolve.status(sessionID) === "owned-detached") return yield* SessionResolve.unavailable(sessionID)
|
||||
yield* coordinator.run(sessionID)
|
||||
}),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
@@ -184,7 +170,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node, SessionResolve.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Job } from "../../job.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionExecution } from "../execution.js"
|
||||
import { SessionResolve } from "../resolve.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
|
||||
@@ -38,8 +37,6 @@ export interface Interface {
|
||||
* shutdown (which preserves the claim on purpose). The claim is never
|
||||
* cleared here: only a terminal event releases it, so a death anywhere in
|
||||
* the resume path leaves the same orphaned claim for the next boot.
|
||||
* Capability-owned Sessions stay pending even when reopened; they require
|
||||
* an explicit prompt or resume instead of automatic recovery.
|
||||
*/
|
||||
readonly resumeSuspendedSessions: Effect.Effect<void>
|
||||
}
|
||||
@@ -69,7 +66,6 @@ export const layer = (options?: Options) =>
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
@@ -77,8 +73,6 @@ export const layer = (options?: Options) =>
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
// Reopening capabilities does not opt a Session into automatic recovery.
|
||||
if (resolve.status(sessionID) !== "unowned") return undefined
|
||||
// Durable before the resume runs, so a crash inside the resumed turn is
|
||||
// counted by the next sweep and the budget cannot be dodged.
|
||||
const attempts = yield* store.countResume(sessionID)
|
||||
@@ -101,14 +95,6 @@ export const layer = (options?: Options) =>
|
||||
return true
|
||||
})
|
||||
|
||||
const eligibleJob = (recovery: Job.Recovery) => {
|
||||
if (recovery.kind === "shell") return resolve.status(recovery.sessionID) === "unowned"
|
||||
return (
|
||||
resolve.status(recovery.parentSessionID) === "unowned" &&
|
||||
resolve.status(recovery.childSessionID) === "unowned"
|
||||
)
|
||||
}
|
||||
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
@@ -157,7 +143,6 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
|
||||
if (result.status === "running") return
|
||||
if (!eligibleJob(recovery)) return
|
||||
const text =
|
||||
result.status === "completed"
|
||||
? (result.output ?? "Subagent completed without a text response.")
|
||||
@@ -187,9 +172,7 @@ export const layer = (options?: Options) =>
|
||||
return
|
||||
}
|
||||
if ((yield* execution.active).has(recovery.childSessionID)) return
|
||||
const prepared = yield* prepareResume(recovery.childSessionID)
|
||||
if (prepared === undefined) return
|
||||
if (!prepared) {
|
||||
if (!(yield* prepareResume(recovery.childSessionID))) {
|
||||
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
|
||||
return
|
||||
}
|
||||
@@ -231,20 +214,18 @@ export const layer = (options?: Options) =>
|
||||
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
|
||||
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
|
||||
const pending = yield* jobs.pendingBackground
|
||||
yield* store.releaseChildClaims([
|
||||
...(yield* resolve.ownedIDs),
|
||||
...pending.flatMap((background) =>
|
||||
yield* store.releaseChildClaims(
|
||||
pending.flatMap((background) =>
|
||||
background.status === "running" && background.recovery.kind === "subagent"
|
||||
? [background.recovery.childSessionID]
|
||||
: [],
|
||||
),
|
||||
])
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
pending,
|
||||
Effect.fnUntraced(function* (background) {
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
if (!eligibleJob(recovery)) return
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
@@ -256,10 +237,10 @@ export const layer = (options?: Options) =>
|
||||
const resumed = yield* execution.active
|
||||
yield* Effect.forEach(
|
||||
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
|
||||
Effect.fnUntraced(function* (sessionID) {
|
||||
if (!(yield* prepareResume(sessionID))) return
|
||||
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
}),
|
||||
(sessionID) =>
|
||||
execution
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
// Async observers consult this set at delivery; later completions wake parents normally.
|
||||
@@ -272,5 +253,5 @@ export const layer = (options?: Options) =>
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [SessionStore.node, SessionExecution.node, SessionResolve.node, Bus.node, Job.node, Session.node],
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
|
||||
})
|
||||
|
||||
@@ -63,6 +63,24 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
||||
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
/** Finds the last assistant even when a checkpoint has replaced it in model-visible history. */
|
||||
export const latestAssistant = Effect.fn("SessionHistory.latestAssistant")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.orDie)
|
||||
return message.type === "assistant" ? message : undefined
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
|
||||
@@ -134,23 +134,6 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
})
|
||||
|
||||
/** Reconciles pending or delivered work without preparing a new admission payload. */
|
||||
export const reconcile = Effect.fn("SessionInbox.reconcile")(function* (
|
||||
db: DatabaseService,
|
||||
request: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) {
|
||||
const existing = yield* find(db, request.id)
|
||||
if (existing !== undefined) {
|
||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||
return existing
|
||||
}
|
||||
return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery)
|
||||
})
|
||||
|
||||
export const admit = Effect.fn("SessionInbox.admit")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
@@ -160,8 +143,13 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
|
||||
readonly item: Item
|
||||
},
|
||||
) {
|
||||
const existing = yield* reconcile(db, { ...request, delivery: request.item.delivery })
|
||||
if (existing !== undefined) return existing
|
||||
const existing = yield* find(db, request.id)
|
||||
if (existing !== undefined) {
|
||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||
return existing
|
||||
}
|
||||
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery)
|
||||
if (promoted !== undefined) return promoted
|
||||
return yield* bus
|
||||
.publish(SessionEvent.InboxEnqueued, {
|
||||
inboxID: request.id,
|
||||
|
||||
@@ -103,7 +103,7 @@ const source = (entry: Info & { readonly removed: boolean }) =>
|
||||
},
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
@@ -60,7 +60,7 @@ interface PrepareInput {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for requests that carry no tools (title, compaction). */
|
||||
/** Omitted for requests that carry no tools, such as titles. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
@@ -70,7 +70,7 @@ interface PrepareInput {
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Requests that are not
|
||||
* part of the conversation (title, compaction) opt out: their transcripts
|
||||
* part of the conversation (such as titles) opt out: their transcripts
|
||||
* pass through unchanged.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
@@ -84,16 +84,14 @@ export const baseTranscript = (input: {
|
||||
readonly tools: Tool.Snapshot
|
||||
readonly initial: string
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly system?: string
|
||||
}) => {
|
||||
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
|
||||
return {
|
||||
providerMetadataKey,
|
||||
system: [
|
||||
input.system ??
|
||||
(input.agent.system
|
||||
? input.agent.system
|
||||
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name))),
|
||||
input.agent.system
|
||||
? input.agent.system
|
||||
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
|
||||
input.initial,
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
@@ -299,17 +297,17 @@ export const layer = Layer.effect(
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context: PluginHooks.Domains["session"]["context"] = {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
|
||||
const context =
|
||||
input.contextHooks === false
|
||||
? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions }
|
||||
: yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
@@ -335,8 +333,6 @@ export const layer = Layer.effect(
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: input.toolChoice,
|
||||
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
}),
|
||||
)
|
||||
const hasHttpHooks =
|
||||
@@ -362,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,
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
export * as SessionResolve from "./resolve.js"
|
||||
|
||||
import type { LLMClientService } from "@opencode-ai/ai"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { App } from "../app.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { llmClient, webSocketConstructor } from "../effect/app-node-platform.js"
|
||||
import { KV } from "../kv.js"
|
||||
import type { SessionCapabilities } from "./capabilities.js"
|
||||
import type { SessionRunner } from "./runner/index.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import type { Image } from "../image.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { SessionModelTransport } from "./model-transport.js"
|
||||
|
||||
const prefix = "session.capabilities/"
|
||||
|
||||
/**
|
||||
* Live operations, never an attempt snapshot. Title, request hooks, compaction,
|
||||
* media/skills, snapshots, output, and transport customization are deferred;
|
||||
* open supplies their internal defaults without directory discovery.
|
||||
*/
|
||||
interface Capabilities extends SessionContext.Interface {
|
||||
readonly image: Image.Interface
|
||||
readonly transport: SessionModelTransport.Interface
|
||||
}
|
||||
|
||||
type Status = "attached" | "owned-detached" | "unowned"
|
||||
type Resolved =
|
||||
| { readonly status: "attached"; readonly capabilities: Capabilities }
|
||||
| { readonly status: "owned-detached" | "unowned" }
|
||||
|
||||
type Opened = {
|
||||
readonly capabilities: Capabilities
|
||||
readonly runner: SessionRunner.Interface
|
||||
readonly scope: Scope.Closeable
|
||||
readonly onRetire: () => Effect.Effect<void>
|
||||
readonly done: Deferred.Deferred<void>
|
||||
current: boolean
|
||||
users: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly own: <A extends { readonly id: SessionSchema.ID }, R>(
|
||||
record: Effect.Effect<A, never, R>,
|
||||
) => Effect.Effect<A, never, R>
|
||||
readonly status: (id: SessionSchema.ID) => Status
|
||||
readonly ownedIDs: Effect.Effect<ReadonlyArray<SessionSchema.ID>>
|
||||
readonly attach: (
|
||||
id: SessionSchema.ID,
|
||||
input: SessionCapabilities.OpenInput,
|
||||
) => Effect.Effect<() => Effect.Effect<void>>
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, never, Scope.Scope>
|
||||
/** Called synchronously when the coordinator installs a busy period, before its first fiber yield. */
|
||||
readonly pin: (id: SessionSchema.ID) => void
|
||||
readonly pinned: (id: SessionSchema.ID) => SessionRunner.Interface | undefined
|
||||
readonly settle: (id: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly remove: (id: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionResolve") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const owned = new Set(
|
||||
(yield* kv.scanAll(prefix))
|
||||
.filter((entry) => entry.value === true)
|
||||
.map((entry) => SessionSchema.ID.make(entry.key.slice(prefix.length))),
|
||||
)
|
||||
const globals = yield* Effect.context<
|
||||
| Database.Service
|
||||
| Bus.Service
|
||||
| SessionStore.Service
|
||||
| LLMClientService
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| Socket.WebSocketConstructor
|
||||
>()
|
||||
const current = new Map<SessionSchema.ID, Opened>()
|
||||
const pinned = new Map<SessionSchema.ID, Opened>()
|
||||
const opened = new Map<Deferred.Deferred<void>, Opened>()
|
||||
// LayerMap invalidation cannot choose synchronously at coordinator start or
|
||||
// run a host hook after all generation-specific users settle. Keep explicit leases.
|
||||
const retire = (value: Opened) =>
|
||||
Effect.suspend(() => {
|
||||
if (value.current || value.users > 0 || !opened.delete(value.done)) return Effect.void
|
||||
return Scope.close(value.scope, Exit.void).pipe(
|
||||
Effect.andThen(value.onRetire()),
|
||||
Effect.onExit((exit) => Deferred.done(value.done, exit)),
|
||||
)
|
||||
})
|
||||
const release = (value: Opened) =>
|
||||
Effect.sync(() => {
|
||||
value.users--
|
||||
}).pipe(Effect.andThen(retire(value)))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => current.clear()).pipe(
|
||||
Effect.andThen(
|
||||
Effect.forEach(
|
||||
opened.values(),
|
||||
(value) => {
|
||||
value.current = false
|
||||
return retire(value)
|
||||
},
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
// Ownership is transitionally one-way: retirement never hands a Session back to discovery.
|
||||
own: (record) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* record
|
||||
if (!owned.has(session.id)) yield* kv.set(prefix + session.id, true)
|
||||
return session
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
// Publish the memory index only after the durable transaction commits.
|
||||
owned.add(session.id)
|
||||
return session
|
||||
}),
|
||||
),
|
||||
status: (id) => (current.has(id) ? "attached" : owned.has(id) ? "owned-detached" : "unowned"),
|
||||
ownedIDs: Effect.sync(() => Array.from(owned)),
|
||||
attach: Effect.fn("SessionResolve.attach")(function* (id, input) {
|
||||
const [
|
||||
{ Image },
|
||||
{ PluginHooks },
|
||||
{ PluginSupervisor },
|
||||
{ Snapshot },
|
||||
{ ToolOutput },
|
||||
{ SessionCompaction },
|
||||
{ SessionContext },
|
||||
{ InstructionEntry },
|
||||
{ SessionModelRequest },
|
||||
{ SessionModelTransport },
|
||||
{ SessionRunner },
|
||||
{ SessionRunnerLLM },
|
||||
{ SessionTitle },
|
||||
] = yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
import("../image.js"),
|
||||
import("../plugin/hooks.js"),
|
||||
import("../plugin/supervisor-service.js"),
|
||||
import("../snapshot.js"),
|
||||
import("../tool-output.js"),
|
||||
import("./compaction.js"),
|
||||
import("./context.js"),
|
||||
import("./instruction-entry.js"),
|
||||
import("./model-request.js"),
|
||||
import("./model-transport.js"),
|
||||
import("./runner/index.js"),
|
||||
import("./runner/llm.js"),
|
||||
import("./title.js"),
|
||||
]),
|
||||
)
|
||||
const scope = yield* Scope.make()
|
||||
const base = Layer.mergeAll(
|
||||
PluginHooks.layer,
|
||||
Image.layer,
|
||||
InstructionEntry.layer,
|
||||
SessionModelTransport.layer,
|
||||
ToolOutput.layer,
|
||||
Snapshot.noopLayer,
|
||||
SessionCompaction.layer,
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
).pipe(Layer.provide(Layer.succeedContext(globals)))
|
||||
const requests = SessionModelRequest.layer.pipe(Layer.provideMerge(base))
|
||||
const capabilities = SessionContext.values(input).pipe(Layer.provideMerge(requests))
|
||||
const runner = SessionRunnerLLM.layer.pipe(
|
||||
Layer.provideMerge(SessionTitle.layer.pipe(Layer.provideMerge(capabilities))),
|
||||
Layer.provide(Layer.succeedContext(globals)),
|
||||
)
|
||||
// Each open builds fresh local state over the SAME captured durable/global services.
|
||||
const services = yield* Layer.buildWithScope(Layer.fresh(runner), scope).pipe(
|
||||
Effect.onError(() => Scope.close(scope, Exit.void)),
|
||||
)
|
||||
const value: Opened = {
|
||||
capabilities: {
|
||||
...Context.get(services, SessionContext.Service),
|
||||
image: Context.get(services, Image.Service),
|
||||
transport: Context.get(services, SessionModelTransport.Service),
|
||||
},
|
||||
runner: Context.get(services, SessionRunner.Service),
|
||||
scope,
|
||||
onRetire: input.retire ?? (() => Effect.void),
|
||||
done: Deferred.makeUnsafe<void>(),
|
||||
current: true,
|
||||
users: 0,
|
||||
}
|
||||
const previous = current.get(id)
|
||||
current.set(id, value)
|
||||
opened.set(value.done, value)
|
||||
if (previous) {
|
||||
previous.current = false
|
||||
yield* retire(previous)
|
||||
}
|
||||
// Closed handles retain only completion, not retired capability functions or layers.
|
||||
const done = value.done
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const value = opened.get(done)
|
||||
if (!value) return
|
||||
if (current.get(id) === value) current.delete(id)
|
||||
value.current = false
|
||||
yield* retire(value)
|
||||
}),
|
||||
)
|
||||
yield* Deferred.await(done)
|
||||
})
|
||||
}),
|
||||
resolve: (session) =>
|
||||
Effect.gen(function* () {
|
||||
const value = current.get(session.id)
|
||||
if (!value) return { status: owned.has(session.id) ? ("owned-detached" as const) : ("unowned" as const) }
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
value.users++
|
||||
}),
|
||||
() => release(value),
|
||||
)
|
||||
return { status: "attached" as const, capabilities: value.capabilities }
|
||||
}),
|
||||
pin: (id) => {
|
||||
const value = current.get(id)
|
||||
if (!value) return
|
||||
value.users++
|
||||
pinned.set(id, value)
|
||||
},
|
||||
pinned: (id) => pinned.get(id)?.runner,
|
||||
settle: (id) =>
|
||||
Effect.suspend(() => {
|
||||
const value = pinned.get(id)
|
||||
if (!value) return Effect.void
|
||||
pinned.delete(id)
|
||||
return release(value)
|
||||
}),
|
||||
remove: (id) =>
|
||||
Effect.gen(function* () {
|
||||
const value = current.get(id)
|
||||
current.delete(id)
|
||||
if (value) {
|
||||
value.current = false
|
||||
yield* retire(value)
|
||||
}
|
||||
if (!owned.has(id)) return
|
||||
yield* kv.remove(prefix + id)
|
||||
owned.delete(id)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
KV.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionStore.node,
|
||||
llmClient,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
webSocketConstructor,
|
||||
// Request preparation reads this Reference from the captured globals, not its fallback metadata.
|
||||
App.node,
|
||||
],
|
||||
})
|
||||
|
||||
/** TODO: replace this defect with the typed error in the capability-gated operations phase. */
|
||||
export const unavailable = (sessionID: SessionSchema.ID) =>
|
||||
Effect.die(new Error(`Session must be reopened with capabilities: ${sessionID}`))
|
||||
@@ -52,8 +52,6 @@ type Execution<E, Reason> = {
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
|
||||
/** Controls new busy periods, including late successors; existing execution may finish. */
|
||||
readonly eligible?: (key: Key) => boolean
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -109,7 +107,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake && (options.eligible?.(key) ?? true)) start(key, false, execution.pendingWake)
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
@@ -123,13 +121,11 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
}
|
||||
if (options.eligible?.(key) === false) return Effect.interrupt
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, scope: Promotable = "input") =>
|
||||
Effect.sync(() => {
|
||||
if (options.eligible?.(key) === false) return
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InstructionState } from "../instruction-state.js"
|
||||
import { SessionCompaction } from "../compaction.js"
|
||||
import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
@@ -30,7 +31,7 @@ import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -139,11 +140,12 @@ export const layer = Layer.effect(
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* store.context(sessionID)
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: context.resolveModel,
|
||||
prepare: context.prepare,
|
||||
messages: yield* store.context(sessionID),
|
||||
messages,
|
||||
context: loadCompactionContext(sessionID, messages),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
})
|
||||
@@ -204,6 +206,20 @@ export const layer = Layer.effect(
|
||||
return selected
|
||||
})
|
||||
|
||||
const loadCompactionContext = Effect.fn("SessionRunner.loadCompactionContext")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
loaded?: SessionContext.Loaded,
|
||||
) {
|
||||
const last =
|
||||
messages.findLast((message) => message.type === "assistant") ??
|
||||
(yield* SessionHistory.latestAssistant(db, sessionID))
|
||||
if (loaded && (!last || last.agent === loaded.agent.id)) return loaded
|
||||
const selected = yield* context.select(sessionID, last?.agent)
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
|
||||
return yield* context.load(selected)
|
||||
})
|
||||
|
||||
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
|
||||
const sessionID = first.session.id
|
||||
@@ -221,6 +237,7 @@ export const layer = Layer.effect(
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
prepare: context.prepare,
|
||||
context: loadCompactionContext(sessionID, loaded.messages, loaded),
|
||||
}
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
@@ -235,7 +252,6 @@ export const layer = Layer.effect(
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
system: loaded.system,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface Interface {
|
||||
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/**
|
||||
* Clears orphaned child claims except children owned by recoverable
|
||||
* background subagent jobs or capability-owned Sessions.
|
||||
* background subagent jobs.
|
||||
*/
|
||||
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
|
||||
/**
|
||||
|
||||
@@ -34,10 +34,7 @@ export interface Interface {
|
||||
sessionID: Session.ID
|
||||
sanitize?: boolean
|
||||
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
|
||||
readonly import: (input: {
|
||||
data: Data
|
||||
location: Location.Ref
|
||||
}) => Effect.Effect<Session.Info, ImportConflictError | Session.NotFoundError>
|
||||
readonly import: (input: { data: Data; location: Location.Ref }) => Effect.Effect<Session.Info, ImportConflictError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
|
||||
@@ -69,7 +66,6 @@ const layer = Layer.effect(
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (recorded) return yield* new ImportConflictError({ sessionID })
|
||||
if (input.data.info.parentID) yield* sessions.get(input.data.info.parentID)
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
const messages = input.data.messages.filter(isSettled).map((message, index) => {
|
||||
@@ -89,7 +85,6 @@ const layer = Layer.effect(
|
||||
SessionEvent.Created,
|
||||
{
|
||||
sessionID,
|
||||
parentID: input.data.info.parentID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
export * as Source from "./source.js"
|
||||
|
||||
import { Effect, Ref } from "effect"
|
||||
import type { SessionSchema } from "./session/schema.js"
|
||||
|
||||
export interface Interface<T, E = never> {
|
||||
/** Return replacement values when changing state; consumers may reuse derived results by reference identity. */
|
||||
readonly get: (session: SessionSchema.Info) => Effect.Effect<T, E>
|
||||
}
|
||||
|
||||
export type Value<T, E = never> = T | Interface<T, E>
|
||||
|
||||
export interface Mutable<T> extends Interface<T> {
|
||||
readonly set: (value: T) => Effect.Effect<void>
|
||||
readonly update: (update: (value: T) => T) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function mutable<T>(initial: T): Mutable<T> {
|
||||
const ref = Ref.makeUnsafe(initial)
|
||||
return {
|
||||
get: () => Ref.get(ref),
|
||||
set: (value) => Ref.set(ref, value),
|
||||
update: (update) => Ref.update(ref, update),
|
||||
}
|
||||
}
|
||||
|
||||
export function constant<T>(value: T): Interface<T> {
|
||||
return { get: () => Effect.succeed(value) }
|
||||
}
|
||||
|
||||
export function from<T, E = never>(value: Value<T, E>): Interface<T, E> {
|
||||
return isSource(value) ? value : constant(value)
|
||||
}
|
||||
|
||||
function isSource<T, E>(value: Value<T, E>): value is Interface<T, E> {
|
||||
return typeof value === "object" && value !== null && "get" in value && typeof value.get === "function"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
|
||||
)
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
+187
-200
@@ -1,6 +1,6 @@
|
||||
export * as Tool from "./tool.js"
|
||||
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
|
||||
export type { Context, Info, Metadata, Options, Result } from "@opencode-ai/schema/tool"
|
||||
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
|
||||
|
||||
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
@@ -49,219 +49,206 @@ 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>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const image = yield* Image.Service
|
||||
|
||||
type NormalizedItem = Tool.Content | "decode" | "size"
|
||||
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
|
||||
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
||||
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
if (base64 === undefined) return Effect.succeed(item)
|
||||
const resource = item.name ?? `${item.mime} tool output`
|
||||
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
|
||||
Effect.map((result) => ({
|
||||
...item,
|
||||
uri: `data:${result.mime};base64,${result.content}`,
|
||||
mime: result.mime,
|
||||
})),
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
||||
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
||||
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
||||
type NormalizedItem = Tool.Content | "decode" | "size"
|
||||
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
|
||||
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
||||
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
if (base64 === undefined) return Effect.succeed(item)
|
||||
const resource = item.name ?? `${item.mime} tool output`
|
||||
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
|
||||
Effect.map((result) => ({
|
||||
...item,
|
||||
uri: `data:${result.mime};base64,${result.content}`,
|
||||
mime: result.mime,
|
||||
})),
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
||||
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
||||
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
||||
)
|
||||
})
|
||||
const note = (reason: "decode" | "size", text: string) => {
|
||||
const count = normalized.filter((item) => item === reason).length
|
||||
if (count === 0) return []
|
||||
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
|
||||
}
|
||||
return [
|
||||
...normalized.filter((item) => typeof item !== "string"),
|
||||
...note("decode", "could not be decoded."),
|
||||
...note("size", "could not be resized below the image size limit."),
|
||||
]
|
||||
})
|
||||
|
||||
const executeTool = Effect.fn("Tool.execute")(function* (
|
||||
tool: Tool.Info,
|
||||
name: string,
|
||||
input: unknown,
|
||||
context: Tool.Context,
|
||||
) {
|
||||
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 })),
|
||||
)
|
||||
})
|
||||
const note = (reason: "decode" | "size", text: string) => {
|
||||
const count = normalized.filter((item) => item === reason).length
|
||||
if (count === 0) return []
|
||||
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
|
||||
}
|
||||
return [
|
||||
...normalized.filter((item) => typeof item !== "string"),
|
||||
...note("decode", "could not be decoded."),
|
||||
...note("size", "could not be resized below the image size limit."),
|
||||
]
|
||||
})
|
||||
|
||||
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(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })),
|
||||
)
|
||||
const base = {
|
||||
tool: name,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
input,
|
||||
}
|
||||
if ("failure" in execution) {
|
||||
const base = {
|
||||
tool: name,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
input: beforeEvent.input,
|
||||
}
|
||||
if ("failure" in execution) {
|
||||
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
||||
...base,
|
||||
status: "error",
|
||||
error: execution.failure,
|
||||
}
|
||||
yield* hooks.trigger("tool", "execute.after", afterEvent)
|
||||
return yield* afterEvent.error
|
||||
}
|
||||
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
||||
...base,
|
||||
status: "error",
|
||||
error: execution.failure,
|
||||
status: "completed",
|
||||
result: {
|
||||
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
||||
content: execution.value.content,
|
||||
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("tool", "execute.after", afterEvent)
|
||||
return yield* afterEvent.error
|
||||
}
|
||||
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
||||
...base,
|
||||
status: "completed",
|
||||
result: {
|
||||
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
||||
content: execution.value.content,
|
||||
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("tool", "execute.after", afterEvent)
|
||||
const afterContent = yield* normalizeImages(normalizeContent(afterEvent.result.content, afterEvent.result.output))
|
||||
return {
|
||||
...(afterEvent.result.output === undefined ? {} : { output: afterEvent.result.output }),
|
||||
content: afterContent,
|
||||
...(afterEvent.result.metadata === undefined ? {} : { metadata: afterEvent.result.metadata }),
|
||||
}
|
||||
})
|
||||
const afterContent = yield* normalizeImages(normalizeContent(afterEvent.result.content, afterEvent.result.output))
|
||||
return {
|
||||
...(afterEvent.result.output === undefined ? {} : { output: afterEvent.result.output }),
|
||||
content: afterContent,
|
||||
...(afterEvent.result.metadata === undefined ? {} : { metadata: afterEvent.result.metadata }),
|
||||
}
|
||||
})
|
||||
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
tools: new Map(),
|
||||
errors: [],
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.tools.values()),
|
||||
get: (id) => draft.tools.get(id),
|
||||
add: (tool) => {
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
const id = effectiveName(tool)
|
||||
draft.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.tools.get(id)
|
||||
if (!current) return
|
||||
const tool = { ...current, options: current.options && { ...current.options } }
|
||||
update(tool)
|
||||
tool.name = current.name
|
||||
tool.id = id
|
||||
if (tool.options?.namespace !== current.options?.namespace)
|
||||
tool.options = { ...tool.options, namespace: current.options?.namespace }
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
draft.tools.set(id, tool)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
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)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: Effect.fnUntraced(function* (input: Parameters<Snapshot["execute"]>[0]) {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
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}` })
|
||||
}),
|
||||
}
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
tools: new Map(),
|
||||
errors: [],
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.tools.values()),
|
||||
get: (id) => draft.tools.get(id),
|
||||
add: (tool) => {
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
const id = effectiveName(tool)
|
||||
draft.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.tools.get(id)
|
||||
if (!current) return
|
||||
const tool = { ...current, options: current.options && { ...current.options } }
|
||||
update(tool)
|
||||
tool.name = current.name
|
||||
tool.id = id
|
||||
if (tool.options?.namespace !== current.options?.namespace)
|
||||
tool.options = { ...tool.options, namespace: current.options?.namespace }
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
draft.tools.set(id, tool)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
),
|
||||
})
|
||||
|
||||
const layer = Layer.effect(Service, make)
|
||||
|
||||
export const snapshot = Effect.fn("Tool.snapshot")(function* (
|
||||
values: readonly Tool.Info[],
|
||||
permissions?: Permission.Ruleset,
|
||||
) {
|
||||
const tools = yield* make
|
||||
yield* tools.transform((draft) => values.forEach((tool) => draft.add(tool)))
|
||||
return yield* tools.snapshot(permissions)
|
||||
}, Effect.scoped)
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
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) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
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,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
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}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const whollyDisabled = (action: string, rules: Permission.Ruleset) => {
|
||||
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
|
||||
import { $ZodType, toJSONSchema } from "zod/v4/core"
|
||||
import { Permission } from "../permission.js"
|
||||
|
||||
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
|
||||
|
||||
@@ -28,14 +27,19 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* decodeInput(tool, input)
|
||||
// Normalize foreign typed failures so downstream Tool.Error handlers settle the call.
|
||||
// Host declines enter Permission.assert's defect tunnel; existing defects and interrupts pass through.
|
||||
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
|
||||
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
|
||||
// downstream and leave its call permanently unsettled, so the declared contract is
|
||||
// enforced here at the untrusted boundary. Declines tunnel through as defects and
|
||||
// interrupts are not errors; neither is touched.
|
||||
const result = yield* tool.execute(decoded, context).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof Permission.DeclinedError) return Effect.die(error)
|
||||
if (error instanceof Tool.Error) return Effect.fail(error)
|
||||
return Effect.fail(new Tool.Error({ message: error instanceof Error ? error.message : String(error) }))
|
||||
}),
|
||||
Effect.mapError((error: unknown) =>
|
||||
error instanceof Tool.Error
|
||||
? error
|
||||
: new Tool.Error({
|
||||
message: error instanceof globalThis.Error ? error.message : String(error),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (tool.output === undefined) {
|
||||
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
|
||||
|
||||
@@ -78,25 +78,33 @@ describe("ConfigCompactionPlugin.Plugin", () => {
|
||||
const started = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Started)
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
const messages: SessionMessage.Info[] = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
],
|
||||
context: Effect.succeed({
|
||||
session,
|
||||
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
|
||||
model: resolved,
|
||||
initial: "",
|
||||
messages,
|
||||
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
|
||||
}),
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_compaction_manual"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SessionSchema } from "../../src/session/schema"
|
||||
import { Tool } from "../../src/tool"
|
||||
|
||||
export const session = Schema.decodeUnknownSync(SessionSchema.Info)({
|
||||
id: "ses_capabilities",
|
||||
projectID: "global",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
location: { directory: "/project" },
|
||||
})
|
||||
|
||||
export const echo = (execute: (text: string) => Effect.Effect<string>, name = "echo"): Tool.Info => ({
|
||||
name,
|
||||
description: `Echo text with ${name}`,
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: (input) => execute(input.text).pipe(Effect.map((output) => ({ output }))),
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
|
||||
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
|
||||
export const promptLocationLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
) as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
@@ -51,8 +51,6 @@ describe("KV", () => {
|
||||
entries: [{ key: `${prefix}éclair`, value: { order: 3 } }],
|
||||
})
|
||||
expect(yield* kv.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
|
||||
expect(yield* kv.scanAll(`${prefix}%_`)).toEqual([])
|
||||
expect(yield* kv.scanAll(prefix)).toEqual([...first.entries, { key: `${prefix}éclair`, value: { order: 3 } }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -78,15 +76,6 @@ describe("KV", () => {
|
||||
expect((yield* kv.scan({ prefix, limit: 0 })).entries).toHaveLength(1)
|
||||
expect((yield* kv.scan({ prefix, limit: -10 })).entries).toHaveLength(1)
|
||||
expect((yield* kv.scan({ prefix, limit: Number.NaN })).entries).toHaveLength(100)
|
||||
|
||||
const all = kv.scanAll(prefix)
|
||||
const entries = Array.from({ length: 1001 }, (_, index) => {
|
||||
const key = `${prefix}${index.toString().padStart(4, "0")}`
|
||||
return { key, value: key }
|
||||
})
|
||||
expect(yield* all).toEqual(entries)
|
||||
yield* kv.remove(`${prefix}0000`)
|
||||
expect(yield* all).toEqual(entries.slice(1))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Permission } from "../src/permission"
|
||||
import { Permissions } from "../src/permissions"
|
||||
import { SessionSchema } from "../src/session/schema"
|
||||
import { Source } from "../src/source"
|
||||
import { session } from "./fixture/capabilities"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
it.effect("allowAll permits requests and exposes an allow-all visibility rule", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* Permissions.allowAll.visibility.get(session)).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
])
|
||||
yield* Permissions.allowAll.ask(session, { action: "write", resources: ["src/file.ts", "other/file.ts"] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rules fail immediately for unmatched, ask, and denied resources", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = { action: "read", resources: ["src/file.ts"] }
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
[],
|
||||
[{ action: "read", resource: "*", effect: "ask" }],
|
||||
[{ action: "read", resource: "*", effect: "deny" }],
|
||||
] satisfies Permission.Ruleset[],
|
||||
(rules) =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = Permissions.rules(rules)
|
||||
expect(yield* permissions.visibility.get(session)).toBe(rules)
|
||||
expect(yield* permissions.ask(session, request).pipe(Effect.flip)).toEqual(
|
||||
new Permission.BlockedError({ rules, permission: request.action, resources: request.resources }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rules use wildcard precedence and require every resource to be allowed", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = Permissions.rules([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "re*", resource: "src/*", effect: "allow" },
|
||||
{ action: "read", resource: "src/private/*", effect: "ask" },
|
||||
{ action: "read", resource: "src/private/public.ts", effect: "allow" },
|
||||
])
|
||||
yield* permissions.ask(session, { action: "read", resources: ["src/file.ts", "src/private/public.ts"] })
|
||||
expect(
|
||||
yield* permissions
|
||||
.ask(session, { action: "read", resources: ["src/file.ts", "src/private/file.ts"] })
|
||||
.pipe(Effect.flip),
|
||||
).toBeInstanceOf(Permission.BlockedError)
|
||||
expect(
|
||||
yield* permissions.ask(session, { action: "write", resources: ["src/file.ts"] }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Permission.BlockedError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocked requests report action-relevant rules without inventing a reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const rules: Permission.Ruleset = [
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "write", resource: "*", effect: "allow" },
|
||||
{ action: "re*", resource: "other/*", effect: "allow" },
|
||||
{ action: "read", resource: "src/*", effect: "deny" },
|
||||
]
|
||||
const request = { action: "read", resources: ["src/file.ts"] }
|
||||
const relevant = [rules[0], rules[2], rules[3]]
|
||||
expect(Permission.relevant(request, rules)).toEqual(relevant)
|
||||
const error = yield* Permissions.rules(rules).ask(session, request).pipe(Effect.flip)
|
||||
expect(error).toEqual(
|
||||
new Permission.BlockedError({ rules: relevant, permission: request.action, resources: request.resources }),
|
||||
)
|
||||
if (error._tag !== "Permission.BlockedError") return yield* Effect.die("Expected blocked permission")
|
||||
expect(error.reason).toBeUndefined()
|
||||
expect(error.message).toBe("Permission denied: read")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rules sample mutable and session-dependent sources for visibility and each request", () =>
|
||||
Effect.gen(function* () {
|
||||
const source = Source.mutable<Permission.Ruleset>([])
|
||||
const permissions = Permissions.rules(source)
|
||||
const request = { action: "read", resources: ["src/file.ts"] }
|
||||
expect(permissions.visibility).toBe(source)
|
||||
expect(yield* permissions.ask(session, request).pipe(Effect.flip)).toBeInstanceOf(Permission.BlockedError)
|
||||
yield* source.set([{ action: "read", resource: "*", effect: "allow" }])
|
||||
yield* permissions.ask(session, request)
|
||||
yield* source.update((rules) => [...rules, { action: "read", resource: "src/*", effect: "deny" }])
|
||||
expect(yield* permissions.ask(session, request).pipe(Effect.flip)).toBeInstanceOf(Permission.BlockedError)
|
||||
|
||||
const scoped = Permissions.rules({
|
||||
get: (current) =>
|
||||
Effect.succeed([{ action: "read", resource: "*", effect: current.id === session.id ? "allow" : "deny" }]),
|
||||
})
|
||||
yield* scoped.ask(session, request)
|
||||
const other = { ...session, id: SessionSchema.ID.make("ses_other") }
|
||||
expect(yield* scoped.visibility.get(other)).toEqual([{ action: "read", resource: "*", effect: "deny" }])
|
||||
expect(yield* scoped.ask(other, request).pipe(Effect.flip)).toBeInstanceOf(Permission.BlockedError)
|
||||
}),
|
||||
)
|
||||
@@ -35,8 +35,6 @@ describe("PluginHooks", () => {
|
||||
system: [SystemPart.make("first")],
|
||||
messages: [Message.user("original")],
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("session", "context", event)).toBe(event)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { SessionDomain } from "@opencode-ai/plugin/promise/session"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function effectPrompt(context: Context) {
|
||||
context.session.hook("prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.prompt.files ??= []
|
||||
event.prompt.files.push({ uri: "file:///policy.md" })
|
||||
event.delivery = "queue"
|
||||
// @ts-expect-error Admission identity cannot be rewritten.
|
||||
event.sessionID = Session.ID.make("ses_other")
|
||||
}),
|
||||
)
|
||||
// @ts-expect-error Prompt admission has no resolved model to filter by provider.
|
||||
context.session.hook("prompt", () => Effect.void, { providerID: "openai" })
|
||||
context.session.hook("context", () => Effect.void, { providerID: "openai" })
|
||||
}
|
||||
|
||||
export function promisePrompt(session: SessionDomain) {
|
||||
session.hook("prompt", (event) => {
|
||||
event.prompt.text = "Prepared"
|
||||
event.metadata = { source: "plugin" }
|
||||
// @ts-expect-error Admission identity cannot be rewritten.
|
||||
event.messageID = SessionMessage.ID.make("msg_other")
|
||||
})
|
||||
// @ts-expect-error Prompt admission has no resolved model to filter by provider.
|
||||
session.hook("prompt", () => {}, { providerID: "openai" })
|
||||
session.hook("context", () => {}, { providerID: "openai" })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -124,8 +124,6 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
|
||||
system: [],
|
||||
messages,
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
})
|
||||
|
||||
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
|
||||
|
||||
@@ -381,8 +381,6 @@ describe("fromPromise", () => {
|
||||
await ctx.session.hook("context", (event) => {
|
||||
event.system.push(SystemPart.make("Promise hook"))
|
||||
delete event.tools.echo
|
||||
event.generation.temperature = 0.4
|
||||
event.providerOptions.reasoningEffort = "medium"
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -394,16 +392,12 @@ describe("fromPromise", () => {
|
||||
system: [SystemPart.make("Initial")],
|
||||
messages: [Message.user("Hello")],
|
||||
tools: { echo: { description: "Echo", input: { type: "object" } } },
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
|
||||
expect(event.tools).toEqual({})
|
||||
expect(event.generation).toEqual({ temperature: 0.4 })
|
||||
expect(event.providerOptions).toEqual({ reasoningEffort: "medium" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -617,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"
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -635,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!",
|
||||
|
||||
@@ -32,8 +32,6 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
system: [SystemPart.make(system)],
|
||||
messages: [],
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
})
|
||||
|
||||
describe("SystemPromptPlugin", () => {
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { SessionResolve } from "@opencode-ai/core/session/resolve"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, RcMap } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
KV.node,
|
||||
Session.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
SessionRestart.node,
|
||||
SessionResolve.node,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
describe("capability-owned Session recovery", () => {
|
||||
for (const claimed of [false, true]) {
|
||||
it.effect(`leaves an owned Session ${claimed ? "with an exhausted claim" : "without a claim"} inert`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const restart = yield* SessionRestart.Service
|
||||
const sessionID = Session.ID.make("ses_capability_recovery")
|
||||
yield* seedSession(sessionID, claimed ? { time_suspended: 123, resume_attempts: 10 } : {})
|
||||
yield* resolve.own(Effect.succeed({ id: sessionID }))
|
||||
const before = yield* accounting(database)
|
||||
|
||||
expect(resolve.status(sessionID)).toBe("owned-detached")
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect(yield* accounting(database)).toEqual(before)
|
||||
expect(resolve.status(sessionID)).toBe("owned-detached")
|
||||
yield* assertInert()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("keeps a completed Job pending for an unclaimed owned parent without waking it", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const jobs = yield* Job.Service
|
||||
const restart = yield* SessionRestart.Service
|
||||
const parent = Session.ID.make("ses_capability_completed_parent")
|
||||
const child = Session.ID.make("ses_capability_completed_child")
|
||||
yield* seedSession(parent)
|
||||
yield* seedSession(child, { parent_id: parent })
|
||||
yield* resolve.own(Effect.succeed({ id: parent }))
|
||||
yield* seedJob(
|
||||
{ kind: "subagent", parentSessionID: parent, childSessionID: child, agent: "explore", description: "Inspect" },
|
||||
"completed",
|
||||
)
|
||||
const pending = yield* jobs.pendingBackground
|
||||
const before = yield* accounting(database)
|
||||
|
||||
expect(pending).toMatchObject([{ status: "completed", output: "Recovered result" }])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect(yield* jobs.pendingBackground).toEqual(pending)
|
||||
expect(yield* jobs.get("recovery-job")).toBeUndefined()
|
||||
expect(yield* accounting(database)).toEqual(before)
|
||||
yield* assertInert()
|
||||
}),
|
||||
)
|
||||
|
||||
for (const owner of ["parent", "child"] as const) {
|
||||
it.effect(`preserves child claims with a capability-owned ${owner} and pending running Job`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const jobs = yield* Job.Service
|
||||
const restart = yield* SessionRestart.Service
|
||||
const parent = Session.ID.make("ses_capability_running_parent")
|
||||
const child = Session.ID.make("ses_capability_running_child")
|
||||
const orphan = Session.ID.make("ses_capability_orphan_child")
|
||||
yield* seedSession(parent, owner === "parent" ? { time_suspended: 789, resume_attempts: 10 } : {})
|
||||
yield* seedSession(child, { parent_id: parent, time_suspended: 123, resume_attempts: 10 })
|
||||
yield* seedSession(orphan, { parent_id: parent, time_suspended: 456, resume_attempts: 1 })
|
||||
yield* resolve.own(Effect.succeed({ id: owner === "parent" ? parent : child }))
|
||||
yield* resolve.own(Effect.succeed({ id: orphan }))
|
||||
yield* seedJob(
|
||||
{
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Inspect",
|
||||
},
|
||||
"running",
|
||||
)
|
||||
const pending = yield* jobs.pendingBackground
|
||||
const before = yield* accounting(database)
|
||||
|
||||
expect(pending).toMatchObject([{ status: "running" }])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect(yield* jobs.pendingBackground).toEqual(pending)
|
||||
expect(yield* jobs.get("recovery-job")).toBeUndefined()
|
||||
expect(yield* accounting(database)).toEqual(before)
|
||||
yield* assertInert()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const status of ["running", "completed"] as const) {
|
||||
it.effect(`keeps a ${status} shell Job pending for an owned Session`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const jobs = yield* Job.Service
|
||||
const restart = yield* SessionRestart.Service
|
||||
const sessionID = Session.ID.make("ses_capability_shell")
|
||||
yield* seedSession(sessionID)
|
||||
yield* resolve.own(Effect.succeed({ id: sessionID }))
|
||||
yield* seedJob({ kind: "shell", sessionID, shellID: "sh_recovery", command: "echo result" }, status)
|
||||
const pending = yield* jobs.pendingBackground
|
||||
const before = yield* accounting(database)
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect(yield* jobs.pendingBackground).toEqual(pending)
|
||||
expect(yield* accounting(database)).toEqual(before)
|
||||
yield* assertInert()
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function seedSession(
|
||||
sessionID: Session.ID,
|
||||
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: sessionID,
|
||||
directory: "/project",
|
||||
title: sessionID,
|
||||
version: "test",
|
||||
...values,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
function seedJob(recovery: Job.Recovery, status: "running" | "completed") {
|
||||
// A previous process-local Job registry leaves only its durable record behind.
|
||||
return Effect.gen(function* () {
|
||||
const jobs = yield* Job.make
|
||||
yield* jobs.start({
|
||||
id: "recovery-job",
|
||||
type: recovery.kind,
|
||||
recovery,
|
||||
run: status === "running" ? Effect.never : Effect.succeed("Recovered result"),
|
||||
})
|
||||
yield* jobs.background("recovery-job")
|
||||
if (status === "completed") yield* jobs.wait({ id: "recovery-job" })
|
||||
}).pipe(Effect.scoped)
|
||||
}
|
||||
|
||||
function accounting(database: Database.Service["Service"]) {
|
||||
return database.db
|
||||
.select({
|
||||
id: SessionTable.id,
|
||||
claimed: SessionTable.time_suspended,
|
||||
attempts: SessionTable.resume_attempts,
|
||||
updated: SessionTable.time_updated,
|
||||
})
|
||||
.from(SessionTable)
|
||||
.orderBy(SessionTable.id)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function assertInert() {
|
||||
return Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
expect(yield* execution.active).toEqual(new Set())
|
||||
expect(yield* database.db.select().from(EventTable).all().pipe(Effect.orDie)).toEqual([])
|
||||
expect(yield* database.db.select().from(SessionMessageTable).all().pipe(Effect.orDie)).toEqual([])
|
||||
expect(yield* database.db.select().from(SessionInboxTable).all().pipe(Effect.orDie)).toEqual([])
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, RcMap, Scope } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "../src/effect/app-node-platform"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Instructions } from "../src/instructions/index"
|
||||
import { KV } from "../src/kv"
|
||||
import { Session } from "../src/session"
|
||||
import { InstructionState } from "../src/session/instruction-state"
|
||||
import { SessionResolve } from "../src/session/resolve"
|
||||
import { SessionRunnerModel } from "../src/session/runner/model"
|
||||
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "../src/session/sql"
|
||||
import { SessionStore } from "../src/session/store"
|
||||
import { Source } from "../src/source"
|
||||
import { SessionRestart } from "../src/session/execution/restart"
|
||||
import { Location } from "../src/location"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { InstructionEntry } from "../src/session/instruction-entry"
|
||||
import { Tool } from "../src/tool"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import path from "path"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { Skill } from "../src/skill"
|
||||
import { Permissions } from "../src/permissions"
|
||||
import { Permission } from "../src/permission"
|
||||
import { SessionMessage } from "../src/session/message"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
import { echo } from "./fixture/capabilities"
|
||||
import { App } from "../src/app"
|
||||
import { SessionExecution } from "../src/session/execution"
|
||||
|
||||
const scripted = TestLLM.layer()
|
||||
const application = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
LocationServiceMap.node,
|
||||
SessionResolve.node,
|
||||
SessionStore.node,
|
||||
SessionRestart.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
KV.node,
|
||||
InstructionEntry.node,
|
||||
PluginHooks.node,
|
||||
]),
|
||||
[
|
||||
[App.node, App.configured({ name: "fixture-host" })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LayerNodePlatform.llmClient, TestLLM.clientLayer.pipe(Layer.provide(scripted))],
|
||||
],
|
||||
).pipe(Layer.provideMerge(scripted))
|
||||
const it = testEffect(application)
|
||||
const isolated = testEffect(scripted)
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "fixture-model", provider: "fixture", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
},
|
||||
)
|
||||
|
||||
describe("Session capabilities", () => {
|
||||
it.live("closing a replacement during retirement leaves late admitted work parked", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const retiring = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
|
||||
const gate = yield* llm.gate
|
||||
const session = yield* sessions.open({
|
||||
model,
|
||||
retire: () => Deferred.succeed(retiring, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
})
|
||||
yield* llm.push(TestLLM.text("first done", "late_close_reply"))
|
||||
const running = yield* session.prompt({ text: "First busy period." }).pipe(Effect.forkScoped)
|
||||
yield* llm.wait(1)
|
||||
const replacement = yield* sessions.open({ id: session.id, model })
|
||||
yield* gate.release
|
||||
yield* Deferred.await(retiring)
|
||||
yield* sessions.prompt({ sessionID: session.id, text: "Late work." })
|
||||
yield* replacement.close()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(running)
|
||||
yield* sessions.wait(session.id)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
expect(yield* sessions.inbox(session.id)).toHaveLength(1)
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
const reopened = yield* sessions.open({ id: session.id, model })
|
||||
yield* llm.push(TestLLM.text("late work drained", "late_reopened_reply"))
|
||||
// An advisory wake after reopen delivers the already-admitted item, without a new prompt.
|
||||
const execution = yield* SessionExecution.Service
|
||||
yield* execution.wake(reopened.id)
|
||||
yield* sessions.wait(reopened.id)
|
||||
expect(yield* sessions.inbox(reopened.id)).toHaveLength(0)
|
||||
expect(llm.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(llm.requests[1].messages)).toContain("Late work.")
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("failed durable ownership writes do not poison the memory index", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const id = Session.ID.make("ses_rejected_marker")
|
||||
yield* db
|
||||
.run("CREATE TRIGGER reject_marker BEFORE INSERT ON kv BEGIN SELECT RAISE(ABORT, 'marker rejected'); END")
|
||||
.pipe(Effect.orDie)
|
||||
const exit = yield* sessions
|
||||
.open({ id, model })
|
||||
.pipe(Effect.exit, Effect.ensuring(db.run("DROP TRIGGER reject_marker").pipe(Effect.orDie)))
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(yield* store.get(id)).toBeUndefined()
|
||||
expect(resolve.status(id)).toBe("unowned")
|
||||
const session = yield* sessions.open({ id, model })
|
||||
expect(resolve.status(id)).toBe("attached")
|
||||
yield* session.close()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("pins at coordinator start before a close can retire the new busy period", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const llm = yield* TestLLM.Service
|
||||
const session = yield* sessions.open({ model })
|
||||
yield* session.prompt({ text: "Begin before close.", resume: false })
|
||||
yield* llm.push(TestLLM.text("settled", "start_close_reply"))
|
||||
const running = yield* execution.resume(session.id).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
expect((yield* execution.active).has(session.id)).toBe(true)
|
||||
const closing = yield* session.close().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
expect(resolve.status(session.id)).toBe("owned-detached")
|
||||
yield* Fiber.join(running)
|
||||
yield* Fiber.join(closing)
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("close waits for busy-period settlement, preserves history, and cannot close a replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const llm = yield* TestLLM.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const retired: string[] = []
|
||||
const session = yield* sessions.open({
|
||||
model,
|
||||
tools: [
|
||||
echo((text) =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as(text),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
retired.push("tool")
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
retire: () =>
|
||||
Effect.sync(() => {
|
||||
retired.push("old")
|
||||
}),
|
||||
})
|
||||
yield* llm.push(
|
||||
TestLLM.tool("close_tool", "execute", { code: 'return await tools.echo({ text: "settled" })' }),
|
||||
TestLLM.text("settled", "close_reply"),
|
||||
)
|
||||
const prompting = yield* session.prompt({ text: "Work before close." }).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
const closing = yield* session.close().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
expect(resolve.status(session.id)).toBe("owned-detached")
|
||||
expect(retired).toEqual([])
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(prompting)
|
||||
yield* Fiber.join(closing)
|
||||
expect(retired).toEqual(["tool", "old"])
|
||||
expect(llm.requests).toHaveLength(2)
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toHaveLength(3)
|
||||
expect((yield* session.resume().pipe(Effect.exit)).toString()).toContain("must be reopened with capabilities")
|
||||
expect(llm.requests).toHaveLength(2)
|
||||
const reopened = yield* sessions.open({
|
||||
id: session.id,
|
||||
model,
|
||||
retire: () =>
|
||||
Effect.sync(() => {
|
||||
retired.push("new")
|
||||
}),
|
||||
})
|
||||
yield* session.close()
|
||||
expect(resolve.status(session.id)).toBe("attached")
|
||||
yield* reopened.close()
|
||||
yield* reopened.close()
|
||||
expect(retired).toEqual(["tool", "old", "new"])
|
||||
expect(resolve.status(session.id)).toBe("owned-detached")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cached snapshots skip rebuilding but reread all selection capabilities", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const tools = Source.mutable([echo(Effect.succeed)])
|
||||
const rules = Source.mutable<Permission.Ruleset>([{ action: "*", resource: "*", effect: "allow" }])
|
||||
const counts = { tools: 0, rules: 0, limits: 0 }
|
||||
const session = yield* sessions.open({
|
||||
model,
|
||||
tools: {
|
||||
get: (session) =>
|
||||
Effect.sync(() => {
|
||||
counts.tools++
|
||||
}).pipe(Effect.andThen(tools.get(session))),
|
||||
},
|
||||
permissions: {
|
||||
ask: Permissions.allowAll.ask,
|
||||
visibility: {
|
||||
get: (session) =>
|
||||
Effect.sync(() => {
|
||||
counts.rules++
|
||||
}).pipe(Effect.andThen(rules.get(session))),
|
||||
},
|
||||
},
|
||||
limits: { get: () => Effect.sync(() => ({ steps: ++counts.limits })) },
|
||||
})
|
||||
const resolved = yield* resolve.resolve(yield* sessions.get(session.id))
|
||||
if (resolved.status !== "attached") return yield* Effect.die("Expected open capabilities")
|
||||
const first = yield* resolved.capabilities.select(session.id)
|
||||
yield* entries.put({ sessionID: session.id, key: InstructionEntry.Key.make("cache-proof"), value: "Fresh entry" })
|
||||
const second = yield* resolved.capabilities.select(session.id)
|
||||
expect(second.tools).toBe(first.tools)
|
||||
expect(second.agent.info.steps).toBe(2)
|
||||
expect(second.instructions.map((source) => source.key)).toContain(Instructions.Key.make("api/cache-proof"))
|
||||
expect(counts).toEqual({ tools: 2, rules: 2, limits: 2 })
|
||||
const original = yield* tools.get(first.session)
|
||||
yield* tools.set([...original])
|
||||
const replaced = yield* resolved.capabilities.select(session.id)
|
||||
expect(replaced.tools).not.toBe(first.tools)
|
||||
yield* tools.set(original)
|
||||
const restored = yield* resolved.capabilities.select(session.id)
|
||||
expect(restored.tools).not.toBe(first.tools)
|
||||
expect(restored.tools).not.toBe(replaced.tools)
|
||||
yield* rules.update((rules) => [...rules])
|
||||
const repolicy = yield* resolved.capabilities.select(session.id)
|
||||
expect(repolicy.tools).not.toBe(restored.tools)
|
||||
expect(counts).toEqual({ tools: 5, rules: 5, limits: 5 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reopens already-owned Sessions without rewriting KV and skips unowned removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* sessions.open({ model })
|
||||
yield* db.run("PRAGMA query_only = ON").pipe(Effect.orDie)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* sessions.open({ id: session.id, model })
|
||||
yield* resolve.remove(Session.ID.make("ses_unowned_removal"))
|
||||
expect(resolve.status(session.id)).toBe("attached")
|
||||
}).pipe(Effect.ensuring(db.run("PRAGMA query_only = OFF").pipe(Effect.orDie)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fresh local defaults do not inherit plugin state from the host's root composition", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const llm = yield* TestLLM.Service
|
||||
const executed: string[] = []
|
||||
yield* hooks.register(
|
||||
"tool",
|
||||
"execute.before",
|
||||
() => new Tool.Error({ message: "Root plugin rejected execution" }),
|
||||
)
|
||||
yield* hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.prompt.text = "Root prompt interceptor must not reach supplied capabilities"
|
||||
}),
|
||||
)
|
||||
const session = yield* sessions.open({
|
||||
model,
|
||||
tools: [
|
||||
echo((text) =>
|
||||
Effect.sync(() => {
|
||||
executed.push(text)
|
||||
return text
|
||||
}),
|
||||
),
|
||||
],
|
||||
})
|
||||
yield* llm.push(
|
||||
TestLLM.tool("root_isolation", "execute", { code: 'return await tools.echo({ text: "local" })' }),
|
||||
TestLLM.text("finished", "root_isolation_reply"),
|
||||
)
|
||||
yield* session.prompt({ text: "Execute with local defaults." })
|
||||
expect(executed).toEqual(["local"])
|
||||
expect(JSON.stringify(llm.requests)).not.toContain("Root plugin rejected execution")
|
||||
expect(JSON.stringify(llm.requests)).not.toContain("Root prompt interceptor")
|
||||
}),
|
||||
)
|
||||
|
||||
isolated.live("reconstructs over durable storage with zero model calls until reopen and explicit drive", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir("session-capabilities-")),
|
||||
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
|
||||
)
|
||||
const llm = yield* TestLLM.Service
|
||||
const root = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Session.node,
|
||||
SessionResolve.node,
|
||||
SessionStore.node,
|
||||
SessionRestart.node,
|
||||
LocationServiceMap.node,
|
||||
Database.node,
|
||||
]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(directory.path, "sessions.db") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LayerNodePlatform.llmClient, TestLLM.clientLayer.pipe(Layer.provide(Layer.succeed(TestLLM.Service, llm)))],
|
||||
],
|
||||
)
|
||||
const firstScope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(firstScope, Exit.void))
|
||||
const first = yield* Layer.buildWithScope(Layer.fresh(root), firstScope)
|
||||
const sessions = Context.get(first, Session.Service)
|
||||
const input = { model, instructions: ["Persist across restarts."], tools: [echo(Effect.succeed)] }
|
||||
const session = yield* sessions.open(input)
|
||||
const initial = yield* sessions.get(session.id)
|
||||
// A process may die after open's durable writes, before admission or a claim.
|
||||
yield* Context.get(first, SessionRestart.Service).resumeSuspendedSessions
|
||||
expect(llm.requests).toHaveLength(0)
|
||||
expect(yield* Context.get(first, SessionStore.Service).listSuspended()).toEqual([])
|
||||
yield* session.prompt({ text: "Pending work.", resume: false })
|
||||
yield* Context.get(first, SessionStore.Service).claim(session.id)
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
// Status and unowned cleanup must remain pure memory operations, even with storage closed.
|
||||
expect(Context.get(first, SessionResolve.Service).status(session.id)).toBe("owned-detached")
|
||||
expect(Context.get(first, SessionResolve.Service).status(Session.ID.make("ses_unknown"))).toBe("unowned")
|
||||
yield* Context.get(first, SessionResolve.Service).remove(Session.ID.make("ses_unknown"))
|
||||
|
||||
const secondScope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(secondScope, Exit.void))
|
||||
const second = yield* Layer.buildWithScope(Layer.fresh(root), secondScope)
|
||||
const restarted = Context.get(second, Session.Service)
|
||||
const recovery = Context.get(second, SessionRestart.Service)
|
||||
const db = Context.get(second, Database.Service).db
|
||||
yield* recovery.resumeSuspendedSessions
|
||||
expect(llm.requests).toHaveLength(0)
|
||||
expect(yield* Context.get(second, SessionStore.Service).listSuspended()).toEqual([session.id])
|
||||
const waiting = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(waiting?.time_suspended).not.toBeNull()
|
||||
expect(waiting?.resume_attempts).toBe(0)
|
||||
expect(Array.from(yield* RcMap.keys(Context.get(second, LocationServiceMap.Service).rcMap))).toEqual([])
|
||||
const reopened = yield* restarted.open({ ...input, id: session.id, title: "ignored" })
|
||||
expect((yield* restarted.get(session.id)).location).toEqual(initial.location)
|
||||
expect((yield* restarted.get(session.id)).projectID).toBe(initial.projectID)
|
||||
yield* recovery.resumeSuspendedSessions
|
||||
expect(llm.requests).toHaveLength(0)
|
||||
yield* llm.push(TestLLM.text("resumed", "recovery_reply"))
|
||||
yield* reopened.resume()
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
expect(llm.requests[0].system.map((part) => part.text).join("\n")).toContain("Persist across restarts.")
|
||||
expect(yield* Context.get(second, SessionStore.Service).listSuspended()).toEqual([])
|
||||
expect(
|
||||
(yield* restarted.messages({ sessionID: session.id })).filter((message) => message.type === "assistant"),
|
||||
).toHaveLength(1)
|
||||
expect(Array.from(yield* RcMap.keys(Context.get(second, LocationServiceMap.Service).rcMap))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("opens with values and drains through tools, durable history, and the instruction epoch", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const sessions = yield* Session.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const kv = yield* KV.Service
|
||||
const executed: string[] = []
|
||||
const tools = Source.mutable([
|
||||
echo((text) =>
|
||||
Effect.sync(() => {
|
||||
executed.push(text)
|
||||
return text
|
||||
}),
|
||||
),
|
||||
])
|
||||
const session = yield* sessions.open({ model, tools, instructions: ["Keep replies brief."] })
|
||||
expect(llm.requests).toHaveLength(0)
|
||||
expect(yield* kv.get(`session.capabilities/${session.id}`)).toBe(true)
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get().pipe(Effect.orDie)
|
||||
expect(row?.time_suspended).toBeNull()
|
||||
yield* llm.push(
|
||||
TestLLM.tool("echo_call", "execute", { code: 'return await tools.echo({ text: "hello" })' }),
|
||||
TestLLM.text("done", "reply"),
|
||||
)
|
||||
yield* session.prompt({ text: "Use echo." })
|
||||
expect(executed).toEqual(["hello"])
|
||||
expect(llm.requests).toHaveLength(2)
|
||||
expect(llm.requests[0]?.tools.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(llm.requests[0]?.http?.headers?.["x-opencode-client"]).toBe("fixture-host")
|
||||
expect(llm.requests[0]?.system.map((part) => part.text).join("\n")).toContain("Keep replies brief.")
|
||||
const history = yield* sessions.messages({ sessionID: session.id })
|
||||
expect(history.filter((message) => message.type === "user")).toHaveLength(1)
|
||||
expect(history.filter((message) => message.type === "assistant")).toHaveLength(2)
|
||||
expect(
|
||||
history.some(
|
||||
(message) =>
|
||||
message.type === "assistant" &&
|
||||
message.content.some((part) => part.type === "tool" && part.state.status === "completed"),
|
||||
),
|
||||
).toBe(true)
|
||||
const state = yield* db
|
||||
.select()
|
||||
.from(InstructionStateTable)
|
||||
.where(eq(InstructionStateTable.session_id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(state?.initial_values["session/instructions"]).toBe(Instructions.hash(["Keep replies brief."]))
|
||||
expect(state?.current_values).toEqual(state?.initial_values)
|
||||
const blob = yield* db
|
||||
.select()
|
||||
.from(InstructionBlobTable)
|
||||
.where(eq(InstructionBlobTable.hash, Instructions.hash(["Keep replies brief."])))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(blob?.value).toEqual(["Keep replies brief."])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("admits images without discovery and rejects undiscovered skill mentions as missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Service
|
||||
const session = yield* sessions.open({ model })
|
||||
const admitted = yield* session.prompt({
|
||||
text: "Inspect this image.",
|
||||
files: [
|
||||
{
|
||||
uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
},
|
||||
],
|
||||
resume: false,
|
||||
})
|
||||
expect(admitted.payload.files?.[0]?.mime).toBe("image/png")
|
||||
expect(llm.requests).toHaveLength(0)
|
||||
expect(
|
||||
yield* session
|
||||
.prompt({ text: "Use a missing skill.", skills: [{ id: Skill.ID.make("missing") }], resume: false })
|
||||
.pipe(Effect.flip),
|
||||
).toBeInstanceOf(Session.SkillNotFoundError)
|
||||
yield* llm.push(TestLLM.text("image received", "image_reply"))
|
||||
yield* session.resume()
|
||||
expect(llm.requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("an unavailable initial Source leaves admitted input pending without a model call", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const sessions = yield* Session.Service
|
||||
const instructions = Source.mutable<ReadonlyArray<string> | Instructions.Unavailable>(Instructions.unavailable)
|
||||
const session = yield* sessions.open({ model, instructions })
|
||||
expect(yield* session.prompt({ text: "Wait for policy." }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Instructions.InitializationBlocked,
|
||||
)
|
||||
expect(llm.requests).toHaveLength(0)
|
||||
expect(yield* sessions.inbox(session.id)).toHaveLength(1)
|
||||
yield* instructions.set(["Policy is ready."])
|
||||
yield* llm.push(TestLLM.text("ready", "ready_reply"))
|
||||
yield* session.resume()
|
||||
expect(llm.requests[0].system.map((part) => part.text).join("\n")).toContain("Policy is ready.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("host permission declines interrupt while corrections remain model-facing", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const sessions = yield* Session.Service
|
||||
for (const outcome of ["decline", "correction", "foreign"]) {
|
||||
const correction = outcome === "correction"
|
||||
const declined = outcome === "decline"
|
||||
const permissions: Permissions.Interface = {
|
||||
visibility: Permissions.allowAll.visibility,
|
||||
ask: () =>
|
||||
correction
|
||||
? new Permission.CorrectedError({ feedback: "Use a safer value." })
|
||||
: new Permission.DeclinedError(),
|
||||
}
|
||||
const tool: Tool.Info = {
|
||||
...echo(Effect.succeed),
|
||||
options: { codemode: false },
|
||||
execute: (input, invocation) =>
|
||||
Effect.gen(function* () {
|
||||
if (outcome === "foreign") return yield* Effect.fail(new Error("ordinary failure"))
|
||||
const session = yield* sessions.get(Session.ID.make(invocation.sessionID))
|
||||
yield* permissions
|
||||
.ask(session, { action: "echo", resources: [input.text] })
|
||||
.pipe(
|
||||
Effect.catchTag("Permission.CorrectedError", (error) => new Tool.Error({ message: error.feedback })),
|
||||
)
|
||||
return { output: input.text }
|
||||
}),
|
||||
}
|
||||
const session = yield* sessions.open({ model, tools: [tool], permissions })
|
||||
const before = llm.requests.length
|
||||
yield* llm.push(TestLLM.tool(`permission_${outcome}`, "echo", { text: "requested" }))
|
||||
if (!declined) yield* llm.push(TestLLM.text("continued", `continued_${outcome}`))
|
||||
const exit = yield* session.prompt({ text: "Request echo." }).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe(declined ? "Failure" : "Success")
|
||||
expect(llm.requests.length - before).toBe(declined ? 1 : 2)
|
||||
const calls = (yield* sessions.messages({ sessionID: session.id })).flatMap((message) =>
|
||||
message.type === "assistant" ? message.content.filter((part) => part.type === "tool") : [],
|
||||
)
|
||||
expect(calls[0].state.status).toBe("error")
|
||||
if (calls[0].state.status !== "error") return yield* Effect.die("Expected durable tool failure")
|
||||
expect(calls[0].state.error.message).toBe(
|
||||
declined ? "The user declined this tool call" : correction ? "Use a safer value." : "ordinary failure",
|
||||
)
|
||||
if (!declined)
|
||||
expect(JSON.stringify(llm.requests[before + 1].messages)).toContain(
|
||||
correction ? "Use a safer value." : "ordinary failure",
|
||||
)
|
||||
if (!declined) {
|
||||
const db = (yield* Database.Service).db
|
||||
expect(
|
||||
(yield* db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get().pipe(Effect.orDie))
|
||||
?.time_suspended,
|
||||
).toBeNull()
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fresh open and reopen share effective capabilities and identical request assembly", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* TestLLM.Service
|
||||
const input = { model, tools: [echo(Effect.succeed)], instructions: ["Stable policy."], system: "Stable system." }
|
||||
const session = yield* sessions.open(input)
|
||||
yield* llm.push(TestLLM.text("done", "parity_reply"))
|
||||
yield* session.prompt({ text: "hello" })
|
||||
const before = yield* sessions.get(session.id)
|
||||
const first = yield* resolve.resolve(before)
|
||||
if (first.status !== "attached") return yield* Effect.die("Expected supplied capabilities")
|
||||
const selected = yield* first.capabilities.select(session.id)
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, session.id)
|
||||
const loaded = yield* first.capabilities.load(selected)
|
||||
const prepare = (capabilities: typeof first.capabilities, value: typeof loaded) =>
|
||||
capabilities.prepare({
|
||||
scope: { session: value.session, agentID: value.agent.id, model: value.model, tools: value.tools },
|
||||
transcript: { system: [...llm.requests[0].system], messages: [...llm.requests[0].messages] },
|
||||
})
|
||||
const initial = yield* prepare(first.capabilities, loaded)
|
||||
const sequence = yield* Bus.latestSequence(db, session.id)
|
||||
const reopened = yield* sessions.open({ ...input, id: session.id, title: "ignored on adopt" })
|
||||
expect(yield* sessions.get(reopened.id)).toEqual(before)
|
||||
const second = yield* resolve.resolve(before)
|
||||
if (second.status !== "attached") return yield* Effect.die("Expected reopened capabilities")
|
||||
const reselected = yield* second.capabilities.select(reopened.id)
|
||||
yield* InstructionState.prepare(db, bus, reselected.instructions, reopened.id)
|
||||
const reloaded = yield* second.capabilities.load(reselected)
|
||||
expect(reloaded.initial).toBe(loaded.initial)
|
||||
expect(reloaded.agent).toEqual(loaded.agent)
|
||||
expect(reloaded.model).toEqual(loaded.model)
|
||||
expect(reloaded.tools.definitions).toEqual(loaded.tools.definitions)
|
||||
expect((yield* prepare(second.capabilities, reloaded)).request).toEqual(initial.request)
|
||||
const call = {
|
||||
sessionID: session.id,
|
||||
agent: loaded.agent.id,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
call: {
|
||||
type: "tool-call" as const,
|
||||
id: "parity_echo",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.echo({ text: "same" })' },
|
||||
},
|
||||
}
|
||||
expect(yield* reloaded.tools.execute(call)).toEqual(yield* loaded.tools.execute(call))
|
||||
expect(yield* Bus.latestSequence(db, session.id)).toBe(sequence)
|
||||
|
||||
const prior = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/prior-host") }),
|
||||
})
|
||||
const adopted = yield* sessions.open({ ...input, id: prior.id })
|
||||
expect(yield* sessions.get(adopted.id)).toEqual(prior)
|
||||
yield* llm.push(TestLLM.text("reconnected", "adopt_reply"))
|
||||
yield* adopted.prompt({ text: "Reconnect from a different cwd." })
|
||||
expect((yield* sessions.get(prior.id)).location).toEqual(prior.location)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hot Sources produce chronological diffs alongside durable entries between busy periods", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const sessions = yield* Session.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const instructions = Source.mutable<ReadonlyArray<string> | Instructions.Unavailable>(["First policy."])
|
||||
const tools = Source.mutable([echo(Effect.succeed)])
|
||||
const session = yield* sessions.open({ model, tools, instructions })
|
||||
yield* entries.put({
|
||||
sessionID: session.id,
|
||||
key: InstructionEntry.Key.make("thread-policy"),
|
||||
value: "Durable policy.",
|
||||
})
|
||||
yield* llm.push(TestLLM.text("first", "first_reply"))
|
||||
yield* session.prompt({ text: "first" })
|
||||
const initial = llm.requests[0].system
|
||||
yield* instructions.update(() => ["Second policy."])
|
||||
yield* tools.update(() => [echo(Effect.succeed, "second_echo")])
|
||||
yield* llm.push(TestLLM.text("second", "second_reply"))
|
||||
yield* session.prompt({ text: "second" })
|
||||
expect(llm.requests[1].system).toEqual(initial)
|
||||
const updates = (yield* sessions.messages({ sessionID: session.id })).filter(
|
||||
(message) => message.type === "system",
|
||||
)
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0].text).toContain("Second policy.")
|
||||
expect(updates[0].text).toContain("second_echo")
|
||||
expect(
|
||||
llm.requests[1].messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some((part) => part.type === "text" && part.text.includes("Second policy.")),
|
||||
),
|
||||
).toBe(true)
|
||||
const state = yield* db
|
||||
.select()
|
||||
.from(InstructionStateTable)
|
||||
.where(eq(InstructionStateTable.session_id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(state?.initial_values["session/instructions"]).toBe(Instructions.hash(["First policy."]))
|
||||
expect(state?.current_values["session/instructions"]).toBe(Instructions.hash(["Second policy."]))
|
||||
expect(state?.current_values["api/thread-policy"]).toBe(Instructions.hash("Durable policy."))
|
||||
|
||||
yield* instructions.set(Instructions.unavailable)
|
||||
yield* llm.push(TestLLM.text("retained", "retained_reply"))
|
||||
yield* session.prompt({ text: "temporarily unavailable" })
|
||||
expect(
|
||||
(yield* sessions.messages({ sessionID: session.id })).filter((message) => message.type === "system"),
|
||||
).toHaveLength(1)
|
||||
yield* instructions.set([])
|
||||
yield* llm.push(TestLLM.text("removed", "removed_reply"))
|
||||
yield* session.prompt({ text: "removed" })
|
||||
expect(
|
||||
(yield* sessions.messages({ sessionID: session.id, order: "asc" }))
|
||||
.filter((message) => message.type === "system")
|
||||
.at(-1)?.text,
|
||||
).toContain("no longer apply")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("pins capabilities across coalesced drains but rereads their Sources at safe boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const sessions = yield* Session.Service
|
||||
const began = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const settled: string[] = []
|
||||
const executed: string[] = []
|
||||
const tools = Source.mutable([
|
||||
echo((text) =>
|
||||
Deferred.succeed(began, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
executed.push("old")
|
||||
return text
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
settled.push("tool")
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
const instructions = Source.mutable(["Old policy."])
|
||||
const session = yield* sessions.open({
|
||||
model,
|
||||
tools,
|
||||
instructions,
|
||||
retire: () =>
|
||||
Effect.sync(() => {
|
||||
settled.push("retired")
|
||||
}),
|
||||
})
|
||||
yield* llm.push(
|
||||
TestLLM.tool("blocked_echo", "execute", { code: 'return await tools.echo({ text: "blocked" })' }),
|
||||
TestLLM.tool("updated_echo", "execute", { code: 'return await tools.updated_echo({ text: "updated" })' }),
|
||||
TestLLM.text("finished", "busy_reply"),
|
||||
)
|
||||
const prompting = yield* session.prompt({ text: "Start work." }).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(began)
|
||||
yield* sessions.open({
|
||||
model,
|
||||
id: session.id,
|
||||
tools: [
|
||||
echo(
|
||||
(text) =>
|
||||
Effect.sync(() => {
|
||||
executed.push("replacement")
|
||||
return text
|
||||
}),
|
||||
"replacement_echo",
|
||||
),
|
||||
],
|
||||
instructions: ["Replacement policy."],
|
||||
})
|
||||
expect(settled).toEqual([])
|
||||
yield* tools.set([
|
||||
echo(
|
||||
(text) =>
|
||||
Effect.sync(() => {
|
||||
executed.push("updated source")
|
||||
return text
|
||||
}),
|
||||
"updated_echo",
|
||||
),
|
||||
])
|
||||
yield* instructions.set(["Updated old policy."])
|
||||
yield* sessions.prompt({ sessionID: session.id, text: "Steer during work." })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(prompting)
|
||||
yield* sessions.wait(session.id)
|
||||
expect(executed).toEqual(["old", "updated source"])
|
||||
expect(settled).toEqual(["tool", "retired"])
|
||||
expect(
|
||||
llm.requests[1].messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some((part) => part.type === "text" && part.text.includes("Updated old policy.")),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(JSON.stringify(llm.requests.slice(0, 3))).not.toContain("Replacement policy.")
|
||||
yield* llm.push(
|
||||
TestLLM.tool("replacement_call", "execute", {
|
||||
code: 'return await tools.replacement_echo({ text: "replacement" })',
|
||||
}),
|
||||
TestLLM.text("replacement finished", "replacement_reply"),
|
||||
)
|
||||
yield* session.prompt({ text: "Next busy period." })
|
||||
expect(executed).toEqual(["old", "updated source", "replacement"])
|
||||
expect(
|
||||
llm.requests[3].messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.some((part) => part.type === "text" && part.text.includes("Replacement policy.")),
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("concurrent opens isolate their tools, instructions, and executable snapshots", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const sessions = yield* Session.Service
|
||||
const gate = yield* llm.gate
|
||||
const executed: string[] = []
|
||||
const first = yield* sessions.open({
|
||||
model,
|
||||
tools: [
|
||||
echo(
|
||||
(text) =>
|
||||
Effect.sync(() => {
|
||||
executed.push("first")
|
||||
return text
|
||||
}),
|
||||
"first_echo",
|
||||
),
|
||||
],
|
||||
instructions: ["First private policy."],
|
||||
})
|
||||
const second = yield* sessions.open({
|
||||
model,
|
||||
tools: [
|
||||
echo(
|
||||
(text) =>
|
||||
Effect.sync(() => {
|
||||
executed.push("second")
|
||||
return text
|
||||
}),
|
||||
"second_echo",
|
||||
),
|
||||
],
|
||||
instructions: ["Second private policy."],
|
||||
})
|
||||
yield* llm.push(
|
||||
TestLLM.tool("first_call", "execute", { code: 'return await tools.first_echo({ text: "first" })' }),
|
||||
TestLLM.tool("second_call", "execute", { code: 'return await tools.second_echo({ text: "second" })' }),
|
||||
TestLLM.text("first done", "first_isolation_reply"),
|
||||
TestLLM.text("second done", "second_isolation_reply"),
|
||||
)
|
||||
const firstPrompt = yield* first.prompt({ text: "first" }).pipe(Effect.forkScoped)
|
||||
yield* llm.wait(1)
|
||||
const secondPrompt = yield* second.prompt({ text: "second" }).pipe(Effect.forkScoped)
|
||||
yield* llm.wait(2)
|
||||
expect(llm.requests).toHaveLength(2)
|
||||
const firstRequest = llm.requests.find((request) => request.http?.headers?.["X-Session-Id"] === first.id)
|
||||
const secondRequest = llm.requests.find((request) => request.http?.headers?.["X-Session-Id"] === second.id)
|
||||
expect(JSON.stringify(firstRequest)).toContain("First private policy.")
|
||||
expect(JSON.stringify(firstRequest)).toContain("first_echo")
|
||||
expect(JSON.stringify(firstRequest)).not.toContain("Second private policy.")
|
||||
expect(JSON.stringify(firstRequest)).not.toContain("second_echo")
|
||||
expect(JSON.stringify(secondRequest)).toContain("Second private policy.")
|
||||
expect(JSON.stringify(secondRequest)).toContain("second_echo")
|
||||
expect(JSON.stringify(secondRequest)).not.toContain("First private policy.")
|
||||
yield* gate.release
|
||||
yield* Fiber.join(firstPrompt)
|
||||
yield* Fiber.join(secondPrompt)
|
||||
expect(executed.toSorted()).toEqual(["first", "second"])
|
||||
for (const session of [first, second]) {
|
||||
const capabilities = yield* resolve.resolve(yield* sessions.get(session.id))
|
||||
if (capabilities.status !== "attached") return yield* Effect.die("Expected isolated capabilities")
|
||||
const selected = yield* capabilities.capabilities.select(session.id)
|
||||
expect(selected.tools.codeModeCatalog?.map((tool) => tool.path)).toEqual([
|
||||
session === first ? "first_echo" : "second_echo",
|
||||
])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retires deleted capabilities and removes their durable ownership marker", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const retired: string[] = []
|
||||
const session = yield* sessions.open({
|
||||
model,
|
||||
retire: () =>
|
||||
Effect.sync(() => {
|
||||
retired.push("retired")
|
||||
}),
|
||||
})
|
||||
yield* sessions.remove(session.id)
|
||||
expect(retired).toEqual(["retired"])
|
||||
expect(resolve.status(session.id)).toBe("unowned")
|
||||
expect(yield* sessions.get(session.id).pipe(Effect.flip)).toBeInstanceOf(Session.NotFoundError)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import type { SessionContext } from "@opencode-ai/core/session/context"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
@@ -74,6 +75,18 @@ const resolved = SessionRunnerModel.resolved(model, {
|
||||
cost,
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
const context = (
|
||||
session: Session.Info,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
): Effect.Effect<SessionContext.Loaded> =>
|
||||
Effect.succeed({
|
||||
session,
|
||||
agent: { id: Agent.defaultID, info: { ...Agent.Info.default(Agent.defaultID), system: "Working agent system" } },
|
||||
model: resolved,
|
||||
initial: "Session instructions",
|
||||
messages,
|
||||
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -93,7 +106,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
test("compaction prompt preserves detailed work state and relevant files", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt()
|
||||
|
||||
expect(prompt).toContain("## Work State\n### Completed")
|
||||
expect(prompt).toContain("### Active")
|
||||
@@ -125,7 +138,7 @@ test("compaction truncation does not split surrogate pairs", () => {
|
||||
})
|
||||
|
||||
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt()
|
||||
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
@@ -249,8 +262,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
prepare: modelRequests.prepare,
|
||||
context: context(session, [userMessage]),
|
||||
messages: [userMessage],
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
@@ -269,6 +282,9 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Working agent system", "Session instructions"])
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
|
||||
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
@@ -305,19 +321,20 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
||||
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
|
||||
})
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages: SessionMessage.Info[] = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
context: context(session, messages),
|
||||
messages,
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
@@ -327,38 +344,45 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps session context hooks away from compaction requests", () =>
|
||||
it.effect("applies the working agent's context hooks to compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
// Context hooks shape the agent conversation; compaction is not part of it,
|
||||
// so it opts out and the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.agent).toBe(Agent.defaultID)
|
||||
event.system.push(SystemPart.make("Injected conversation context"))
|
||||
event.messages.push(Message.user("Additional conversation context"))
|
||||
}),
|
||||
)
|
||||
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages: SessionMessage.Info[] = [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
context: context(session, messages),
|
||||
messages,
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_hook_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system).toEqual([])
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([
|
||||
"Working agent system",
|
||||
"Session instructions",
|
||||
"Injected conversation context",
|
||||
])
|
||||
expect(requests[0]?.messages.at(-2)?.content).toEqual([Message.text("Additional conversation context")])
|
||||
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -33,8 +33,6 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { promptLocationLayer } from "./fixture/prompt-location"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
@@ -52,7 +50,6 @@ const it = testEffect(
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
@@ -966,7 +963,7 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
liveIt.live("runs a shell command and projects the started/ended shell message", () =>
|
||||
it.live("runs a shell command and projects the started/ended shell message", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -991,7 +988,7 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
liveIt.live("still emits shell ended for a failing command", () =>
|
||||
it.live("still emits shell ended for a failing command", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -18,7 +18,6 @@ import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionResolve } from "@opencode-ai/core/session/resolve"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
@@ -27,9 +26,7 @@ import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node, SessionResolve.node]),
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
|
||||
)
|
||||
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
@@ -1138,7 +1135,6 @@ function buildExecution(
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = overrideJobs ?? (yield* Job.Service)
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const sessionLayer = Layer.effect(
|
||||
Session.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -1174,7 +1170,6 @@ function buildExecution(
|
||||
Layer.provide(Layer.succeed(Database.Service, database)),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
Layer.provide(Layer.succeed(SessionResolve.Service, resolve)),
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
Layer.provide(locations),
|
||||
),
|
||||
|
||||
@@ -1,165 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { LanguageModel, Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { Gemini } from "@opencode-ai/ai/protocols/gemini"
|
||||
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { SessionModelRequest, boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [
|
||||
[SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })],
|
||||
]),
|
||||
)
|
||||
|
||||
const requestInput = (model: LanguageModel) => ({
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: Session.ID.make("ses_request_options"),
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agentID: Agent.ID.make("build"),
|
||||
model: SessionRunnerModel.resolved(model, {
|
||||
capabilities: { ...capabilities(["text"]), responsesWebsockets: model.provider === "openai" },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
}),
|
||||
},
|
||||
transcript: { system: [], messages: [Message.user("Hello")] },
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.context options", () => {
|
||||
it.effect("compiles ordered generation and provider overrides without mutating defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const model = Gemini.route
|
||||
.with({
|
||||
generation: { maxTokens: 100, topP: 0.7 },
|
||||
providerOptions: { thinkingConfig: { includeThoughts: true, thinkingBudget: 256 } },
|
||||
})
|
||||
.model({
|
||||
id: "gemini-2.5-flash",
|
||||
defaults: {
|
||||
generation: { temperature: 0.8 },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 512 } },
|
||||
},
|
||||
})
|
||||
const baseline = yield* requests.prepare(requestInput(model))
|
||||
expect(baseline.request.generation).toBeUndefined()
|
||||
expect(baseline.request.providerOptions).toBeUndefined()
|
||||
const first = yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.generation).toEqual({})
|
||||
expect(event.providerOptions).toEqual({})
|
||||
event.generation = {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.2,
|
||||
topK: 40,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.3,
|
||||
seed: 42,
|
||||
stop: ["END"],
|
||||
}
|
||||
event.providerOptions = { thinkingConfig: { thinkingBudget: 1024 } }
|
||||
}),
|
||||
)
|
||||
const second = yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.generation.temperature).toBe(0.2)
|
||||
expect(event.providerOptions.thinkingConfig).toEqual({ thinkingBudget: 1024 })
|
||||
event.generation.temperature = 0
|
||||
event.generation.stop?.push("STOP")
|
||||
}),
|
||||
)
|
||||
const prepared = yield* requests.prepare(requestInput(model))
|
||||
expect((yield* compileRequest(prepared.request)).body).toMatchObject({
|
||||
generationConfig: {
|
||||
maxOutputTokens: 2048,
|
||||
temperature: 0,
|
||||
topP: 0.7,
|
||||
topK: 40,
|
||||
frequencyPenalty: 0.1,
|
||||
presencePenalty: 0.3,
|
||||
seed: 42,
|
||||
stopSequences: ["END", "STOP"],
|
||||
thinkingConfig: { includeThoughts: true, thinkingBudget: 1024 },
|
||||
},
|
||||
})
|
||||
// Each new request starts with fresh override objects, even while hooks remain registered.
|
||||
expect((yield* requests.prepare(requestInput(model))).request.generation).toEqual(prepared.request.generation)
|
||||
yield* first.dispose
|
||||
yield* second.dispose
|
||||
const unhooked = yield* requests.prepare(requestInput(model))
|
||||
expect(unhooked.request.generation).toBeUndefined()
|
||||
expect(unhooked.request.providerOptions).toBeUndefined()
|
||||
expect((yield* compileRequest(unhooked.request)).body).toEqual((yield* compileRequest(baseline.request)).body)
|
||||
expect(model.defaults?.generation).toEqual({ temperature: 0.8 })
|
||||
expect(model.route.defaults.generation).toEqual({ maxTokens: 100, topP: 0.7 })
|
||||
expect(model.defaults?.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 512 } })
|
||||
expect(model.route.defaults.providerOptions).toEqual({
|
||||
thinkingConfig: { includeThoughts: true, thinkingBudget: 256 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compiles OpenAI semantic reasoning options without revoking WebSocket transport", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", () => Effect.die("Other-provider hook must not run"), {
|
||||
providerID: "google",
|
||||
})
|
||||
yield* hooks.register(
|
||||
"session",
|
||||
"context",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.maxTokens = 8000
|
||||
event.providerOptions.reasoningEffort = "high"
|
||||
}),
|
||||
{ providerID: "openai" },
|
||||
)
|
||||
const input = requestInput(OpenAIResponses.route.model({ id: "gpt-5.5" }))
|
||||
const prepared = yield* requests.prepare({ ...input, webSocket: "session" })
|
||||
expect(prepared.options.webSocket).toBeDefined()
|
||||
expect(prepared.options.http).toBeUndefined()
|
||||
expect((yield* compileRequest(prepared.request)).body).toMatchObject({
|
||||
max_output_tokens: 8000,
|
||||
reasoning: { effort: "high" },
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
const excluded = yield* requests.prepare({ ...input, contextHooks: false })
|
||||
expect(excluded.request.generation).toBeUndefined()
|
||||
expect(excluded.request.providerOptions).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.unsupportedParts", () => {
|
||||
test("replaces unsupported user media with a visible error", () => {
|
||||
const messages = unsupportedParts(
|
||||
|
||||
@@ -698,22 +698,16 @@ describe("SessionModelTransport", () => {
|
||||
|
||||
test("poisons instead of dropping data when the inbound queue overflows", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
const poisoned = Deferred.makeUnsafe<void>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () =>
|
||||
// Hold consumption at the send boundary until the reader fills and poisons the inbound queue.
|
||||
Effect.sync(() => {
|
||||
for (let index = 0; index <= 129; index++) Queue.offerUnsafe(messages, `frame:${index}`)
|
||||
}).pipe(Effect.andThen(Deferred.await(poisoned))),
|
||||
messages: Stream.fromQueue(messages).pipe(Stream.tap(() => Effect.yieldNow)),
|
||||
close: Effect.sync(() => closed++).pipe(
|
||||
Effect.andThen(Deferred.succeed(poisoned, undefined)),
|
||||
Effect.andThen(Queue.shutdown(messages)),
|
||||
Effect.asVoid,
|
||||
),
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -727,7 +721,7 @@ describe("SessionModelTransport", () => {
|
||||
...item,
|
||||
driver: {
|
||||
create: item.driver.create,
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame" as const, frame }),
|
||||
observe: (_create, frame) => Effect.sleep("1 millis").pipe(Effect.as({ type: "frame" as const, frame })),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Fiber, Stream } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const runtime = PluginRuntime.makeCell()
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
Session.node,
|
||||
LocationServiceMap.node,
|
||||
PluginRuntime.providerNodeWithCell(runtime),
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(runtime)],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
const project = Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const tmp = yield* project
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const services = locations.get(session.location)
|
||||
const hooks = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* PluginHooks.Service
|
||||
}).pipe(Effect.provide(services))
|
||||
return { sessions, session, hooks, services }
|
||||
})
|
||||
|
||||
describe("Session prompt hooks", () => {
|
||||
it.live("waits for local plugin setup before admitting even a plain-text prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* project
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, ".opencode/plugins/prompt.ts"),
|
||||
`export default {
|
||||
id: "prompt-readiness",
|
||||
async setup(ctx) {
|
||||
await ctx.session.hook("prompt", (event) => {
|
||||
event.prompt.text = "Prepared by plugin"
|
||||
})
|
||||
},
|
||||
}`,
|
||||
),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
|
||||
const admitted = yield* sessions.prompt({ sessionID: session.id, text: "Original", resume: false })
|
||||
expect(admitted.payload.text).toBe("Prepared by plugin")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("persists ordered draft edits and resolves added files and skills without mutating the caller", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
const skills = yield* Skill.Service.pipe(Effect.provide(fixture.services))
|
||||
const skill = Skill.Info.make({
|
||||
id: Skill.ID.make("policy"),
|
||||
name: Skill.Name.make("Policy"),
|
||||
description: "Company policy",
|
||||
location: AbsolutePath.make(path.join(fixture.session.location.directory, "policy.md")),
|
||||
content: "Follow company policy.",
|
||||
})
|
||||
yield* skills.transform((draft) => draft.add(skill))
|
||||
const input = {
|
||||
sessionID: fixture.session.id,
|
||||
id: SessionMessage.ID.create(),
|
||||
text: "secret",
|
||||
files: [
|
||||
{
|
||||
uri: "data:text/plain;base64,b3JpZ2luYWw=",
|
||||
name: "original.txt",
|
||||
mention: { start: 0, end: 6, text: "secret" },
|
||||
},
|
||||
],
|
||||
metadata: { source: "api" },
|
||||
resume: false,
|
||||
}
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(input.sessionID)
|
||||
expect(event.messageID).toBe(input.id)
|
||||
event.prompt.text = "Redacted"
|
||||
const file = event.prompt.files?.[0]
|
||||
if (file) {
|
||||
file.uri = "data:text/plain;base64,cG9saWN5"
|
||||
file.name = "policy.txt"
|
||||
delete file.mention
|
||||
}
|
||||
event.prompt.skills = [{ id: skill.id }]
|
||||
event.prompt.agents = [{ name: "reviewer" }]
|
||||
event.metadata ??= {}
|
||||
event.metadata.source = "plugin"
|
||||
event.delivery = "queue"
|
||||
}),
|
||||
)
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.prompt.text).toBe("Redacted")
|
||||
event.prompt.text += " with policy"
|
||||
}),
|
||||
)
|
||||
const admitted = yield* fixture.sessions.prompt(input)
|
||||
expect(admitted).toMatchObject({
|
||||
id: input.id,
|
||||
delivery: "queue",
|
||||
payload: {
|
||||
text: "Redacted with policy",
|
||||
metadata: { source: "plugin" },
|
||||
files: [{ name: "policy.txt", data: "cG9saWN5", mime: "text/plain" }],
|
||||
agents: [{ name: "reviewer" }],
|
||||
skills: [{ id: skill.id, name: skill.name, text: Skill.toModelOutput(skill, []) }],
|
||||
},
|
||||
})
|
||||
expect(input.text).toBe("secret")
|
||||
expect(input.files).toEqual([
|
||||
{
|
||||
uri: "data:text/plain;base64,b3JpZ2luYWw=",
|
||||
name: "original.txt",
|
||||
mention: { start: 0, end: 6, text: "secret" },
|
||||
},
|
||||
])
|
||||
expect(input.metadata).toEqual({ source: "api" })
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* SessionInbox.find(database.db, input.id)).toEqual(admitted)
|
||||
const log = yield* fixture.sessions.log({ sessionID: input.sessionID }).pipe(Stream.runCollect)
|
||||
expect(JSON.stringify(log)).not.toContain("secret")
|
||||
yield* SessionInbox.promote(database.db, bus, input.sessionID, "input")
|
||||
expect(yield* fixture.sessions.messages({ sessionID: input.sessionID })).toMatchObject([
|
||||
{ id: input.id, type: "user", text: "Redacted with policy", metadata: { source: "plugin" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("skips hooks and payload resolution on pending and delivered retries, including conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
const calls: string[] = []
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(event.prompt.text)
|
||||
event.prompt.text = "First admission"
|
||||
}),
|
||||
)
|
||||
const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "Original", resume: false }
|
||||
const first = yield* fixture.sessions.prompt(input)
|
||||
const retry = { ...input, text: "Ignored", files: [{ uri: "file:///missing-retry-file" }] }
|
||||
expect(yield* fixture.sessions.prompt(retry)).toEqual(first)
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* SessionInbox.promote(database.db, bus, input.sessionID, "steer")
|
||||
expect((yield* fixture.sessions.prompt(retry)).payload).toEqual(first.payload)
|
||||
const other = yield* fixture.sessions.create({ location: fixture.session.location })
|
||||
expect((yield* fixture.sessions.prompt({ ...retry, sessionID: other.id }).pipe(Effect.flip))._tag).toBe(
|
||||
"Session.PromptConflictError",
|
||||
)
|
||||
const synthetic = yield* fixture.sessions.synthetic({
|
||||
sessionID: input.sessionID,
|
||||
text: "Synthetic",
|
||||
resume: false,
|
||||
})
|
||||
expect((yield* fixture.sessions.prompt({ ...retry, id: synthetic.id }).pipe(Effect.flip))._tag).toBe(
|
||||
"Session.PromptConflictError",
|
||||
)
|
||||
expect(calls).toEqual(["Original"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("leaves a staged revert untouched on retries and failed preparation", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const first = yield* fixture.sessions.prompt({ sessionID: fixture.session.id, text: "Boundary", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, fixture.session.id, "steer")
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID: fixture.session.id,
|
||||
revert: { messageID: first.id, files: [] },
|
||||
})
|
||||
const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook")))
|
||||
expect(
|
||||
(yield* fixture.sessions.prompt({
|
||||
sessionID: fixture.session.id,
|
||||
id: first.id,
|
||||
text: "Ignored",
|
||||
resume: false,
|
||||
})).payload,
|
||||
).toEqual(first.payload)
|
||||
expect(
|
||||
(yield* fixture.sessions
|
||||
.prompt({ sessionID: fixture.session.id, text: "Fail", resume: false })
|
||||
.pipe(Effect.exit))._tag,
|
||||
).toBe("Failure")
|
||||
expect((yield* fixture.sessions.get(fixture.session.id)).revert?.messageID).toBe(first.id)
|
||||
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toMatchObject([{ id: first.id }])
|
||||
yield* failing.dispose
|
||||
const next = yield* fixture.sessions.prompt({
|
||||
sessionID: fixture.session.id,
|
||||
text: "After revert",
|
||||
resume: false,
|
||||
})
|
||||
expect((yield* fixture.sessions.get(fixture.session.id)).revert).toBeUndefined()
|
||||
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([])
|
||||
expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([next])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps first-admission-wins for concurrent transformed submissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const calls: string[] = []
|
||||
yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.gen(function* () {
|
||||
calls.push(event.prompt.text)
|
||||
event.prompt.text += " transformed"
|
||||
if (calls.length === 2) yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
)
|
||||
const input = { sessionID: fixture.session.id, id: SessionMessage.ID.create(), text: "First", resume: false }
|
||||
const submissions = yield* Effect.all(
|
||||
[fixture.sessions.prompt(input), fixture.sessions.prompt({ ...input, text: "Second" })],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(entered)
|
||||
expect(yield* fixture.sessions.inbox(input.sessionID)).toEqual([])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const results = yield* Fiber.join(submissions)
|
||||
expect(results[0]).toEqual(results[1])
|
||||
expect(["First transformed", "Second transformed"]).toContain(results[0]?.payload.text)
|
||||
expect(yield* fixture.sessions.inbox(input.sessionID)).toHaveLength(1)
|
||||
expect(yield* fixture.sessions.prompt(input)).toEqual(results[0])
|
||||
expect(calls).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not admit failed attachment preparation or an interrupted hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
const registration = yield* fixture.hooks.register("session", "prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.prompt.files = [{ uri: "file:///missing-hook-file" }]
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
(yield* fixture.sessions
|
||||
.prompt({ sessionID: fixture.session.id, text: "Original", resume: false })
|
||||
.pipe(Effect.flip))._tag,
|
||||
).toBe("Session.AttachmentError")
|
||||
yield* registration.dispose
|
||||
const failing = yield* fixture.hooks.register("session", "prompt", () => Effect.die(new Error("Broken hook")))
|
||||
expect(
|
||||
(yield* fixture.sessions
|
||||
.prompt({ sessionID: fixture.session.id, text: "Fail", resume: false })
|
||||
.pipe(Effect.exit))._tag,
|
||||
).toBe("Failure")
|
||||
yield* failing.dispose
|
||||
const started = yield* Deferred.make<void>()
|
||||
yield* fixture.hooks.register("session", "prompt", () =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const submission = yield* fixture.sessions
|
||||
.prompt({ sessionID: fixture.session.id, text: "Interrupt", resume: false })
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(submission)
|
||||
expect(yield* fixture.sessions.inbox(fixture.session.id)).toEqual([])
|
||||
expect(yield* fixture.sessions.messages({ sessionID: fixture.session.id })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("applies a Promise plugin to command-generated prompts only in its own location", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* project
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, ".opencode/plugins/command.ts"),
|
||||
`export default {
|
||||
id: "prompt-command",
|
||||
async setup(ctx) {
|
||||
await ctx.session.hook("prompt", (event) => {
|
||||
event.prompt.text += " with plugin"
|
||||
})
|
||||
await ctx.command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "review",
|
||||
async execute(input) {
|
||||
await ctx.session.prompt({ sessionID: input.sessionID, text: "Review", resume: false })
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
}`,
|
||||
),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ location: { directory: AbsolutePath.make(tmp.path) } })
|
||||
const other = yield* setup
|
||||
yield* sessions.command({ sessionID: session.id, command: "review", text: "" })
|
||||
expect(yield* sessions.inbox(session.id)).toMatchObject([{ payload: { text: "Review with plugin" } }])
|
||||
const untouched = yield* other.sessions.prompt({
|
||||
sessionID: other.session.id,
|
||||
text: "Other location",
|
||||
resume: false,
|
||||
})
|
||||
expect(untouched.payload.text).toBe("Other location")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -28,7 +28,6 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -68,7 +67,6 @@ const locations = Layer.effect(
|
||||
Effect.sync(() => {
|
||||
let ready = false
|
||||
return Layer.mergeAll(
|
||||
LayerNode.compile(PluginHooks.node),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
|
||||
@@ -44,8 +44,6 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { promptLocationLayer } from "./fixture/prompt-location"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
|
||||
@@ -157,7 +155,6 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[LayerNodePlatform.llmClient, llmClient],
|
||||
[Permission.node, permission],
|
||||
[Catalog.node, promptCatalog],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user