Compare commits

..
145 changed files with 1359 additions and 6352 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/core": patch
---
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
-8
View File
@@ -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.
+2 -2
View File
@@ -237,9 +237,9 @@ jobs:
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests against production build
- name: Run app e2e tests
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:built
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
+9 -9
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -2175,19 +2175,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.11", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.11", "@opencode-ai/pty-darwin-x64": "0.1.11", "@opencode-ai/pty-linux-arm64-gnu": "0.1.11", "@opencode-ai/pty-linux-arm64-musl": "0.1.11", "@opencode-ai/pty-linux-x64-gnu": "0.1.11", "@opencode-ai/pty-linux-x64-musl": "0.1.11" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-Q4p0XXZWbc8FnpEJaaLqVbCdodxR9lVzaQjMH18KvjX/4m6tYfuspz03mvkN9MdmtDJ2GOZS7QgGQ7Q+RE9aWw=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hz59ImecqeBdLQ40TknPDc9k4xWjQPaTgZ7cXzF3vclAvZiFYSM1rRdbBF6rOaOB0DBp0OFYsiaPP1ykszLsfw=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TPpA+FZ08BXtTcOeqe0FEJitqLld6Nl46UizcSmQCTRM22xOKPar6OoxHGYtqdFCaFk43a+hk/0GWpV7mcEQEg=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PTU9Ss5a5pApw6IeVvjjbFPuui2oKMoTQ/nY+K1+idLpgMeQHXk2URJbQqetqJxH4OLL5eSutDeWvpkweW0tTw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-NFZ2LLfEaO6858cYtEwfQKda/HnCrPR0WflnWoDllHmdY12umeHkeE8DnZQK48tziXxvEACspItXmleiAMAg3g=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-2Wbko2tFkgTmY6ceB+QMA3E+omaRd5IBBFjJoikMxkc7n75TXYQVz09tCC0pPW+flGvApFQ2YcIkSotdLwit+A=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-PF7vbOsSOVbRSo11pOOmJq/Vp34Ww7Xoo8rUeMSAoG1tJSIp2SXFHCRKEGH9H4KxGUfpRHQlDBV1HLz8onnyJQ=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-iYdVrLtyKmjlyypisF9SqzgyriWT90kSCh3crxw9AKU=",
"aarch64-linux": "sha256-BV2t4w5ujArbtSC/Qfm3gLzevQW9A6hMgOyPVp94g/o=",
"aarch64-darwin": "sha256-EwMq7zaxzzcsmH0Pjqu4ftGdcM8Lna8mvHgKzRcVI8g=",
"x86_64-darwin": "sha256-PokzxlkQy6JvHADF2ZMIIDI1u9ZjSNNedpmR9gvHS5c="
"x86_64-linux": "sha256-NV1PD2fCgWEKsr9kR0pV9jgkC400dzoF7/DnI/fY5yI=",
"aarch64-linux": "sha256-TDTdwE0mcHLrrKPDwPPBk3qIDl/PXJrLX6Zbwp7EH3I=",
"aarch64-darwin": "sha256-6MEoiV1UKAWgC7C6PR4USCP/LLZXROfBfPg6sb2VVWg=",
"x86_64-darwin": "sha256-8JV6YVZFq1BC++zpARxBWhQ+wuNJrWgTZJ6jfQhDybs="
}
}
+4 -28
View File
@@ -31,7 +31,7 @@ Your app is ready to be deployed!
## E2E Testing
Locally, Playwright starts the Vite dev server automatically via `webServer`, or reuses one already running at the configured address. The browser suite uses isolated API fixtures rather than a live opencode backend.
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
```bash
bunx playwright install chromium
@@ -39,35 +39,11 @@ bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
```bash
bun run test:e2e:built
bun run test:e2e:built -- --grep "settings"
```
To test an already-running dev server without starting or building a server:
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
```
For an already-running production build, also set `PLAYWRIGHT_BUILD=1` so the fixture API uses the app's origin:
```bash
PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
```
External targets must use HTTP because fixture URLs use HTTP. `PLAYWRIGHT_BASE_URL` skips server startup and building in either mode.
Compiled CLI startup and service lifecycle coverage runs separately in CI via `packages/cli/script/service-smoke.ts`.
Environment options:
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
## Deployment
-21
View File
@@ -44,7 +44,6 @@ The suite contains:
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
- retained renderer heap with a large model catalog across repeated session navigation
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
@@ -79,26 +78,6 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
```
## Retained renderer memory
Run the catalog workload against the production app bundle:
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/provider-memory-benchmark.spec.ts --repeat-each=3
```
`PROVIDER_MEMORY_MODELS` defaults to 1,200 and `PROVIDER_MEMORY_SWITCHES` defaults to 10. Each sample records Chromium's `Runtime.getHeapUsage` and `Memory.getDOMCounters` after an explicit garbage collection. This measures retained state, not allocation peaks or normal GC timing. It does not include worker heaps, the Electron main/GPU processes, or the OpenCode server, and must not be reported as total desktop RAM. Use identical model counts and navigation sequences for before/after comparisons.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
@@ -1,7 +1,7 @@
import config from "../../playwright.config"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
process.env.PLAYWRIGHT_SERVER_PORT = new URL(process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`).port || "80"
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
export default {
@@ -12,11 +12,9 @@ export default {
fullyParallel: false,
workers: 1,
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
webServer: config.webServer
? {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
}
: undefined,
webServer: {
...config.webServer,
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
reuseExistingServer: false,
},
}
@@ -126,19 +126,13 @@ test("keeps moving upward while drag-selecting above the timeline", async ({ pag
)
})
})
const textBox = await text.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null
})
const textBox = await text.boundingBox()
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
// Start on a text line, not the empty right edge or gap between wrapped lines.
await page.mouse.move(textBox.x + Math.min(20, textBox.width / 2), textBox.y + textBox.height / 2)
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
@@ -201,45 +195,6 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("keeps an older answer selected while scrolling within the interaction buffer", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const answer = page.getByText("History 78.", { exact: false })
await expect(answer).toBeVisible()
await expect
.poll(() =>
answer.evaluate((element) => element.closest('[data-component="markdown"]')?.hasAttribute("data-markdown-ready")),
)
.toBe(true)
const textBox = await answer.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
})
const scrollBox = await scroller.boundingBox()
expect(scrollBox).not.toBeNull()
if (!scrollBox) return
await page.mouse.move(textBox.x + Math.min(180, textBox.width - 2), textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 2, textBox.y + textBox.height / 2, { steps: 30 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.mouse.move(scrollBox.x + scrollBox.width / 2, scrollBox.y + scrollBox.height / 2)
await page.mouse.wheel(0, -450)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(400)
await expect(answer).toHaveCount(1)
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.getByRole("heading", { name: "Timeline visual stability" }).click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed)).toBe(true)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -304,16 +259,12 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
const before = await scroller.evaluate((element) => element.scrollTop)
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
await nested.press("PageUp")
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await page.waitForTimeout(300)
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await nested.evaluate((element) => element.scrollTo({ top: 0, behavior: "instant" }))
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBe(0)
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 }))
element.scrollTo({ top: Math.min(300, element.scrollHeight - element.clientHeight), behavior: "instant" })
})
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(500)
await nested.evaluate((element) => (element.scrollTop = 0))
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
expect(boundaryBefore).toBeGreaterThan(0)
await nested.press("PageUp")
@@ -1,98 +0,0 @@
import { benchmark, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
import type { CatalogUpdated } from "@opencode-ai/client/promise"
benchmark("measures retained renderer memory with a large model catalog", async ({ page, report }) => {
benchmark.setTimeout(120_000)
const count = Number(process.env.PROVIDER_MEMORY_MODELS ?? 1200)
const switches = Number(process.env.PROVIDER_MEMORY_SWITCHES ?? 10)
const provider = fixture.provider.all[0]
const selected = { ...provider.models["claude-opus-4-6"] }
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
pageMessages,
provider: {
...fixture.provider,
all: [
{
...provider,
models: {
[selected.id]: selected,
...Object.fromEntries(
Array.from({ length: count - 1 }, (_, index) => {
const id = `catalog-model-${index}`
return [
id,
{
id,
name: `Catalog model ${index}`,
cost: { input: 1, output: 2 },
limit: { context: 200_000, output: 8192 },
variants: { high: { reasoningEffort: "high" } },
},
]
}),
),
},
},
],
},
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
const cdp = await page.context().newCDPSession(page)
const samples = []
for (let index = 0; index <= switches; index++) {
if (index > 0) {
const target = index % 2 === 1
const id = target ? fixture.targetID : fixture.sourceID
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`).click()
await expectSessionTitle(page, target ? fixture.expected.targetTitle : fixture.expected.sourceTitle)
await waitForStableTimeline(
page,
(target ? fixture.expected.targetMessageIDs : fixture.expected.sourceMessageIDs).at(-1)!,
)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
}
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
await cdp.send("HeapProfiler.collectGarbage")
samples.push({
switches: index,
heap: await cdp.send("Runtime.getHeapUsage"),
dom: await cdp.send("Memory.getDOMCounters"),
})
}
expect(samples).toHaveLength(switches + 1)
expect(samples.every((sample) => sample.heap.usedSize > 0)).toBe(true)
selected.name = "Updated catalog model"
await page.evaluate(
(event) => {
const host = window as Window & { __mockServerStream?: { push: (events: CatalogUpdated[]) => void } }
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
host.__mockServerStream.push([event])
},
{
id: "evt_catalog_refresh",
created: Date.now(),
type: "catalog.updated",
location: { directory: fixture.directory },
data: {},
} satisfies CatalogUpdated,
)
await expect(page.locator('[data-action="composer-model"]')).toContainText(selected.name)
report(
{ samples },
{ models: count, switches, gc: "explicit", scope: "renderer main isolate; not total desktop RAM" },
)
await cdp.detach()
})
@@ -11,73 +11,115 @@ import {
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
const scenarios = [
{ cached: false, review: false, resized: false },
{ cached: false, review: true, resized: false },
{ cached: true, review: false, resized: false },
{ cached: true, review: true, resized: false },
{ cached: true, review: true, resized: true },
]
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
scenarios.forEach((scenario) => {
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
benchmark(
"benchmarks session tab switching with and without the review pane",
async ({ browser, report }, testInfo) => {
benchmark.setTimeout(360_000)
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
const results = {
closed: { cold: [] as Result[], hot: [] as Result[] },
open: { cold: [] as Result[], hot: [] as Result[] },
}
for (const reviewPane of ["closed", "open"] as const) {
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < runs; run++) {
results[reviewPane][mode].push(
await withBenchmarkPage(
browser,
`session-tab-switch-${reviewPane}-${mode}-${run}`,
(page) => trial(page, mode, reviewPane),
testInfo,
),
)
}
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
}
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
},
)
return measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1),
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
},
testInfo,
)
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "open") {
const reviewDiffs = createReviewDiffs()
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
await installTimelineSettings(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (reviewPane === "open") {
await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
},
]),
)
}
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
return Object.fromEntries(
Object.entries(results).map(([reviewPane, values]) => [
reviewPane,
summarize(values as Record<"cold" | "hot", Result[]>),
]),
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
await expect(tab).toHaveCount(1)
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
async function openReviewPane(page: Page) {
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toBeVisible()
const panel = page.locator("#review-panel")
await expect(panel).toBeVisible()
await page.waitForFunction(() => {
const text = document.querySelector("#review-panel")?.textContent ?? ""
const panel = document.querySelector<HTMLElement>("#review-panel")
const text = panel?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
}
@@ -20,10 +20,9 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
samples,
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: stable < 0 ? null : samples[stable + 2].observedAtMs,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
@@ -1,67 +0,0 @@
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
},
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
@@ -25,7 +25,7 @@ async function installSessionSwitchProbe(
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
@@ -37,6 +37,7 @@ async function installSessionSwitchProbe(
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
@@ -62,30 +63,26 @@ async function installSessionSwitchProbe(
)
if (root) {
const view = root.getBoundingClientRect()
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
observedAtMs,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
@@ -97,7 +94,7 @@ async function installSessionSwitchProbe(
})
} else {
samples.push({
observedAtMs: performance.now() - started,
observedAtMs,
destination: [],
source: [],
hasVisibleRows: false,
@@ -110,25 +107,23 @@ async function installSessionSwitchProbe(
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
@@ -53,15 +53,6 @@ test("reports missing correctness without throwing", () => {
expect(result.stableObservedMs).toBeNull()
})
test("does not report stability for only two correct samples", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(16)
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
@@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
@@ -12,17 +12,17 @@ test("closing the active server's last tab opens the remaining server tab", asyn
const requests: string[] = []
await mockServers(page, requests)
await page.addInitScript(
({ serverA, serverB, sessionA, sessionB }) => {
({ serverB, sessionA, sessionB }) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: serverA, sessionId: sessionA },
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
]),
)
},
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
@@ -55,7 +55,7 @@ function session(id: string, directory: string, title: string) {
async function mockServers(page: Page, requests: string[]) {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await page.route("**/api/**", async (route) => {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
requests.push(url.toString())
@@ -1,103 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
status: "active",
tui: false,
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
@@ -308,7 +308,7 @@ async function mockServers(
permissionResponses: PermissionResponse[] = [],
options: MockServerOptions = {},
) {
await page.route("**/api/**", async (route) => {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const remote = url.origin === serverB
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
@@ -51,7 +51,7 @@ function session(id: string, directory: string, title: string) {
}
async function mockServers(page: Page) {
await page.route("**/api/**", async (route) => {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
const current = url.origin === serverA ? sessionA : sessionB
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -18,7 +18,7 @@ type InboxRow = {
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
function createQueueMock(seed: string[]) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
sessionID,
@@ -32,16 +32,13 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
) => {
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: type === "session.tool.success" ? 2 : 1 },
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
data,
} as OpenCodeEvent)
}
@@ -50,8 +47,6 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
prompts,
changes,
log,
messages,
emit,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
@@ -131,11 +126,10 @@ async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>,
directory,
title: "Session queue regression",
version: "dev",
model: { id: "queue-model", providerID: "opencode" },
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: mock.messages }),
pageMessages: () => ({ items: [] }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
@@ -233,130 +227,3 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
}, testInfo) => {
const model = { id: "queue-model", providerID: "opencode" }
const userID = "msg_queue_initial_user"
const assistantID = "msg_queue_continued_assistant"
const followUp = "U2: Also check the retry path."
const mock = createQueueMock(
[],
[
{ id: userID, type: "user", text: "U1: Inspect the queue ordering.", time: { created: 1700000000000 } },
{
id: "msg_queue_initial_assistant",
type: "assistant",
agent: "build",
model,
content: [{ type: "text", text: "A1: I will inspect the current implementation." }],
finish: "tool-calls",
time: { created: 1700000000001, completed: 1700000000002 },
},
],
)
const view = await openSession(page, mock, delivery)
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).toBeVisible()
await expect(view.input).toBeEditable()
await view.input.fill(followUp)
await view.input.press("Enter")
await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual([delivery])
await expect(view.input).toHaveText("")
const inboxID = mock.rows[0].id
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
if (delivery === "queue") {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
await expect(pending).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)
// 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 })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
]) {
const ref = { sessionID, assistantMessageID: assistantID, id: tool.id }
mock.emit("session.tool.input.started", { ...ref, name: tool.name })
mock.emit("session.tool.input.ended", { ...ref, text: JSON.stringify(tool.input) })
mock.emit("session.tool.called", { ...ref, input: tool.input, executed: true })
mock.emit("session.tool.success", {
...ref,
content: [{ type: "text", text: "Inspection complete." }],
executed: true,
})
}
mock.emit("session.step.ended", {
sessionID,
assistantMessageID: assistantID,
finish: "tool-calls",
cost: 0,
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(thinking).toBeVisible()
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
{ id: inboxID, delivery: "steer" },
])
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(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(), 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).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(
"data-message-id",
userID,
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
.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(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,121 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/settings-demo"
const sandboxes = Array.from({ length: 12 }, (_, index) => `${directory}/workspace-${index + 1}`)
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
directory,
projectID: "proj_settings_demo",
time: { created: 1700000000000, updated: 1700000000000 },
})),
pageMessages: () => ({ items: [] }),
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Workspaces", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await expect(settings.getByText("No workspaces", { exact: true })).toHaveCount(0)
inventory.resolve()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await expect(settings.getByText("12 workspaces", { exact: true })).toBeVisible()
sessions.resolve()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
await route.fulfill({
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
})
})
await page.route("**/api/plugin", async (route) => {
await plugins.promise
await route.fulfill({
json: {
location: { directory },
data: [
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const list = settings.locator('[data-component="settings-list"]')
await expect(list).toHaveCSS("max-height", "none")
await expect(list).toHaveCSS("overflow-y", "visible")
await settings.getByText("Workspace 12 session", { exact: true }).scrollIntoViewIfNeeded()
await expect(settings.getByText("Workspace 12 session", { exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 844 })
await expect(list).toHaveCSS("max-height", "none")
await expect(list).toHaveCSS("overflow-y", "visible")
await settings.getByText("Workspace 12 session", { exact: true }).scrollIntoViewIfNeeded()
await expect(settings.getByText("Workspace 12 session", { exact: true })).toBeInViewport()
})
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession } from "../utils/mock-server"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
@@ -238,7 +238,7 @@ function session(id: string, title: string) {
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB, sessionC]
await page.route("**/api/**", async (route) => {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
+1 -3
View File
@@ -144,14 +144,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
page.on("close", () => void transport.dispose())
await page.route("**/api/**", async (route) => {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.origin !== server && url.port !== appPort) return route.fallback()
// Production serves the UI and API from one origin; leave app assets to Vite.
if (!url.pathname.startsWith("/api/")) return route.fallback()
if (route.request().method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: corsHeaders })
}
-1
View File
@@ -25,7 +25,6 @@
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
+12 -23
View File
@@ -2,19 +2,10 @@ import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
const url = new URL(baseURL)
if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// app URL")
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
// Production connects to its own origin, so fixture URLs must match the preview server.
if (built) {
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
}
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = built
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -32,18 +23,16 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command,
url: baseURL,
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
webServer: {
command,
url: baseURL,
reuseExistingServer: reuse,
timeout: 120_000,
env: {
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
},
use: {
baseURL,
trace: "on-first-retry",
+1 -1
View File
@@ -17,7 +17,7 @@ export function Home() {
return (
<div
class={`
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
+1 -1
View File
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
</div>
@@ -1,16 +1,10 @@
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { unwrap } from "solid-js/store"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const providerCatalogs = new WeakMap<
ProviderListOutput["data"],
WeakMap<ModelListOutput["data"], ProviderListResponse>
>()
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
if (input.every((agent) => !("request" in agent))) return input as Agent[]
return (input as AgentListOutput["data"]).map((agent) => ({
@@ -36,16 +30,12 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
}
export function normalizeProviderList(
input: ProviderListOutput["data"] | ProviderListResponse,
catalog?: ModelListOutput["data"],
providers: ProviderListOutput["data"] | ProviderListResponse,
models?: ModelListOutput["data"],
): ProviderListResponse {
if (!Array.isArray(input)) return input
// Client sync replaces whole catalog lists. Track those reads at the caller,
// not every model field, and share conversions without retaining old lists.
const providers = unwrap(input)
const models = unwrap(catalog)
const cached = models && providerCatalogs.get(providers)?.get(models)
if (cached) return cached
if (!Array.isArray(providers)) {
return providers
}
const all = new Map<string, Provider>()
for (const provider of providers) {
@@ -111,7 +101,7 @@ export function normalizeProviderList(
}
}
const result = {
return {
all,
connected: providers.map((provider) => provider.id),
default: Object.fromEntries(
@@ -121,12 +111,6 @@ export function normalizeProviderList(
}),
),
}
if (models) {
const cache = providerCatalogs.get(providers) ?? new WeakMap<ModelListOutput["data"], ProviderListResponse>()
cache.set(models, result)
providerCatalogs.set(providers, cache)
}
return result
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
+4 -2
View File
@@ -44,7 +44,9 @@ function TargetSessionSettingsCommand() {
return null
}
function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>) {
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
) {
return (
<ErrorBoundary
fallback={(error) => (
@@ -104,7 +106,7 @@ function PendingSessionState(props: { sessionID: string }) {
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex min-h-0 flex-1 p-2">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
+2 -1
View File
@@ -8,7 +8,7 @@ import type { SessionModel } from "./model"
import { sessionPanelLayout } from "./session-panel-layout"
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
export function createSessionScreenLayout(session: SessionModel) {
export function createSessionScreenLayout(session: SessionModel, serverScope: string) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
@@ -92,6 +92,7 @@ export function createSessionScreenLayout(session: SessionModel) {
centered: createMemo(() => session.isDesktop()),
files: { open: fileTreeOpen },
panel: {
key: createMemo(() => (session.identity.params.id ? `${serverScope}\0${session.identity.params.id}` : undefined)),
max: panelMax,
ref: (element: HTMLDivElement) => {
row = element
+10 -6
View File
@@ -4,6 +4,7 @@ import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import type { SessionModel } from "@/session/model"
@@ -22,9 +23,10 @@ import { SessionIdentityHeader } from "./session-identity-header"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const serverSDK = useServerSDK()
const settings = useSettings()
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session)
const screen = createSessionScreenLayout(session, serverSDK.scope)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({
@@ -162,7 +164,7 @@ export function SessionScreen(props: { session: SessionModel }) {
return (
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
classList={{
@@ -175,10 +177,12 @@ export function SessionScreen(props: { session: SessionModel }) {
width: screen.panel.width(),
}}
>
<Show when={!!session.identity.params.id}>
<SessionPanelFrame raised>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
<Show when={screen.panel.key()} keyed>
{(_) => (
<SessionPanelFrame raised={!!session.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
<Show when={screen.panel.resizable()}>
+1 -4
View File
@@ -2,10 +2,7 @@ import type { ParentProps } from "solid-js"
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div
class="relative flex size-full flex-col overflow-hidden"
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
>
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
{props.children}
</div>
)
@@ -1,8 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -19,105 +17,6 @@ const messages = [
] satisfies SessionMessageInfo[]
describe("visibleTimelineMessages", () => {
const steer = {
id: "msg_3",
sessionID: "ses_1",
timeCreated: 3,
type: "user",
delivery: "steer",
payload: { text: "queued" },
} satisfies SessionInboxInfo
const work = {
id: "msg_5",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_read",
name: "read",
state: {
status: "completed",
input: { filePath: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 5, completed: 6 },
},
],
time: { created: 5, completed: 6 },
} satisfies SessionMessageInfo
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"])
expect(source.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_3", "msg_5"])
expect(visible[2]).toBe(work)
createRoot((dispose) => {
const projection = createTimelineProjection({
sessionMessages: () => visible,
status: () => ({ type: "busy" }),
showReasoningSummaries: () => false,
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
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"],
])
expect(
projection
.assistantMessagesByParent()
.get("msg_1")
?.map((message) => message.id),
).toEqual(["msg_2", "msg_5"])
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
dispose()
})
})
test("moves a queued input after existing work when changed to steer", () => {
const source = [...messages.slice(0, 3), work]
expect(visibleTimelineMessages(source, [{ ...steer, delivery: "queue" }]).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
])
expect(visibleTimelineMessages(source, [steer]).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
"msg_3",
])
const delivered = [messages[0], messages[1], work, messages[2]]
expect(visibleTimelineMessages(delivered, [])).toBe(delivered)
})
test("preserves steer order and excludes reverted steers", () => {
const source = [...messages, work]
const pending = [steer, { ...steer, id: "msg_4" }]
expect(visibleTimelineMessages(source, pending).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
"msg_3",
"msg_4",
])
expect(visibleTimelineMessages(source, pending, "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_3",
])
})
test("hides queued inputs until delivery", () => {
const pending = [
{
@@ -17,19 +17,8 @@ export function visibleTimelineMessages(
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const steers = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])),
)
if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages
const visible = messages.filter(
(message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID),
)
if (steers.size === 0) return visible
// Pending steers do not own assistant work until they are delivered.
return [
...visible.filter((message) => !steers.has(message.id)),
...visible.filter((message) => steers.has(message.id)),
]
if (queued.size === 0 && !revertMessageID) return messages
return messages.filter((message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID))
}
export function timelineChildTitle(input: {
@@ -481,6 +481,7 @@ function MessageTimelineView(
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
@@ -1,15 +1,8 @@
import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import {
batch,
createEffect,
createMemo,
createSignal,
@@ -62,6 +55,7 @@ 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
}
@@ -74,12 +68,11 @@ export function createTimelineVirtualizer(input: Input) {
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [overscan, setOverscan] = createSignal(2)
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const knownKeys = new Set(rows().map(TimelineRow.key))
const addedKeys = new Set<string>()
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
@@ -99,19 +92,14 @@ export function createTimelineVirtualizer(input: Input) {
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
// A newly observed element gets a real ResizeObserver box before paint. Reuse
// its snapshot on attachment, but later explicit measurements must read layout
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
measureElement: (element, entry, instance) => {
const initial = !measuredElements.has(element)
measuredElements.add(element)
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
// on the cheap precomputed path, but make explicit measurements read the real height.
measureElement: (element, entry) => {
const box = entry?.borderBoxSize[0]
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined) return size
}
return element.offsetHeight
return box ? Math.round(box.blockSize) : element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
@@ -143,59 +131,41 @@ export function createTimelineVirtualizer(input: Input) {
return input.showHeader() ? 64 : 0
},
paddingEnd: 64,
get rangeExtractor() {
rangeExtractor: (range) => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const buffer = overscan()
return (range: Range) => {
const indexes = defaultRangeExtractor({ ...range, overscan: buffer })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
},
})
const resizeItem = virtualizer.resizeItem
const pendingSizes = new Map<number, { key: string; size: number }>()
let resizeScheduled = false
// Read the whole measurement delivery before committing reactive row sizes.
// Otherwise each row can render and force layout before the next is measured.
virtualizer.resizeItem = (index, size) => {
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if (virtualizer.itemSizeCache.get(key) === size) {
pendingSizes.delete(index)
return
}
pendingSizes.set(index, { key, size })
if (resizeScheduled) return
resizeScheduled = true
let resizeAnchorScheduled = false
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
// reconciles the end. Coalesce one correction per measurement batch, before paint.
const anchorResizedBottom = () => {
if (resizeAnchorScheduled) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeScheduled = false
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
})
})
resizeAnchorScheduled = false
if (!input.pinned()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
virtualizer.scrollToEnd()
virtualizer.scrollToEnd()
})
}
onCleanup(() => pendingSizes.clear())
virtualizer.resizeItem = (index, size) => {
resizeItem(index, size)
if (listRoot() && input.pinned()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
if (addedKeys.has(String(item.key)))
return item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
return (
item.end <=
(instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
)
const first = instance.range?.startIndex
return first !== undefined && item.index < first
}
@@ -216,41 +186,50 @@ export function createTimelineVirtualizer(input: Input) {
})
})
let settleFrame: number | undefined
let overscanFrame: number | undefined
let overscanTimer: number | undefined
const expandOverscan = () => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
// Let the visible rows paint before building the normal interaction buffer.
overscanTimer = window.setTimeout(() => {
overscanTimer = undefined
setOverscan(20)
}, 0)
})
}
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleFrame = requestAnimationFrame(settleColdBottom)
overscanFrame = requestAnimationFrame(settleColdBottom)
return
}
settleFrame = requestAnimationFrame(() => {
overscanFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleColdBottom()
return
}
settleFrame = undefined
virtualContent?.style.removeProperty("visibility")
expandOverscan()
overscanFrame = undefined
const content = virtualContent
if (!content) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
content.style.removeProperty("visibility")
return
}
const animation = ["animate-in", "fade-in", "duration-150"]
const clearAnimation = (event: AnimationEvent) => {
if (event.target !== content) return
content.removeEventListener("animationend", clearAnimation)
content.removeEventListener("animationcancel", clearAnimation)
content.classList.remove(...animation)
}
content.addEventListener("animationend", clearAnimation)
content.addEventListener("animationcancel", clearAnimation)
content.classList.add(...animation)
content.style.removeProperty("visibility")
})
}
onMount(() => {
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
if (!coldBottomMount) expandOverscan()
overscanFrame = requestAnimationFrame(() => {
if (renderOverscan() < 20) setRenderOverscan(20)
if (!coldBottomMount) {
overscanFrame = undefined
return
}
settleColdBottom()
})
})
let measuredSessionKey = input.sessionKey()
@@ -277,13 +256,11 @@ export function createTimelineVirtualizer(input: Input) {
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
setOverscan(20)
}
const handleListTouchStart = (event: TouchEvent) => {
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
setOverscan(20)
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
@@ -300,19 +277,14 @@ export function createTimelineVirtualizer(input: Input) {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
setOverscan(20)
const release = () => {
pointerHeld = false
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
}
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
}
const releasePointer = () => {
pointerHeld = false
}
onMount(() => {
window.addEventListener("pointerup", releasePointer)
window.addEventListener("pointercancel", releasePointer)
})
onCleanup(() => {
window.removeEventListener("pointerup", releasePointer)
window.removeEventListener("pointercancel", releasePointer)
})
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
@@ -321,7 +293,6 @@ export function createTimelineVirtualizer(input: Input) {
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
setOverscan(20)
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
@@ -400,7 +371,7 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0">
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
@@ -444,6 +415,7 @@ export function createTimelineVirtualizer(input: Input) {
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
class="motion-reduce:animate-none"
ref={(element) => {
virtualContent = element
input.setContentRef(element)
@@ -475,9 +447,7 @@ export function createTimelineVirtualizer(input: Input) {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
@@ -27,7 +27,6 @@ export const SettingsExtensions: Component = () => {
const [mcpList, { refetch: refetchMcp }] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.mcp.list().then((result) => result.data),
{ initialValue: [] },
)
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
const mcps = createMemo<McpRowItem[]>(() => {
@@ -45,7 +44,6 @@ export const SettingsExtensions: Component = () => {
const [pluginList] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
{ initialValue: [] },
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
+6
View File
@@ -885,6 +885,8 @@
display: flex;
flex-direction: column;
gap: 0;
max-height: 480px;
overflow-y: auto;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
@@ -1046,6 +1048,8 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
@@ -1078,6 +1082,8 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
@@ -97,12 +97,10 @@ export const ProjectSettingsExtensions: Component = () => {
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
{ initialValue: [] },
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
{ initialValue: [] },
)
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const projectPlugins = createMemo(() => {
@@ -60,7 +60,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
enabled: serverSDK.connection.status() === "connected",
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
@@ -72,9 +71,10 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
),
refetchOnMount: "always",
}))
const inventory = createMemo(() => (projectQuery.isPending ? [] : (projectQuery.data ?? [])))
const workspaces = createMemo(() => workspaceInventory(inventory()))
const projects = createMemo(() => inventory().filter((project) => managedWorkspaceDirectories(project).length > 0))
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
const projects = createMemo(() =>
(projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
@@ -110,18 +110,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
enabled: workspaceDirectories().length > 0,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
const sessions = sessionQuery.isPending ? [] : (sessionQuery.data ?? [])
return new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionsForWorkspace(sessions, workspace.directory),
]),
)
})
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
]),
),
)
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
@@ -279,9 +279,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-tab-body settings-workspaces">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
{language.plural("settings.workspaces.count", filtered().length)}
</Show>
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
@@ -334,17 +332,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-empty">
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
</div>
}
fallback={<div class="settings-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsList>
<For each={filtered()}>
+1 -1
View File
@@ -36,7 +36,7 @@ export function AppRoutes() {
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
<div class="flex min-h-0 flex-1 p-2">
<SessionPanelFrame raised />
</div>
}
+1 -8
View File
@@ -43,13 +43,6 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
: "8px",
}}
>
<Titlebar
@@ -66,7 +59,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
style={{ width: `${state.tabsWidth}px` }}
>
<ResizeHandle
+1 -2
View File
@@ -27,7 +27,6 @@ import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
const titlebarHeight = 36
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
@@ -60,7 +59,7 @@ export function Titlebar(props: {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `env(titlebar-area-height, ${windowsTitlebarHeight / Math.min(titlebarZoom(), 1)}px)`
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -1,70 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
test.each([
{ name: "local development", ci: "", build: "", built: false },
{ name: "local production build", ci: "", build: "1", built: true },
{ name: "CI production build", ci: "true", build: "", built: true },
{ name: "CI cannot opt into development", ci: "true", build: "0", built: true },
])("Playwright uses $name", ({ ci, build, built }) => {
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", 'import config from "./playwright.config.ts"; console.log(JSON.stringify(config))'],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
CI: ci,
PLAYWRIGHT_BUILD: build,
PLAYWRIGHT_BASE_URL: undefined,
PLAYWRIGHT_PORT: "4321",
PLAYWRIGHT_SERVER_HOST: "127.0.0.2",
PLAYWRIGHT_SERVER_PORT: "4322",
},
})
expect(result.exitCode).toBe(0)
const config = JSON.parse(result.stdout.toString())
expect(config.use.baseURL).toBe("http://127.0.0.1:4321")
expect(config.webServer.url).toBe(config.use.baseURL)
expect(config.webServer.command).toBe(
built
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
)
expect(config.webServer.reuseExistingServer).toBe(!built)
expect(config.webServer.env).toEqual({
VITE_OPENCODE_SERVER_HOST: built ? "127.0.0.1" : "127.0.0.2",
VITE_OPENCODE_SERVER_PORT: built ? "4321" : "4322",
})
})
test.each([
"./playwright.config.ts",
"./e2e/performance/playwright.config.ts",
"./e2e/performance/playwright.uncapped.config.ts",
"./e2e/performance/timeline-stability/playwright.config.ts",
])("%s leaves an explicit external app unmanaged", (file) => {
const result = Bun.spawnSync({
cmd: [
process.execPath,
"-e",
`import config from ${JSON.stringify(file)}; console.log(JSON.stringify({ ...config, fixtureHost: process.env.PLAYWRIGHT_SERVER_HOST, fixturePort: process.env.PLAYWRIGHT_SERVER_PORT }))`,
],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "http://127.0.0.1:4444" },
})
expect(result.exitCode).toBe(0)
const config = JSON.parse(result.stdout.toString())
expect(config.webServer).toBeUndefined()
expect(config.use.baseURL).toBe("http://127.0.0.1:4444")
expect(config.fixtureHost).toBe("127.0.0.1")
expect(config.fixturePort).toBe("4444")
})
test("Playwright rejects HTTPS targets unsupported by the API fixtures", () => {
const result = Bun.spawnSync({
cmd: [process.execPath, "-e", 'import "./playwright.config.ts"'],
cwd: path.resolve(import.meta.dirname, ".."),
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "https://e2e.example.com" },
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toString()).toContain("E2E fixtures require an http:// app URL")
})
@@ -1,52 +0,0 @@
import { expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
test("preserves an already normalized reactive catalog", () => {
const [store] = createStore({ catalog: { all: new Map<string, never>(), connected: [], default: {} } })
expect(normalizeProviderList(store.catalog)).toBe(store.catalog)
})
test("shares catalog snapshots and reacts to replacement lists", () => {
createRoot((dispose) => {
const provider = { id: "openai", name: "OpenAI", package: "@ai-sdk/openai", activation: "enabled" as const }
const model = {
id: "gpt-5",
modelID: "gpt-5",
providerID: "openai",
name: "GPT-5",
settings: {},
headers: {},
capabilities: { tools: true, input: ["text" as const], output: ["text" as const] },
variants: [],
time: { released: 1 },
cost: [],
status: "active" as const,
enabled: true,
limit: { context: 128_000, output: 8192 },
}
const [store, setStore] = createStore({ providers: [provider], models: [model] })
const first = createMemo(() => normalizeProviderList(store.providers, store.models))
const second = createMemo(() => normalizeProviderList(store.providers, store.models))
expect(first()).toBe(second())
const initial = first()
setStore("models", [{ ...model, name: "Renamed", limit: { context: 256_000, output: 16384 } }])
expect(first()).not.toBe(initial)
expect(first()).toBe(second())
expect(first().all.get("openai")?.models[model.id]).toMatchObject({
name: "Renamed",
limit: { context: 256_000, output: 16384 },
})
const renamed = first()
setStore("providers", [{ ...provider, name: "Custom OpenAI" }])
expect(first()).not.toBe(renamed)
expect(first()).toBe(second())
expect(first().all.get("openai")?.name).toBe("Custom OpenAI")
expect(normalizeProviderList(store.providers).all.get("openai")?.models).toEqual({})
setStore("models", [])
expect(first().all.get("openai")?.models).toEqual({})
expect(first().default).toEqual({})
dispose()
})
})
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+1 -1
View File
@@ -118,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.12",
"@opencode-ai/pty": "0.1.11",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+31 -97
View File
@@ -11,9 +11,6 @@ import { KeyedMutex } from "./effect/keyed-mutex.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import type { SessionID } from "@opencode-ai/schema/session-id"
import { AbsolutePath } from "@opencode-ai/schema/schema"
export type Subscriber<D extends Event.Definition = Event.Definition> = (event: Event.Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
@@ -122,9 +119,6 @@ export interface Subscribe {
/**
* Volatile live channel: every event published from now on, nothing before or
* across a disconnect. Consumers that need reliability combine it with `log`.
* With an ambient Location, delivery is restricted to that Location and global
* events. Unlocated Session events use the Session's owner at publication time.
* Session moves reach both the old and new Location, without changing the event.
*/
(): Stream.Stream<Event.Payload>
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
@@ -189,7 +183,6 @@ export function configured(options?: Options) {
// Deferred import: a static one would close the module cycle
// bus → location → project → bus and hit the node bindings in TDZ.
const { Location } = yield* Effect.promise(() => import("./location.js"))
const { SessionTable } = yield* Effect.promise(() => import("./session/sql.js"))
const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -201,64 +194,6 @@ export function configured(options?: Options) {
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
const sessions = new Map<SessionID, Location.Ref>()
// Keep routing separate from the public event, and retain its snapshot
// while a slow subscriber drains events queued before a move or deletion.
const routes = new WeakMap<Event.Payload, readonly Location.Ref[]>()
const isSessionEvent = (event: Event.Payload): event is SessionEvent.Event =>
Object.hasOwn(SessionEvent.All.cases, event.type)
const prepareRoutes = Effect.fnUntraced(function* (events: readonly Event.Payload[]) {
const updates = new Map<SessionID, Location.Ref | undefined>()
const resolved = new Map<Event.Payload, readonly Location.Ref[]>()
for (const event of events) {
if (!isSessionEvent(event)) continue
const id = event.data.sessionID
if (event.type === "session.created") {
updates.set(id, event.data.location)
resolved.set(event, [event.location ?? event.data.location])
continue
}
if (event.location && event.type !== "session.forked" && event.type !== "session.moved") {
if (event.type === "session.deleted") updates.set(id, undefined)
continue
}
const owner = event.type === "session.forked" ? event.data.parentID : id
let ref = updates.has(owner) ? updates.get(owner) : sessions.get(owner)
if (!ref && !updates.has(owner)) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, owner))
.get()
.pipe(Effect.orDie)
ref = row
? { directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }
: undefined
updates.set(owner, ref)
}
if (event.type === "session.moved") {
// Both owners need the transition, even if the producer supplied
// an envelope location. Later events use only the destination.
updates.set(id, event.data.location)
resolved.set(event, ref ? [ref, event.data.location] : [event.data.location])
continue
}
if (event.type === "session.forked") updates.set(id, ref)
resolved.set(event, event.location ? [event.location] : ref ? [ref] : [])
if (event.type === "session.deleted") updates.set(id, undefined)
}
// Apply only after the projection transaction commits. A failed move
// must not redirect events away from the Session's actual location.
return () => {
for (const [id, ref] of updates) {
if (ref) sessions.set(id, ref)
else sessions.delete(id)
}
for (const [event, ref] of resolved) routes.set(event, ref)
}
})
const getOrCreate = (definition: Event.Definition) =>
Effect.gen(function* () {
@@ -400,7 +335,6 @@ export function configured(options?: Options) {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
@@ -432,13 +366,12 @@ export function configured(options?: Options) {
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
return { aggregateID, seq }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
@@ -476,14 +409,15 @@ export function configured(options?: Options) {
Effect.gen(function* () {
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (!committed) return event
event = committed.event as Event.Payload<D>
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
return event
}),
)
}
const route = yield* prepareRoutes([event as Event.Payload])
route()
yield* notify(event as Event.Payload, false)
return event
})
@@ -593,11 +527,7 @@ export function configured(options?: Options) {
.pipe(Effect.orDie)
const firstSeq = (row?.seq ?? -1) + 1
const finalSeq = firstSeq + payloads.length - 1
const queued = payloads.map((item, index) => ({
...item.event,
durable: envelope(aggregateID, firstSeq + index, item.definition.durable.version),
}))
const route = yield* prepareRoutes(queued)
const result = new Array<Event.Payload>()
const rows = new Array<typeof EventTable.$inferInsert>()
const ids = new Set<Event.ID>()
for (const [index, item] of payloads.entries()) {
@@ -629,7 +559,10 @@ export function configured(options?: Options) {
}),
)
}
const event = queued[index]
const event = {
...item.event,
durable: envelope(aggregateID, seq, item.definition.durable.version),
} as Event.Payload
for (const projector of projectors.get(
versionedType(item.definition.type, item.definition.durable.version),
) ?? []) {
@@ -645,6 +578,7 @@ export function configured(options?: Options) {
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
result.push(event)
}
yield* db
.insert(EventSequenceTable)
@@ -653,12 +587,11 @@ export function configured(options?: Options) {
.run()
.pipe(Effect.orDie)
if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
return { events: queued, route }
return result
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
committed.route()
yield* Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
@@ -666,8 +599,8 @@ export function configured(options?: Options) {
discard: true,
},
)
yield* Effect.forEach(committed.events, (event) => notify(event, true), { discard: true })
return committed.events as PublishResult<I>
yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
return committed as PublishResult<I>
}),
),
)
@@ -699,7 +632,13 @@ export function configured(options?: Options) {
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(committed.event, true)
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
@@ -714,10 +653,7 @@ export function configured(options?: Options) {
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(
Effect.tap(() => Effect.sync(() => sessions.delete(aggregateID as SessionID))),
Effect.orDie,
)
.pipe(Effect.orDie)
}
function claim(aggregateID: string, ownerID: string) {
@@ -735,17 +671,15 @@ export function configured(options?: Options) {
Effect.map((location) =>
Option.match(location, {
onNone: () => stream,
onSome: (location) => {
const matches = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID
return stream.pipe(
Stream.filter((event) => {
const refs = routes.get(event)
if (refs) return refs.some(matches)
return !event.location || matches(event.location)
}),
)
},
onSome: (location) =>
stream.pipe(
Stream.filter(
(event) =>
!event.location ||
(event.location.directory === location.directory &&
event.location.workspaceID === location.workspaceID),
),
),
}),
),
),
-2
View File
@@ -507,11 +507,9 @@ const layer = Layer.effect(
from: TreeID
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
+1 -1
View File
@@ -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>({
+8 -1
View File
@@ -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
View File
@@ -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 }
}),
})
}),
+4 -4
View File
@@ -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 }
-38
View File
@@ -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,
})
}),
}
}
+1 -1
View File
@@ -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[]>()
+20 -1
View File
@@ -73,7 +73,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
return {
app,
location: locationInfo(),
options: {},
agent: {
get: (input) => {
@@ -289,6 +288,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
return response(mcp.servers())
},
add: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
return mcp.add(input.server, input.config)
},
remove: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
return mcp.remove(input.server)
},
connect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
return mcp.connect(input.server)
},
disconnect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
return mcp.disconnect(input.server)
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((draft) => {
+17
View File
@@ -2,6 +2,7 @@ export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
@@ -39,6 +40,10 @@ export interface Interface {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
}
}
}
@@ -96,6 +101,10 @@ export const layerWithCell = (cell: Cell) =>
},
mcp: {
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
},
},
}),
@@ -140,6 +149,14 @@ export const providerLayerWithCell = (cell: Cell) =>
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
add: (ref, server, config) =>
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
remove: (ref, server) =>
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
connect: (ref, server) =>
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
disconnect: (ref, server) =>
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
},
},
}
+6 -10
View File
@@ -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 })
+67 -116
View File
@@ -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"
@@ -56,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
//
@@ -190,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[]
}>
@@ -345,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
@@ -353,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,
@@ -364,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)
@@ -391,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
@@ -547,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 = {}) {
@@ -686,24 +648,16 @@ const layer = Layer.effect(
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 resolved = input.files?.length || input.skills?.length ? yield* resolve.resolve(session) : undefined
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
if (resolved?.status === "owned-detached") return yield* SessionResolve.unavailable(session.id)
const capabilities = resolved?.status === "attached" ? resolved.capabilities : undefined
const image = capabilities
? Effect.succeed(capabilities.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 = capabilities
? Effect.undefined
: Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
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,
@@ -736,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)
@@ -824,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 })
@@ -1068,7 +1021,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
skills: Effect.Effect<Skill.Interface | undefined>,
skills: Effect.Effect<Skill.Interface>,
) {
const fs = yield* FSUtil.Service
const files = input.files
@@ -1078,7 +1031,6 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
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* () {
@@ -1249,7 +1201,6 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
SessionResolve.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
-38
View File
@@ -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>
}
+10 -14
View File
@@ -8,10 +8,9 @@ import { Bus } from "../bus.js"
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 { SessionMessage } from "./message.js"
import type { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
@@ -70,13 +69,14 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
@@ -86,9 +86,6 @@ export type ManualInput = {
readonly messages: readonly SessionMessage.Info[]
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 = {
@@ -99,7 +96,6 @@ type Plan = {
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
readonly prepare: SessionModelRequest.Interface["prepare"]
}
export type Outcome =
@@ -282,7 +278,7 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const prepared = yield* plan.prepare({
const prepared = yield* dependencies.modelRequests.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
@@ -352,7 +348,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
@@ -392,7 +387,7 @@ const make = (dependencies: Dependencies) => {
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
const resolved = yield* dependencies.models.resolve(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
@@ -406,7 +401,6 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
@@ -428,12 +422,14 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
return make({ bus, llm })
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return make({ bus, llm, models, modelRequests })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient],
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
})
+16 -198
View File
@@ -1,8 +1,7 @@
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"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -12,23 +11,14 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { Model } from "../model.js"
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"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
@@ -38,8 +28,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,33 +37,19 @@ export interface Loaded {
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly tools: Tool.Snapshot
readonly system?: Selection["system"]
}
/**
* Resolves model-request state in two phases: `select` fixes the Session,
* agent, instruction sources, and tool snapshot; `load` adds the model and
* active history for that selection. Auxiliary operations resolve only the
* capabilities they need; request preparation stays separate from selection.
* active history for that selection. This module does not build or execute the
* model request.
*/
export interface Interface {
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
readonly select: (sessionID: SessionSchema.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: (
session: SessionSchema.Info,
) => Effect.Effect<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
/** Selects auxiliary title capabilities without instruction or tool preflight. */
readonly selectTitle: (session: SessionSchema.Info) => Effect.Effect<
| {
readonly agent: Agent.Info
readonly primary: SessionRunnerModel.Resolved | undefined
readonly selected: SessionRunnerModel.Resolved
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -86,7 +60,6 @@ const layer = Layer.effect(
Effect.gen(function* () {
const agents = yield* Agent.Service
const builtins = yield* InstructionBuiltIns.Service
const catalog = yield* Catalog.Service
const db = (yield* Database.Service).db
const discovery = yield* InstructionDiscovery.Service
const entries = yield* InstructionEntry.Service
@@ -94,41 +67,12 @@ const layer = Layer.effect(
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
const registry = yield* Tool.Service
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, catalog.model.available)
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
const agent = yield* agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* resolveModel(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* resolveModel({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
}).pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
return { agent, primary, selected }
})
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -167,153 +111,28 @@ 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* models.resolve(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 })
}),
)
/** 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))
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Agent.node,
Catalog.node,
Database.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
@@ -324,7 +143,6 @@ export const node = makeLocationNode({
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
SkillInstructions.node,
Tool.node,
+9 -23
View File
@@ -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. */
+9 -28
View File
@@ -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],
})
+6 -3
View File
@@ -9,6 +9,7 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
export const layer = Layer.effect(
SessionGenerate.Service,
@@ -16,11 +17,13 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const database = yield* Database.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* context.resolveModel(selection.session)
const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
@@ -29,7 +32,7 @@ export const layer = Layer.effect(
initial: history.initial,
messages: history.messages,
})
const prepared = yield* context.prepare({
const prepared = yield* modelRequests.prepare({
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
@@ -56,5 +59,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, llmClient],
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
})
@@ -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
+3 -5
View File
@@ -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)
-291
View File
@@ -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}`))
+1 -5
View File
@@ -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".
+5 -11
View File
@@ -30,12 +30,13 @@ 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
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -141,8 +142,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
resolveModel: context.resolveModel,
prepare: context.prepare,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
@@ -216,12 +215,7 @@ export const layer = Layer.effect(
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
@@ -235,9 +229,8 @@ export const layer = Layer.effect(
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
system: loaded.system,
})
const prepared = yield* context.prepare({
const prepared = yield* modelRequests.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
@@ -326,6 +319,7 @@ export const node = makeLocationNode({
Bus.node,
llmClient,
SessionContext.node,
SessionModelRequest.node,
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
+6 -8
View File
@@ -3,6 +3,7 @@ export * as SessionRunnerModel from "./model.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "../../catalog.js"
import { ModelResolver } from "../../model-resolver.js"
import { Capabilities, ID, Info, Ref, VariantID } from "../../model.js"
import { Provider } from "../../provider.js"
@@ -40,11 +41,7 @@ export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolve
export type Resolved = ModelResolver.Resolved
export interface Interface {
/** Availability is sampled lazily for each explicitly selected model resolution. */
readonly resolve: (
session: SessionSchema.Info,
available: () => Effect.Effect<ReadonlyArray<Info>>,
) => Effect.Effect<Resolved, Error>
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunnerModel") {}
@@ -73,16 +70,17 @@ export const resolved = (
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const resolver = yield* ModelResolver.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
if (!session.model) {
const resolved = yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* available()).find(
const selected = (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
if (!selected)
@@ -96,4 +94,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
@@ -157,7 +157,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
if (item.type === "reasoning")
return reuseProviderMetadata
? [
@@ -168,7 +167,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
},
]
: item.text.length > 0
? [{ type: message.error === undefined ? "reasoning" : "text", text: item.text }]
? [{ type: "text", text: item.text }]
: []
// Call-side metadata is model-scoped proof of generation (Gemini thought
// signatures, OpenAI encrypted reasoning): only the producing model may
+1 -1
View File
@@ -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>
/**
+55 -13
View File
@@ -4,16 +4,18 @@ import { isDeepStrictEqual } from "node:util"
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import type { Agent } from "../agent.js"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionContext } from "./context.js"
import { Model } from "../model.js"
import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
@@ -28,7 +30,10 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly context: SessionContext.Interface
readonly agents: Agent.Interface
readonly catalog: Catalog.Interface
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
readonly store: SessionStore.Interface
}
@@ -67,7 +72,7 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
})
: Effect.void,
)
const prepared = yield* dependencies.context.prepare({
const prepared = yield* dependencies.modelRequests.prepare({
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
@@ -101,6 +106,9 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
.find((line) => line.length > 0)
})
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
const make = (dependencies: Dependencies) => {
const generate = Effect.fn("SessionTitle.generate")(function* (
db: Database.Interface["db"],
@@ -132,12 +140,34 @@ const make = (dependencies: Dependencies) => {
Effect.orElseSucceed(() => firstUser.text),
)
: firstUser.text
const selection = yield* dependencies.context.selectTitle(session)
if (!selection) return
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* dependencies.models.resolve(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* dependencies.catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* dependencies.catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* dependencies.models
.resolve({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
})
.pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
const title =
(yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.selected })) ??
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
? yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.primary })
(yield* attempt(dependencies, { session, agent, text, model: selected })) ??
(primary && !isDeepStrictEqual(selected.ref, primary.ref)
? yield* attempt(dependencies, { session, agent, text, model: primary })
: undefined)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
@@ -162,10 +192,13 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const context = yield* SessionContext.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const title = make({ bus, llm, context, store })
const title = make({ bus, llm, agents, catalog, models, modelRequests, store })
return Service.of({
generate: (sessionID) => title.generate(database.db, sessionID),
})
@@ -175,5 +208,14 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
deps: [
Bus.node,
llmClient,
Agent.node,
Catalog.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
Database.node,
],
})
-37
View File
@@ -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"
}
+1 -1
View File
@@ -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
+205 -208
View File
@@ -1,9 +1,10 @@
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"
import type { ToolDraft } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Agent } from "./agent.js"
@@ -23,20 +24,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Draft {
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
readonly add: (tool: Tool.Info) => void
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
readonly remove: (id: string) => void
}
type Data = {
tools: Map<string, Tool.Info & { readonly id: string }>
errors: { tool: Tool.Info; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<ToolDraft> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -54,209 +42,218 @@ export interface Snapshot {
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 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 base = {
tool: name,
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
id: context.id,
input: beforeEvent.input,
}
if ("failure" in execution) {
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 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) => 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 state = State.create({
name: "tool",
initial: () => ({
tools: new Map<string, Types.Mutable<Tool.Info>>(),
errors: new Array<{ tool: Tool.Info; error: RegistrationError }>(),
}),
),
})
})
draft: (data) => data,
finalize: (draft) =>
Effect.forEach(
draft.errors,
(entry) =>
Effect.logError("Skipping invalid tool registration", {
name: entry.tool.name,
namespace: entry.tool.options?.namespace,
error: entry.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: (callback) =>
state.transform((draft) => {
// Preserve rejection of ambiguous adds within one transform, without rejecting later overrides.
const added = new Map<string, Tool.Info | undefined>()
callback({
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
if (added.has(id)) {
draft.errors.push({
tool,
error: new RegistrationError({ name: id, message: `Duplicate normalized tool name: ${id}` }),
})
const previous = added.get(id)
if (previous) {
draft.tools.set(id, previous)
return
}
draft.tools.delete(id)
return
}
added.set(id, draft.tools.get(id))
draft.tools.set(id, { ...tool })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current }
update(tool)
tool.name = current.name
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)
added.delete(id)
},
})
}),
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))
+5 -7
View File
@@ -32,14 +32,12 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
Registrations are scoped:
- The latest valid active registration for the same effective name wins.
- `update` and `remove` target effective names and do nothing for missing tools. Updates preserve the name and namespace; invalid updates leave the previous definition intact. Creating a tool requires `add`.
- Disposing a registration or closing its scope removes only its transform and rebuilds from the remaining transforms, revealing any earlier definition it overrode.
- Each model request captures the effective definitions and executors it advertises; later reloads and disposal affect later snapshots. Captured executors may still reference mutable producer-owned state.
MCP owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides.
- Tool transforms use the shared `State.create` lifecycle, like agents and skills: `add`, `update`, and `remove` replay in registration order when state is rebuilt.
- `update` and `remove` do nothing for missing tools. `add` requires a complete tool definition.
- Disposing a registration or closing its scope removes its transform and rebuilds the remaining state. `reload` replays transforms after their external inputs change.
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
+75 -82
View File
@@ -33,88 +33,81 @@ export const layer = Layer.effect(
const lock = Semaphore.makeUnsafe(1)
let discovered: MCP.Tool[] = []
// Register once after initial discovery; only subsequent updates need a debounced reload.
const initial = yield* lock
.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
yield* tools.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
// Keep the source's position so later plugin transforms also apply after MCP refreshes.
yield* tools.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
id: context.id,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
}),
)
.pipe(Effect.forkScoped)
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
const reconcile = lock.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
@@ -122,9 +115,9 @@ export const layer = Layer.effect(
}),
)
const initial = yield* reconcile.pipe(Effect.forkScoped)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
// Each read loads the whole catalog, so queued notifications need only one refresh.
Stream.runForEachArray(() => reconcile),
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
+12 -8
View File
@@ -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")
@@ -1,336 +0,0 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
import { eq } from "drizzle-orm"
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 { Location } from "@opencode-ai/core/location"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { Event } from "@opencode-ai/schema/event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
const b = Location.Ref.make({ directory: AbsolutePath.make("/b") })
const otherWorkspace = Location.Ref.make({ directory: a.directory, workspaceID: WorkspaceID.make("wrk_other") })
const id = SessionID.make("ses_routing")
const Done = Bus.ephemeral({ type: "test.routing.done", schema: {} })
const Global = Bus.ephemeral({ type: "test.routing.global", schema: { sessionID: SessionID } })
const seed = Effect.fn(function* (ref: Location.Ref = a) {
const database = yield* Database.Service
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: a.directory, sandboxes: [] }).run()
yield* database.db
.insert(SessionTable)
.values({
id,
project_id: Project.ID.global,
directory: ref.directory,
workspace_id: ref.workspaceID,
slug: "routing",
version: "test",
})
.run()
})
const watch = (bus: Bus.Interface, ref?: Location.Ref, gate?: Deferred.Deferred<void>) => {
const collect = bus.subscribe().pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.mapEffect((event) => (gate ? Deferred.await(gate).pipe(Effect.as(event)) : Effect.succeed(event))),
Stream.runCollect,
)
return (ref ? collect.pipe(Effect.provideService(Location.Service, location(ref))) : collect).pipe(
Effect.forkScoped({ startImmediately: true }),
)
}
const delta = (bus: Bus.Interface) =>
bus.publish(SessionEvent.Text.Delta, {
sessionID: id,
assistantMessageID: SessionMessage.ID.make("msg_routing"),
ordinal: 0,
delta: "text",
})
describe("Bus Session routing", () => {
it.effect("delivers workspace-only moves to both owners without duplicating same-location moves", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, otherWorkspace)
const moved = yield* bus.publish(
SessionEvent.Moved,
{ sessionID: id, location: otherWorkspace, projectID: Project.ID.global },
{ location: a },
)
const after = yield* delta(bus)
const same = yield* bus.publish(SessionEvent.Moved, {
sessionID: id,
location: otherWorkspace,
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
it.effect("routes forks through their parent before the child exists", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const database = yield* Database.Service
yield* bus.publish(SessionEvent.Synthetic, { sessionID: id, text: "Fork boundary" })
const boundary = yield* database.db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, id))
.get()
if (!boundary) return yield* Effect.die("Missing fork boundary")
yield* Effect.forEach(["publish", "batch", "replay"] as const, (mode) =>
Effect.gen(function* () {
const child = SessionID.create()
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const payload = {
sessionID: child,
parentID: id,
boundary: { type: "before" as const, messageID: boundary.id },
}
const eventID = Event.ID.create()
if (mode === "publish") yield* bus.publish(SessionEvent.Forked, payload, { id: eventID })
if (mode === "batch") yield* bus.publishAll([[SessionEvent.Forked, payload, { id: eventID }]])
if (mode === "replay")
yield* bus.replay(
{
id: eventID,
type: Bus.versionedType(SessionEvent.Forked.type, 2),
seq: 0,
aggregateID: child,
data: payload,
},
{ publish: true },
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
}),
)
}),
)
it.effect("routes existing Sessions without changing public events or global delivery", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const workspace = yield* watch(bus, otherWorkspace)
const global = yield* watch(bus)
const listened: Event.Payload[] = []
yield* bus.listen((event) =>
Effect.sync(() => {
listened.push(event)
}),
)
const renamed = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "first" })
const text = yield* delta(bus)
const broadcast = yield* bus.publish(Global, { sessionID: id })
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
).toBe(true)
}),
)
it.effect("applies the same routing to typed and multi-type subscriptions", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const typed = yield* bus
.subscribe(SessionEvent.Renamed)
.pipe(
Stream.take(1),
Stream.runCollect,
Effect.provideService(Location.Service, location(b)),
Effect.forkScoped({ startImmediately: true }),
)
const multiple = yield* bus.subscribe([SessionEvent.Renamed, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.provideService(Location.Service, location(b)),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "wrong location" })
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
}),
)
it.effect("snapshots routing across creation and moves for slow subscribers", () =>
Effect.gen(function* () {
const database = yield* Database.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: a.directory, sandboxes: [] })
.run()
const bus = yield* Bus.Service
const gate = yield* Deferred.make<void>()
const first = yield* watch(bus, a, gate)
const second = yield* watch(bus, b, gate)
const created = yield* bus.publish(SessionEvent.Created, {
sessionID: id,
location: a,
projectID: Project.ID.global,
slug: "routing",
version: "test",
})
const before = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "before" })
const moved = yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const after = yield* delta(bus)
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
it.effect("routes a cold Session deletion before its projector removes ownership", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const global = yield* watch(bus)
const deleted = yield* bus.publish(SessionEvent.Deleted, { sessionID: id })
const missing = yield* delta(bus)
const done = yield* bus.publish(Done, {})
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
}),
)
it.effect("preserves routing through a batch that moves and deletes a Session", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const gate = yield* Deferred.make<void>()
const first = yield* watch(bus, a, gate)
const second = yield* watch(bus, b, gate)
const events = yield* bus.publishAll([
[SessionEvent.Renamed, { sessionID: id, title: "before" }],
[SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global }],
[SessionEvent.Renamed, { sessionID: id, title: "after" }],
[SessionEvent.Deleted, { sessionID: id }],
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
}),
)
it.effect("does not change ownership when single or batched moves roll back", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const before = yield* delta(bus)
const single = yield* bus
.publish(
SessionEvent.Moved,
{ sessionID: id, location: b, projectID: Project.ID.global },
{ commit: () => Effect.die("rollback") },
)
.pipe(Effect.exit)
const batch = yield* bus
.publishAll([
[SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global }],
[SessionEvent.Renamed, { sessionID: id, title: "rollback" }, { commit: () => Effect.die("rollback") }],
])
.pipe(Effect.exit)
const after = yield* delta(bus)
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
}),
)
it.effect("updates cached ownership on silent replay and filters published replay", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
yield* delta(bus)
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
yield* bus.replay({
id: Event.ID.create(),
type: Bus.versionedType(SessionEvent.Moved.type, 1),
seq: 0,
aggregateID: id,
data: { sessionID: id, location: b, projectID: Project.ID.global },
})
const after = yield* delta(bus)
const replayID = Event.ID.create()
yield* bus.replay(
{
id: replayID,
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
seq: 1,
aggregateID: id,
data: { sessionID: id, title: "replayed" },
},
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
)
})
+7 -5
View File
@@ -9,7 +9,6 @@ import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
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"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
@@ -39,13 +38,19 @@ const config = Config.testLayer()
const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[
SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved),
}),
],
[Config.node, config],
]),
),
@@ -54,7 +59,6 @@ describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
@@ -81,8 +85,6 @@ describe("ConfigCompactionPlugin.Plugin", () => {
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
@@ -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 }))),
})
-31
View File
@@ -131,37 +131,6 @@ describe("Git worktrees", () => {
})
describe("Git trees", () => {
it.live("lists both sides of a rename as separate file changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(root.path)
await $`git config diff.renames true`.cwd(root.path).quiet()
await Bun.write(path.join(root.path, "old name.txt"), "Preserve this content.\n")
})
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
yield* Effect.promise(() => fs.rename(path.join(root.path, "old name.txt"), path.join(root.path, "new name.txt")))
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
RelativePath.make("new name.txt"),
RelativePath.make("old name.txt"),
])
expect(
(yield* git.tree.diff({ repository, from: before, to: after })).map((file) => [file.file, file.status]),
).toEqual([
["new name.txt", "added"],
["old name.txt", "deleted"],
])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
-11
View File
@@ -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))
}),
)
})
+8 -37
View File
@@ -81,28 +81,6 @@ const itWithActivity = testEffect(
)
describe("LocationServiceMap", () => {
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const sessionID = Session.ID.make("ses_routing_activity")
yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
yield* bus.publish(SessionEvent.Created, {
sessionID,
location: ref,
projectID: Project.ID.global,
slug: "routing",
version: "test",
})
yield* TestClock.adjust("59 minutes")
const event = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
expect(event).not.toHaveProperty("location")
yield* TestClock.adjust("2 minutes")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
)
itWithActivity.effect("refreshes lifetime from Session events only", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
@@ -797,10 +775,8 @@ describe("LocationServiceMap", () => {
}),
),
)
const failure = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
Session.Info.make({
id: Session.ID.make("ses_unavailable_model"),
projectID: Project.ID.global,
@@ -814,9 +790,8 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
@@ -840,10 +815,8 @@ describe("LocationServiceMap", () => {
["azure-cognitive-services", "azure"],
["google-vertex-anthropic", "google-vertex"],
] as const) {
const failure = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
Session.Info.make({
id: Session.ID.make(`ses_removed_${providerID}`),
projectID: Project.ID.global,
@@ -857,9 +830,8 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
@@ -911,7 +883,6 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
+30 -179
View File
@@ -36,7 +36,6 @@ import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
@@ -1033,58 +1032,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
Effect.gen(function* () {
const service = yield* MCP.Service
yield* Effect.scoped(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.set("dynamic", {
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
})
})
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
"connected",
)
expect(yield* service.tools()).toHaveLength(2)
const settings = { disabled: true }
yield* service.transform((draft) => {
draft.update("dynamic", (server) => {
server.disabled = settings.disabled
})
})
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe("disabled")
expect(yield* service.tools()).toEqual([])
settings.disabled = false
yield* service.reload()
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
"connected",
)
expect(yield* service.tools()).toHaveLength(2)
const removed = yield* service.transform((draft) => draft.remove("dynamic"))
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
expect(yield* service.tools()).toEqual([])
yield* removed.dispose
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
"connected",
)
expect(yield* service.tools()).toHaveLength(2)
}),
)
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
expect(yield* service.tools()).toEqual([])
}),
)
test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1282,13 +1229,12 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mutations on catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string, description = name) =>
const tool = (server: string, name: string) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
name,
description,
codemode: false,
inputSchema: { type: "object", properties: {} },
})
@@ -1300,28 +1246,14 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
const policy = yield* registry.transform((draft) => {
draft.update("demo_search", (tool) => {
tool.description = "Updated search"
})
draft.remove("other_lookup")
})
yield* registration.flush
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_search",
"other_lookup",
"execute",
])
const override = yield* registry.transform((draft) => {
draft.add({
name: "search",
options: { namespace: "demo", codemode: false },
description: "Override search",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "override" }),
})
})
const mutation = yield* registry.transform((draft) => {
draft.update("other_lookup", (tool) => {
tool.description += " updated"
})
draft.remove("repaired_lookup")
})
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_search", "execute"])
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
@@ -1329,31 +1261,18 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"other_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
"Updated search",
)
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup updated",
)
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
executeTool(registry, {
expect(
yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
}).pipe(
Effect.tap((result) =>
Effect.sync(() =>
expect(result).toMatchObject({
status: "completed",
output: name === "demo_search" ? "override" : "healthy",
}),
),
),
),
)
call: { type: "tool-call", id: "call_demo_search", name: "demo_search", input: {} },
}),
).toMatchObject({ status: "completed" })
yield* Ref.set(catalog, [
tool("demo", "status"),
@@ -1365,46 +1284,18 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
yield* waitForTool(registry, "demo_status")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"demo_status",
"repaired_lookup",
"execute",
])
yield* policy.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_status",
"other_lookup",
"repaired_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup updated",
)
yield* mutation.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toContain("repaired_lookup")
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup",
)
yield* Ref.set(catalog, [tool("demo", "search", "Latest search"), tool("demo", "refreshed")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_refreshed")
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
yield* override.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_refreshed",
"demo_search",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Latest search",
)
expect(
yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: "call_restored_search", name: "demo_search", input: {} },
}),
).toMatchObject({ status: "completed", output: "healthy" })
}).pipe(
Effect.provide(
Layer.fresh(
@@ -1433,47 +1324,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
}),
)
testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after initial registration", () => {
let reads = 0
return Effect.gen(function* () {
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
yield* registration.flush
expect(reads).toBe(1)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* TestClock.adjust("250 millis")
yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" }))
yield* TestClock.adjust("2 seconds")
expect(reads).toBe(3)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () =>
Effect.sync(() => [
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: `read_${++reads}`,
codemode: false,
inputSchema: { type: "object", properties: {} },
}),
]),
}),
],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Image.node, imagePassthrough],
]),
),
)
})
it.effect("advertises MCP output schemas to Code Mode", () =>
it.live("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
@@ -1490,7 +1341,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
it.live("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1515,7 +1366,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
it.live("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
yield* waitForTool(registry, "direct_lookup")
@@ -1529,7 +1380,7 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
// success whose text happens to describe an error.
it.effect("fails the call when MCP reports isError", () =>
it.live("fails the call when MCP reports isError", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1547,7 +1398,7 @@ it.effect("fails the call when MCP reports isError", () =>
)
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
it.effect("preserves MCP text and media content for the model", () =>
it.live("preserves MCP text and media content for the model", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
@@ -1568,7 +1419,7 @@ it.effect("preserves MCP text and media content for the model", () =>
}),
)
it.effect("waits for permission before calling an MCP tool", () =>
it.live("waits for permission before calling an MCP tool", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<Permission.AssertInput>()
@@ -1610,7 +1461,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
}),
)
it.effect("does not call MCP when permission is blocked", () =>
it.live("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
calls = 0
assertion = yield* Deferred.make<Permission.AssertInput>()
@@ -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)
}),
)
+12 -31
View File
@@ -25,34 +25,6 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("exposes the current location to activated plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const location = yield* Location.Service
const seen: Location.Info[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "location-context",
effect: (ctx) =>
Effect.sync(() => {
seen.push(ctx.location)
}),
}),
"1",
),
])
expect(seen).toEqual([
new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -71,7 +43,7 @@ describe("Plugin", () => {
}),
)
it.effect("exposes MCP reads and transforms and routes explicit read locations", () =>
it.effect("routes explicit MCP locations through the plugin runtime", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
@@ -100,6 +72,10 @@ describe("Plugin", () => {
data: [],
}
}),
add: (ref) => Effect.sync(() => routed.push(`add:${ref.directory}`)),
remove: (ref) => Effect.sync(() => routed.push(`remove:${ref.directory}`)),
connect: (ref) => Effect.sync(() => routed.push(`connect:${ref.directory}`)),
disconnect: (ref) => Effect.sync(() => routed.push(`disconnect:${ref.directory}`)),
},
},
}),
@@ -107,9 +83,14 @@ describe("Plugin", () => {
)
const location = { directory: target }
expect(Object.keys(host.mcp).sort()).toEqual(["list", "reload", "transform"])
yield* host.mcp
.add({ location, server: "routed", config: { type: "local", command: ["unused"], disabled: true } })
.pipe(Effect.orDie)
yield* host.mcp.remove({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual(["list:/target"])
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
}),
)
+4 -10
View File
@@ -18,16 +18,6 @@ type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
export function host(overrides: Overrides = {}): Plugin.Context {
return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
location:
overrides.location ??
new Location.Info({
directory: AbsolutePath.make("/workspace"),
project: {
id: Project.ID.global,
directory: AbsolutePath.make("/workspace"),
canonical: AbsolutePath.make("/workspace"),
},
}),
options: {},
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
@@ -87,6 +77,10 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
mcp: overrides.mcp ?? {
list: () => Effect.die("unused mcp.list"),
add: () => Effect.die("unused mcp.add"),
remove: () => Effect.die("unused mcp.remove"),
connect: () => Effect.die("unused mcp.connect"),
disconnect: () => Effect.die("unused mcp.disconnect"),
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
},
+74 -183
View File
@@ -17,7 +17,6 @@ import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { Workspace } from "@opencode-ai/core/workspace"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { define } from "@opencode-ai/plugin/promise/plugin"
import { Money } from "@opencode-ai/schema/money"
@@ -29,41 +28,6 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const location = yield* Location.Service
const expected = new Location.Info({
directory: AbsolutePath.make("/worktree/packages/app"),
workspaceID: Workspace.ID.make("wrk_plugin_location"),
project: {
id: Project.ID.global,
directory: AbsolutePath.make("/worktree"),
canonical: AbsolutePath.make("/project"),
},
})
const host = yield* PluginHost.make(plugins).pipe(
Effect.provideService(Location.Service, {
...location,
directory: expected.directory,
workspaceID: expected.workspaceID,
project: expected.project,
}),
)
const seen: Location.Info[] = []
yield* PluginPromise.fromPromise(
define({
id: "promise-location",
setup: (ctx) => {
seen.push(ctx.location)
},
}),
).effect(host)
expect(seen).toEqual([expected])
}),
)
it.effect("adapts plugin storage methods", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -278,14 +242,12 @@ describe("fromPromise", () => {
const promisePlugin = define({
id: "promise-client-reads",
setup: async (ctx) => {
expect(Object.keys(ctx.mcp).sort()).toEqual(["list", "reload", "transform"])
const results = await Promise.all([
ctx.agent.list(),
ctx.catalog.provider.list(),
ctx.catalog.model.list(),
ctx.command.list(),
ctx.integration.list(),
ctx.mcp.list(),
ctx.plugin.list(),
ctx.reference.list(),
ctx.skill.list(),
@@ -297,7 +259,7 @@ describe("fromPromise", () => {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(seen).toHaveLength(9)
expect(seen).toHaveLength(8)
expect(new Set(seen).size).toBe(1)
}),
)
@@ -634,150 +596,6 @@ describe("fromPromise", () => {
}),
)
it.live("reloads and disposes Promise tools while preserving older snapshots", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const source = { description: "Original", replays: 0 }
const registrations: Array<{ reload: () => Promise<void>; dispose: () => Promise<void> }> = []
yield* PluginPromise.fromPromise(
define({
id: "promise-tool-lifecycle",
setup: async (ctx) => {
expect(Object.keys(ctx.tool).sort()).toEqual(["hook", "reload", "transform"])
const registration = await ctx.tool.transform((draft) => {
source.replays++
const description = source.description
draft.add({
name: "reloadable",
description,
input: Schema.Struct({}),
output: Schema.String,
options: { codemode: false },
execute: async () => ({ output: description }),
})
expect(draft.list().map((tool) => tool.id)).toEqual(["reloadable"])
expect(draft.get("reloadable")?.id).toBe("reloadable")
expect(draft.get("reloadable")?.name).toBe("reloadable")
expect(draft.get("missing")).toBeUndefined()
})
registrations.push({ reload: ctx.tool.reload, dispose: registration.dispose })
},
}),
).effect(host)
const registration = registrations[0]
if (!registration) return yield* Effect.die("Promise tool registration was not captured")
const original = yield* registry.snapshot()
const execute = (snapshot: Tool.Snapshot) =>
snapshot.execute({
sessionID: Session.ID.make("ses_promise_tool_reload"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_reload"),
call: { type: "tool-call", id: "call_promise_tool_reload", name: "reloadable", input: {} },
})
source.description = "Reloaded"
yield* Effect.promise(() => registration.reload())
const reloaded = yield* registry.snapshot()
expect(source.replays).toBe(2)
expect(reloaded.definitions).toContainEqual(
expect.objectContaining({ name: "reloadable", description: "Reloaded" }),
)
expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" })
expect(yield* execute(original)).toMatchObject({ output: "Original" })
yield* Effect.promise(() => registration.dispose())
yield* Effect.promise(() => registration.dispose())
expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false)
expect(yield* execute(original)).toMatchObject({ output: "Original" })
expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" })
yield* Effect.promise(() => registration.reload())
expect(source.replays).toBe(2)
expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false)
}),
)
it.live("adapts tool updates, executor wrapping, and removal across replay and disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: Tool.Metadata[] = []
let greeting = "Hello"
const registrations: Array<{ dispose: () => Promise<void> }> = []
yield* host.tool.transform((draft) => {
const text = greeting
draft.add({
name: "hello",
description: "Hello",
options: { namespace: "acme", codemode: false },
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: ({ name }, context) =>
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
})
draft.add({
name: "temporary",
description: "Temporary",
input: Schema.Struct({}),
options: { codemode: false },
execute: () => Effect.succeed({ content: "temporary" }),
})
})
yield* PluginPromise.fromPromise(
define({
id: "promise-tool-mutations",
setup: async (ctx) => {
registrations.push(
await ctx.tool.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_hello", (tool) => {
const execute = tool.execute
tool.description = "Wrapped"
delete tool.output
tool.execute = async (input, context) => {
const result = await execute(input, context)
return { content: `${result.output} Wrapped.` }
}
})
draft.remove("temporary")
}),
)
greeting = "Hi"
await ctx.tool.reload()
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
expect(snapshot.definitions[0]?.outputSchema).toBeUndefined()
expect(
yield* snapshot.execute({
sessionID: Session.ID.make("ses_promise_tool_update"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_update"),
call: { type: "tool-call", id: "call_update", name: "acme_hello", input: { name: "world" } },
progress: (update) =>
Effect.sync(() => {
progress.push(update)
}),
}),
).toMatchObject({ content: [{ type: "text", text: "Hi, world! Wrapped." }] })
expect(progress).toEqual([{ phase: "original" }])
const registration = registrations[0]
if (!registration) return yield* Effect.die("Promise tool registration was not captured")
yield* Effect.promise(() => registration.dispose())
yield* Effect.promise(() => registration.dispose())
const restored = yield* registry.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
expect(restored.definitions[0]?.description).toBe("Hello")
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -818,4 +636,77 @@ describe("fromPromise", () => {
})
}),
)
it.live("adapts tool mutation, replay, and disposal through the Promise API", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: Tool.Metadata[] = []
let greeting = "Hello"
let registration: { dispose(): Promise<void> } | undefined
yield* host.tool.transform((draft) => {
const text = greeting
draft.add({
name: "hello",
description: "Hello",
options: { namespace: "acme", codemode: false },
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: ({ name }, context) =>
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
})
draft.add({
name: "temporary",
description: "Temporary",
input: Schema.Struct({}),
options: { codemode: false },
execute: () => Effect.succeed({ content: "temporary" }),
})
})
yield* PluginPromise.fromPromise(
define({
id: "promise-update",
setup: async (ctx) => {
registration = await ctx.tool.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_hello", (tool) => {
const execute = tool.execute
tool.description = "Wrapped"
tool.execute = async (input, context) => {
const result = await execute(input, context)
return { ...result, output: `${result.output} Wrapped.` }
}
})
draft.remove("temporary")
})
greeting = "Hi"
await ctx.tool.reload()
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
expect(
yield* snapshot.execute({
sessionID: Session.ID.make("ses_promise_update"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_update"),
progress: (value) => Effect.sync(() => progress.push(value)),
call: { type: "tool-call", id: "call_promise_update", name: "acme_hello", input: { name: "world" } },
}),
).toMatchObject({ output: "Hi, world! Wrapped." })
expect(progress).toEqual([{ phase: "original" }])
const registered = registration
if (!registered) throw new Error("Expected registration")
yield* Effect.promise(() => registered.dispose())
yield* Effect.promise(() => registered.dispose())
const restored = yield* registry.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
expect(restored.definitions[0]?.description).toBe("Hello")
}),
)
})
@@ -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,865 +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" }),
)
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")
}),
)
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)
}),
)
})
+4 -11
View File
@@ -10,7 +10,6 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
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"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -74,6 +73,9 @@ const resolved = SessionRunnerModel.resolved(model, {
cost,
limit: { context: 200_000, output: 32_000 },
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved),
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -83,11 +85,11 @@ const it = testEffect(
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[SessionRunnerModel.node, models],
],
),
)
@@ -240,7 +242,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
const modelRequests = yield* SessionModelRequest.Service
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
@@ -249,8 +250,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
@@ -304,12 +303,9 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
fork_session_id: rootID,
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
@@ -340,12 +336,9 @@ it.effect("keeps session context hooks away from compaction requests", () =>
}),
)
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
const modelRequests = yield* SessionModelRequest.Service
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
+1 -6
View File
@@ -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),
),
-117
View File
@@ -1,117 +0,0 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/core/agent"
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 { Model } from "@opencode-ai/core/model"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
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 { Snapshot } from "@opencode-ai/core/snapshot"
import { Money } from "@opencode-ai/schema/money"
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 it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Global.node, tempGlobalLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
describe("Session.revert files", () => {
it.live("undoes and restores a file rename without losing either path", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const directory = path.join(tmp.path, "project")
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await Bun.write(original, "Preserve this content.\n")
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const session = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
if (!before) throw new Error("Initial snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(() => fs.rename(original, renamed))
const after = yield* snapshot.capture()
if (!after) throw new Error("Renamed snapshot missing")
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: after,
files: yield* snapshot.files({ from: before, to: after }),
})
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
expect({
original: yield* Effect.promise(() => Bun.file(original).exists()),
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
}).toEqual({ original: true, renamed: false })
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
["new name.txt", "deleted"],
["old name.txt", "added"],
])
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
yield* session.revert.clear(created.id)
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
"Keep this later edit.\n",
)
expect((yield* session.get(created.id)).revert).toBeUndefined()
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
)
})
@@ -984,7 +984,7 @@ Recent work
)
expect(messages[0]?.content).toEqual([
{ type: "reasoning", text: "Visible thought" },
{ type: "text", text: "Visible thought" },
{
type: "tool-call",
id: "hosted-old-model",
@@ -6,13 +6,12 @@ import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import { State } from "@opencode-ai/core/state"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { TestClock } from "effect/testing"
import { z } from "zod"
import { testEffect } from "./lib/effect"
@@ -73,111 +72,94 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.effect("reads the current draft tools by effective name", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { namespace: "acme", codemode: false })
yield* service.transform((draft) => {
expect(draft.list().map((tool) => tool.id)).toEqual(["acme_echo"])
expect(draft.get("acme_echo")?.id).toBe("acme_echo")
expect(draft.get("acme_echo")?.name).toBe("echo")
expect(draft.get("missing")).toBeUndefined()
})
}),
)
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
it.live("replays updates and removals on reload and restores definitions on disposal", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let text = "original"
const source = yield* Scope.make()
yield* service
.transform((draft) => {
draft.add({ ...constant(text), name: "echo", options: { namespace: "acme", codemode: false } })
draft.add({ ...make(), name: "hidden" })
})
.pipe(Scope.provide(source))
const original = yield* service.snapshot()
const source = yield* service.transform((draft) =>
draft.add({
...constant(text),
name: "echo",
description: text,
options: { namespace: "acme", codemode: false },
}),
)
yield* transform(service, { echo: make() }, { namespace: "other", codemode: false })
const before = yield* service.snapshot()
const update = yield* service.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.remove("missing")
draft.update("acme_echo", (tool) => {
tool.description += " updated"
const execute = tool.execute
tool.description = "Updated"
tool.execute = (input, context) =>
execute(input, context).pipe(
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
)
})
})
const scope = yield* Scope.make()
yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope))
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
const removal = yield* service.transform((draft) => {
draft.remove("missing")
draft.remove("other_echo")
})
const updated = yield* service.snapshot()
expect(updated.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
expect(updated.definitions[0]?.description).toBe("original updated")
expect((yield* updated.execute(call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
const reload = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
const refreshed = yield* service.snapshot()
expect(refreshed.definitions[0]?.description).toBe("Updated")
expect(refreshed.codeModeCatalog).toEqual([])
expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" })
yield* service.reload()
const reloaded = yield* service.snapshot()
expect(reloaded.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
expect(reloaded.definitions[0]?.description).toBe("refreshed updated")
expect((yield* reloaded.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* before.execute(call("acme_echo"))).output).toEqual({ text: "original" })
yield* removal.dispose
yield* removal.dispose
yield* update.dispose
yield* update.dispose
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["hidden"])
const restored = yield* service.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "other_echo", "execute"])
expect((yield* restored.execute(call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* source.dispose
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["other_echo", "execute"])
}),
)
it.effect("does not retain an updated tool after its source scope closes", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const scope = yield* Scope.make()
yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
yield* service.transform((draft) =>
draft.update("acme_echo", (tool) => {
tool.description = "Updated again"
draft.update("echo", (tool) => {
tool.description = "Updated"
}),
)
yield* Scope.close(source, Exit.void)
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("updates schemas and executors without renaming tools and applies removal in order", () =>
it.effect("batches tool transforms with the shared state lifecycle", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), options: { namespace: "acme.tools", codemode: false } })
draft.add({ ...make(), name: "removed", options: { codemode: false } })
draft.remove("removed")
draft.update("removed", () => {
throw new Error("must not resurrect a tool")
})
draft.remove("acme_tools_echo")
draft.add({ ...make(), options: { namespace: "acme.tools", codemode: false } })
draft.update("acme_tools_echo", (tool) => {
tool.name = "renamed"
tool.options = { namespace: "other", codemode: false }
tool.input = Schema.Struct({ value: Schema.Finite })
tool.output = Schema.Finite
tool.execute = ({ value }) => Effect.succeed({ output: value + 1 })
})
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_tools_echo", "execute"])
expect(snapshot.definitions[0]?.inputSchema.properties).toEqual({ value: { type: "number" } })
expect(
(yield* snapshot.execute({
...call("acme_tools_echo"),
call: {
type: "tool-call",
id: "updated",
name: "acme_tools_echo",
input: { value: 2 },
},
})).output,
).toBe(3)
expect(yield* snapshot.execute(call("acme_tools_echo")).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
let runs = 0
yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
runs++
draft.add({ ...make(), options: { codemode: false } })
})
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
tool.description = "Batched"
}),
)
expect(runs).toBe(0)
}),
)
expect(runs).toBe(1)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Batched")
}),
)
@@ -191,119 +173,34 @@ describe("Tool", () => {
}),
)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "echo" })
}),
)
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
it.effect("updates newly added tools and applies removals in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let source: Info[] = []
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
)
it.effect("disposes overlays once and replays remaining transforms in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
yield* service.transform((draft) => {
runs.push("base")
draft.add({ ...constant("base"), name: "echo", options: { codemode: false } })
})
const scope = yield* Scope.make()
const overlay = yield* service
.transform((draft) => {
runs.push("overlay")
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
draft.add({ ...make(), options: { codemode: false } })
draft.update("echo", (tool) => {
tool.description = "Updated"
tool.input = Schema.Struct({ value: Schema.Number })
tool.output = Schema.Number
tool.execute = ({ value }) => Effect.succeed({ output: value * 2 })
})
.pipe(Scope.provide(scope))
expect(runs).toEqual(["base", "base", "overlay"])
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
yield* overlay.dispose
expect(runs).toEqual(["base", "base", "overlay", "base"])
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "base" })
yield* overlay.dispose
yield* Scope.close(scope, Exit.void)
expect(runs).toEqual(["base", "base", "overlay", "base"])
}),
)
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
const scope = yield* Scope.make()
yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
runs.push("base")
draft.add({ ...constant("base"), name: "echo", options: { codemode: false } })
})
yield* service.transform((draft) => {
runs.push("overlay")
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}).pipe(Scope.provide(scope)),
)
expect(runs).toEqual(["base", "overlay"])
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
expect(runs).toEqual(["base", "overlay"])
}),
)
it.effect("uses the last valid addition on replay and restores earlier transforms on disposal", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo_tool: constant("base") }, { codemode: false })
let source = [{ ...constant("overlay"), name: "echo.tool", options: { codemode: false } }]
const registration = yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "overlay" })
source = [...source, { ...constant("collision"), name: "echo_tool", options: { codemode: false } }]
const collision = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(collision)
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "collision" })
yield* registration.dispose
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
source = [{ ...constant("invalid"), name: "", options: { codemode: false } }]
const invalid = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(invalid)
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
draft.add({ ...make(), name: "removed" })
draft.remove("removed")
draft.add({ ...make(), name: "removed" })
draft.remove("removed")
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Updated")
expect(
(yield* snapshot.execute({
...call("echo"),
call: { type: "tool-call", id: "updated", name: "echo", input: { value: 3 } },
})).output,
).toBe(6)
}),
)
@@ -328,7 +225,7 @@ describe("Tool", () => {
}).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("skips invalid and reserved names while letting the last normalized name win", () =>
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(
@@ -337,18 +234,18 @@ describe("Tool", () => {
before: make(),
"": make(),
["x".repeat(65)]: make(),
"echo.tool": constant("first"),
echo_tool: constant("last"),
"echo.tool": make(),
echo_tool: make(),
execute: make(),
after: make(),
},
{ codemode: false },
)
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "echo_tool", "execute"])
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
expect((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" })
expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool")
expect(snapshot.codeModeCatalog).toEqual([])
}),
)
-4
View File
@@ -24,8 +24,6 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Location } from "@opencode-ai/core/location"
import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -129,8 +127,6 @@ const it = testEffect(
[llmClient, client],
[Catalog.node, catalog],
[SessionRunnerModel.node, models],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })],
],
),
)
-47
View File
@@ -1,47 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { Source } from "../src/source"
import { session } from "./fixture/capabilities"
import { it } from "./lib/effect"
it.effect("mutable sources sample current values and defer writes until execution", () =>
Effect.gen(function* () {
const source = Source.mutable(1)
const independent = Source.mutable(1)
const read = source.get(session)
const set = source.set(2)
const update = source.update((value) => value + 3)
expect(yield* read).toBe(1)
yield* set
expect(yield* read).toBe(2)
yield* update
expect(yield* read).toBe(5)
expect(yield* independent.get(session)).toBe(1)
}),
)
it.effect("constant and plain values retain their identity", () =>
Effect.gen(function* () {
const values = ["read", "write"]
expect(yield* Source.constant(values).get(session)).toBe(values)
expect(yield* Source.from(values).get(session)).toBe(values)
expect(yield* Source.from(null).get(session)).toBeNull()
expect(yield* Source.from(undefined).get(session)).toBeUndefined()
expect(yield* Source.from(0).get(session)).toBe(0)
const record = { get: "not a source" }
expect(yield* Source.from(record).get(session)).toBe(record)
}),
)
it.effect("structural sources receive the session and preserve typed failures", () =>
Effect.gen(function* () {
const source: Source.Interface<string, "unavailable"> = {
get: (current) => (current.title === "unavailable" ? Effect.fail("unavailable") : Effect.succeed(current.id)),
}
const converted: Source.Interface<string, "unavailable"> = Source.from(source)
expect(converted).toBe(source)
expect(yield* converted.get(session)).toBe(session.id)
expect(yield* converted.get({ ...session, title: "unavailable" }).pipe(Effect.flip)).toBe("unavailable")
}),
)

Some files were not shown because too many files have changed in this diff Show More