mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54fea4b111 | ||
|
|
4a5fa79461 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 })))
|
||||
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Limits, Draft>({
|
||||
|
||||
@@ -402,7 +402,14 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
|
||||
return Array.filterMap(yield* kv.scanAll(backgroundPrefix), (entry) => decodeBackground(entry.value))
|
||||
const recovered: Background[] = []
|
||||
let after: string | undefined
|
||||
do {
|
||||
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
|
||||
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
|
||||
after = page.next
|
||||
} while (after)
|
||||
return recovered
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
|
||||
+20
-33
@@ -29,7 +29,6 @@ export interface Interface {
|
||||
readonly set: (key: string, value: Value) => Effect.Effect<void>
|
||||
readonly remove: (key: string) => Effect.Effect<void>
|
||||
readonly scan: (options: ScanOptions) => Effect.Effect<ScanResult>
|
||||
readonly scanAll: (prefix: string) => Effect.Effect<readonly Entry[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
|
||||
@@ -38,28 +37,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const scan: Interface["scan"] = Effect.fn("KV.scan")(function* (options) {
|
||||
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
|
||||
const end = prefixEnd(options.prefix)
|
||||
const rows = yield* db
|
||||
.select({ key: KVTable.key, value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(
|
||||
and(
|
||||
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
|
||||
end === undefined ? undefined : lt(KVTable.key, end),
|
||||
options.after === undefined ? undefined : gt(KVTable.key, options.after),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(KVTable.key))
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const entries = rows.slice(0, limit)
|
||||
if (rows.length <= limit) return { entries }
|
||||
return { entries, next: entries[entries.length - 1].key }
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("KV.get")(function* (key) {
|
||||
return (yield* db
|
||||
@@ -80,16 +57,26 @@ const layer = Layer.effect(
|
||||
remove: Effect.fn("KV.remove")(function* (key) {
|
||||
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
|
||||
}),
|
||||
scan,
|
||||
scanAll: Effect.fn("KV.scanAll")(function* (prefix) {
|
||||
const entries: Entry[] = []
|
||||
let after: string | undefined
|
||||
do {
|
||||
const page = yield* scan({ prefix, after, limit: 1000 })
|
||||
entries.push(...page.entries)
|
||||
after = page.next
|
||||
} while (after !== undefined)
|
||||
return entries
|
||||
scan: Effect.fn("KV.scan")(function* (options) {
|
||||
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
|
||||
const end = prefixEnd(options.prefix)
|
||||
const rows = yield* db
|
||||
.select({ key: KVTable.key, value: KVTable.value })
|
||||
.from(KVTable)
|
||||
.where(
|
||||
and(
|
||||
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
|
||||
end === undefined ? undefined : lt(KVTable.key, end),
|
||||
options.after === undefined ? undefined : gt(KVTable.key, options.after),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(KVTable.key))
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const entries = rows.slice(0, limit)
|
||||
if (rows.length <= limit) return { entries }
|
||||
return { entries, next: entries[entries.length - 1].key }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -100,10 +100,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export function relevant(input: Pick<Request, "action">, rules: Permission.Ruleset) {
|
||||
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
@@ -165,6 +161,10 @@ const layer = Layer.effect(
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
function relevant(input: AssertInput, rules: Permission.Ruleset) {
|
||||
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
|
||||
}
|
||||
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export * as Permissions from "./permissions.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Permission } from "./permission.js"
|
||||
import type { SessionSchema } from "./session/schema.js"
|
||||
import { Source } from "./source.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly visibility: Source.Interface<Permission.Ruleset>
|
||||
readonly ask: (
|
||||
session: SessionSchema.Info,
|
||||
request: Omit<Permission.AssertInput, "sessionID" | "agent">,
|
||||
) => Effect.Effect<void, Permission.Error | Permission.DeclinedError>
|
||||
}
|
||||
|
||||
export const allowAll: Interface = {
|
||||
visibility: Source.constant([{ action: "*", resource: "*", effect: "allow" }]),
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
export function rules(source: Source.Value<Permission.Ruleset>): Interface {
|
||||
const visibility = Source.from(source)
|
||||
return {
|
||||
visibility,
|
||||
ask: Effect.fn("Permissions.ask")(function* (session, request) {
|
||||
const rules = yield* visibility.get(session)
|
||||
if (
|
||||
request.resources.every((resource) => Permission.evaluate(request.action, resource, rules).effect === "allow")
|
||||
)
|
||||
return
|
||||
return yield* new Permission.BlockedError({
|
||||
rules: Permission.relevant(request, rules),
|
||||
permission: request.action,
|
||||
resources: request.resources,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const callbacks = new Map<string, Entry[]>()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,6 @@ import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { SessionResolve } from "./resolve.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
@@ -53,7 +52,6 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -88,14 +86,10 @@ export const layer = Layer.effect(
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const pinned = resolve.pinned(sessionID)
|
||||
const result = yield* (
|
||||
pinned
|
||||
? pinned.drain({ sessionID, force, continuation, promotable })
|
||||
: SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
@@ -107,14 +101,11 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
eligible: (sessionID) => resolve.status(sessionID) !== "owned-detached",
|
||||
started: (sessionID) => {
|
||||
resolve.pin(sessionID)
|
||||
return reportLifecycle(
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
)
|
||||
},
|
||||
),
|
||||
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
@@ -146,7 +137,7 @@ export const layer = Layer.effect(
|
||||
releaseOnCommit(sessionID),
|
||||
)
|
||||
}),
|
||||
).pipe(Effect.ensuring(resolve.settle(sessionID))),
|
||||
),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -169,12 +160,7 @@ export const layer = Layer.effect(
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
return interrupted
|
||||
}),
|
||||
resume: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
// TODO: typed unavailable-operation errors belong to the capability-gated operations phase.
|
||||
if (resolve.status(sessionID) === "owned-detached") return yield* SessionResolve.unavailable(sessionID)
|
||||
yield* coordinator.run(sessionID)
|
||||
}),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
@@ -184,7 +170,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node, SessionResolve.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Job } from "../../job.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionExecution } from "../execution.js"
|
||||
import { SessionResolve } from "../resolve.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
|
||||
@@ -38,8 +37,6 @@ export interface Interface {
|
||||
* shutdown (which preserves the claim on purpose). The claim is never
|
||||
* cleared here: only a terminal event releases it, so a death anywhere in
|
||||
* the resume path leaves the same orphaned claim for the next boot.
|
||||
* Capability-owned Sessions stay pending even when reopened; they require
|
||||
* an explicit prompt or resume instead of automatic recovery.
|
||||
*/
|
||||
readonly resumeSuspendedSessions: Effect.Effect<void>
|
||||
}
|
||||
@@ -69,7 +66,6 @@ export const layer = (options?: Options) =>
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
@@ -77,8 +73,6 @@ export const layer = (options?: Options) =>
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
// Reopening capabilities does not opt a Session into automatic recovery.
|
||||
if (resolve.status(sessionID) !== "unowned") return undefined
|
||||
// Durable before the resume runs, so a crash inside the resumed turn is
|
||||
// counted by the next sweep and the budget cannot be dodged.
|
||||
const attempts = yield* store.countResume(sessionID)
|
||||
@@ -101,14 +95,6 @@ export const layer = (options?: Options) =>
|
||||
return true
|
||||
})
|
||||
|
||||
const eligibleJob = (recovery: Job.Recovery) => {
|
||||
if (recovery.kind === "shell") return resolve.status(recovery.sessionID) === "unowned"
|
||||
return (
|
||||
resolve.status(recovery.parentSessionID) === "unowned" &&
|
||||
resolve.status(recovery.childSessionID) === "unowned"
|
||||
)
|
||||
}
|
||||
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
@@ -157,7 +143,6 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
|
||||
if (result.status === "running") return
|
||||
if (!eligibleJob(recovery)) return
|
||||
const text =
|
||||
result.status === "completed"
|
||||
? (result.output ?? "Subagent completed without a text response.")
|
||||
@@ -187,9 +172,7 @@ export const layer = (options?: Options) =>
|
||||
return
|
||||
}
|
||||
if ((yield* execution.active).has(recovery.childSessionID)) return
|
||||
const prepared = yield* prepareResume(recovery.childSessionID)
|
||||
if (prepared === undefined) return
|
||||
if (!prepared) {
|
||||
if (!(yield* prepareResume(recovery.childSessionID))) {
|
||||
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
|
||||
return
|
||||
}
|
||||
@@ -231,20 +214,18 @@ export const layer = (options?: Options) =>
|
||||
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
|
||||
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
|
||||
const pending = yield* jobs.pendingBackground
|
||||
yield* store.releaseChildClaims([
|
||||
...(yield* resolve.ownedIDs),
|
||||
...pending.flatMap((background) =>
|
||||
yield* store.releaseChildClaims(
|
||||
pending.flatMap((background) =>
|
||||
background.status === "running" && background.recovery.kind === "subagent"
|
||||
? [background.recovery.childSessionID]
|
||||
: [],
|
||||
),
|
||||
])
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
pending,
|
||||
Effect.fnUntraced(function* (background) {
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
if (!eligibleJob(recovery)) return
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
@@ -256,10 +237,10 @@ export const layer = (options?: Options) =>
|
||||
const resumed = yield* execution.active
|
||||
yield* Effect.forEach(
|
||||
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
|
||||
Effect.fnUntraced(function* (sessionID) {
|
||||
if (!(yield* prepareResume(sessionID))) return
|
||||
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
}),
|
||||
(sessionID) =>
|
||||
execution
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
// Async observers consult this set at delivery; later completions wake parents normally.
|
||||
@@ -272,5 +253,5 @@ export const layer = (options?: Options) =>
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [SessionStore.node, SessionExecution.node, SessionResolve.node, Bus.node, Job.node, Session.node],
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
export * as SessionResolve from "./resolve.js"
|
||||
|
||||
import type { LLMClientService } from "@opencode-ai/ai"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { App } from "../app.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { llmClient, webSocketConstructor } from "../effect/app-node-platform.js"
|
||||
import { KV } from "../kv.js"
|
||||
import type { SessionCapabilities } from "./capabilities.js"
|
||||
import type { SessionRunner } from "./runner/index.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import type { Image } from "../image.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { SessionModelTransport } from "./model-transport.js"
|
||||
|
||||
const prefix = "session.capabilities/"
|
||||
|
||||
/**
|
||||
* Live operations, never an attempt snapshot. Title, request hooks, compaction,
|
||||
* media/skills, snapshots, output, and transport customization are deferred;
|
||||
* open supplies their internal defaults without directory discovery.
|
||||
*/
|
||||
interface Capabilities extends SessionContext.Interface {
|
||||
readonly image: Image.Interface
|
||||
readonly transport: SessionModelTransport.Interface
|
||||
}
|
||||
|
||||
type Status = "attached" | "owned-detached" | "unowned"
|
||||
type Resolved =
|
||||
| { readonly status: "attached"; readonly capabilities: Capabilities }
|
||||
| { readonly status: "owned-detached" | "unowned" }
|
||||
|
||||
type Opened = {
|
||||
readonly capabilities: Capabilities
|
||||
readonly runner: SessionRunner.Interface
|
||||
readonly scope: Scope.Closeable
|
||||
readonly onRetire: () => Effect.Effect<void>
|
||||
readonly done: Deferred.Deferred<void>
|
||||
current: boolean
|
||||
users: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly own: <A extends { readonly id: SessionSchema.ID }, R>(
|
||||
record: Effect.Effect<A, never, R>,
|
||||
) => Effect.Effect<A, never, R>
|
||||
readonly status: (id: SessionSchema.ID) => Status
|
||||
readonly ownedIDs: Effect.Effect<ReadonlyArray<SessionSchema.ID>>
|
||||
readonly attach: (
|
||||
id: SessionSchema.ID,
|
||||
input: SessionCapabilities.OpenInput,
|
||||
) => Effect.Effect<() => Effect.Effect<void>>
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, never, Scope.Scope>
|
||||
/** Called synchronously when the coordinator installs a busy period, before its first fiber yield. */
|
||||
readonly pin: (id: SessionSchema.ID) => void
|
||||
readonly pinned: (id: SessionSchema.ID) => SessionRunner.Interface | undefined
|
||||
readonly settle: (id: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly remove: (id: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionResolve") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const owned = new Set(
|
||||
(yield* kv.scanAll(prefix))
|
||||
.filter((entry) => entry.value === true)
|
||||
.map((entry) => SessionSchema.ID.make(entry.key.slice(prefix.length))),
|
||||
)
|
||||
const globals = yield* Effect.context<
|
||||
| Database.Service
|
||||
| Bus.Service
|
||||
| SessionStore.Service
|
||||
| LLMClientService
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| Socket.WebSocketConstructor
|
||||
>()
|
||||
const current = new Map<SessionSchema.ID, Opened>()
|
||||
const pinned = new Map<SessionSchema.ID, Opened>()
|
||||
const opened = new Map<Deferred.Deferred<void>, Opened>()
|
||||
// LayerMap invalidation cannot choose synchronously at coordinator start or
|
||||
// run a host hook after all generation-specific users settle. Keep explicit leases.
|
||||
const retire = (value: Opened) =>
|
||||
Effect.suspend(() => {
|
||||
if (value.current || value.users > 0 || !opened.delete(value.done)) return Effect.void
|
||||
return Scope.close(value.scope, Exit.void).pipe(
|
||||
Effect.andThen(value.onRetire()),
|
||||
Effect.onExit((exit) => Deferred.done(value.done, exit)),
|
||||
)
|
||||
})
|
||||
const release = (value: Opened) =>
|
||||
Effect.sync(() => {
|
||||
value.users--
|
||||
}).pipe(Effect.andThen(retire(value)))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => current.clear()).pipe(
|
||||
Effect.andThen(
|
||||
Effect.forEach(
|
||||
opened.values(),
|
||||
(value) => {
|
||||
value.current = false
|
||||
return retire(value)
|
||||
},
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
// Ownership is transitionally one-way: retirement never hands a Session back to discovery.
|
||||
own: (record) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* record
|
||||
if (!owned.has(session.id)) yield* kv.set(prefix + session.id, true)
|
||||
return session
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
// Publish the memory index only after the durable transaction commits.
|
||||
owned.add(session.id)
|
||||
return session
|
||||
}),
|
||||
),
|
||||
status: (id) => (current.has(id) ? "attached" : owned.has(id) ? "owned-detached" : "unowned"),
|
||||
ownedIDs: Effect.sync(() => Array.from(owned)),
|
||||
attach: Effect.fn("SessionResolve.attach")(function* (id, input) {
|
||||
const [
|
||||
{ Image },
|
||||
{ PluginHooks },
|
||||
{ PluginSupervisor },
|
||||
{ Snapshot },
|
||||
{ ToolOutput },
|
||||
{ SessionCompaction },
|
||||
{ SessionContext },
|
||||
{ InstructionEntry },
|
||||
{ SessionModelRequest },
|
||||
{ SessionModelTransport },
|
||||
{ SessionRunner },
|
||||
{ SessionRunnerLLM },
|
||||
{ SessionTitle },
|
||||
] = yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
import("../image.js"),
|
||||
import("../plugin/hooks.js"),
|
||||
import("../plugin/supervisor-service.js"),
|
||||
import("../snapshot.js"),
|
||||
import("../tool-output.js"),
|
||||
import("./compaction.js"),
|
||||
import("./context.js"),
|
||||
import("./instruction-entry.js"),
|
||||
import("./model-request.js"),
|
||||
import("./model-transport.js"),
|
||||
import("./runner/index.js"),
|
||||
import("./runner/llm.js"),
|
||||
import("./title.js"),
|
||||
]),
|
||||
)
|
||||
const scope = yield* Scope.make()
|
||||
const base = Layer.mergeAll(
|
||||
PluginHooks.layer,
|
||||
Image.layer,
|
||||
InstructionEntry.layer,
|
||||
SessionModelTransport.layer,
|
||||
ToolOutput.layer,
|
||||
Snapshot.noopLayer,
|
||||
SessionCompaction.layer,
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
).pipe(Layer.provide(Layer.succeedContext(globals)))
|
||||
const requests = SessionModelRequest.layer.pipe(Layer.provideMerge(base))
|
||||
const capabilities = SessionContext.values(input).pipe(Layer.provideMerge(requests))
|
||||
const runner = SessionRunnerLLM.layer.pipe(
|
||||
Layer.provideMerge(SessionTitle.layer.pipe(Layer.provideMerge(capabilities))),
|
||||
Layer.provide(Layer.succeedContext(globals)),
|
||||
)
|
||||
// Each open builds fresh local state over the SAME captured durable/global services.
|
||||
const services = yield* Layer.buildWithScope(Layer.fresh(runner), scope).pipe(
|
||||
Effect.onError(() => Scope.close(scope, Exit.void)),
|
||||
)
|
||||
const value: Opened = {
|
||||
capabilities: {
|
||||
...Context.get(services, SessionContext.Service),
|
||||
image: Context.get(services, Image.Service),
|
||||
transport: Context.get(services, SessionModelTransport.Service),
|
||||
},
|
||||
runner: Context.get(services, SessionRunner.Service),
|
||||
scope,
|
||||
onRetire: input.retire ?? (() => Effect.void),
|
||||
done: Deferred.makeUnsafe<void>(),
|
||||
current: true,
|
||||
users: 0,
|
||||
}
|
||||
const previous = current.get(id)
|
||||
current.set(id, value)
|
||||
opened.set(value.done, value)
|
||||
if (previous) {
|
||||
previous.current = false
|
||||
yield* retire(previous)
|
||||
}
|
||||
// Closed handles retain only completion, not retired capability functions or layers.
|
||||
const done = value.done
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const value = opened.get(done)
|
||||
if (!value) return
|
||||
if (current.get(id) === value) current.delete(id)
|
||||
value.current = false
|
||||
yield* retire(value)
|
||||
}),
|
||||
)
|
||||
yield* Deferred.await(done)
|
||||
})
|
||||
}),
|
||||
resolve: (session) =>
|
||||
Effect.gen(function* () {
|
||||
const value = current.get(session.id)
|
||||
if (!value) return { status: owned.has(session.id) ? ("owned-detached" as const) : ("unowned" as const) }
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
value.users++
|
||||
}),
|
||||
() => release(value),
|
||||
)
|
||||
return { status: "attached" as const, capabilities: value.capabilities }
|
||||
}),
|
||||
pin: (id) => {
|
||||
const value = current.get(id)
|
||||
if (!value) return
|
||||
value.users++
|
||||
pinned.set(id, value)
|
||||
},
|
||||
pinned: (id) => pinned.get(id)?.runner,
|
||||
settle: (id) =>
|
||||
Effect.suspend(() => {
|
||||
const value = pinned.get(id)
|
||||
if (!value) return Effect.void
|
||||
pinned.delete(id)
|
||||
return release(value)
|
||||
}),
|
||||
remove: (id) =>
|
||||
Effect.gen(function* () {
|
||||
const value = current.get(id)
|
||||
current.delete(id)
|
||||
if (value) {
|
||||
value.current = false
|
||||
yield* retire(value)
|
||||
}
|
||||
if (!owned.has(id)) return
|
||||
yield* kv.remove(prefix + id)
|
||||
owned.delete(id)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
KV.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionStore.node,
|
||||
llmClient,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
webSocketConstructor,
|
||||
// Request preparation reads this Reference from the captured globals, not its fallback metadata.
|
||||
App.node,
|
||||
],
|
||||
})
|
||||
|
||||
/** TODO: replace this defect with the typed error in the capability-gated operations phase. */
|
||||
export const unavailable = (sessionID: SessionSchema.ID) =>
|
||||
Effect.die(new Error(`Session must be reopened with capabilities: ${sessionID}`))
|
||||
@@ -52,8 +52,6 @@ type Execution<E, Reason> = {
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
|
||||
/** Controls new busy periods, including late successors; existing execution may finish. */
|
||||
readonly eligible?: (key: Key) => boolean
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -109,7 +107,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake && (options.eligible?.(key) ?? true)) start(key, false, execution.pendingWake)
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
@@ -123,13 +121,11 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
}
|
||||
if (options.eligible?.(key) === false) return Effect.interrupt
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, scope: Promotable = "input") =>
|
||||
Effect.sync(() => {
|
||||
if (options.eligible?.(key) === false) return
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
export * as Source from "./source.js"
|
||||
|
||||
import { Effect, Ref } from "effect"
|
||||
import type { SessionSchema } from "./session/schema.js"
|
||||
|
||||
export interface Interface<T, E = never> {
|
||||
/** Return replacement values when changing state; consumers may reuse derived results by reference identity. */
|
||||
readonly get: (session: SessionSchema.Info) => Effect.Effect<T, E>
|
||||
}
|
||||
|
||||
export type Value<T, E = never> = T | Interface<T, E>
|
||||
|
||||
export interface Mutable<T> extends Interface<T> {
|
||||
readonly set: (value: T) => Effect.Effect<void>
|
||||
readonly update: (update: (value: T) => T) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function mutable<T>(initial: T): Mutable<T> {
|
||||
const ref = Ref.makeUnsafe(initial)
|
||||
return {
|
||||
get: () => Ref.get(ref),
|
||||
set: (value) => Ref.set(ref, value),
|
||||
update: (update) => Ref.update(ref, update),
|
||||
}
|
||||
}
|
||||
|
||||
export function constant<T>(value: T): Interface<T> {
|
||||
return { get: () => Effect.succeed(value) }
|
||||
}
|
||||
|
||||
export function from<T, E = never>(value: Value<T, E>): Interface<T, E> {
|
||||
return isSource(value) ? value : constant(value)
|
||||
}
|
||||
|
||||
function isSource<T, E>(value: Value<T, E>): value is Interface<T, E> {
|
||||
return typeof value === "object" && value !== null && "get" in value && typeof value.get === "function"
|
||||
}
|
||||
@@ -45,7 +45,7 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
|
||||
)
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
+205
-208
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)) })
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 }))),
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -18,7 +18,6 @@ import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionResolve } from "@opencode-ai/core/session/resolve"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
@@ -27,9 +26,7 @@ import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node, SessionResolve.node]),
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
|
||||
)
|
||||
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
@@ -1138,7 +1135,6 @@ function buildExecution(
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = overrideJobs ?? (yield* Job.Service)
|
||||
const sessions = yield* Session.Service
|
||||
const resolve = yield* SessionResolve.Service
|
||||
const sessionLayer = Layer.effect(
|
||||
Session.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -1174,7 +1170,6 @@ function buildExecution(
|
||||
Layer.provide(Layer.succeed(Database.Service, database)),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
Layer.provide(Layer.succeed(SessionResolve.Service, resolve)),
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
Layer.provide(locations),
|
||||
),
|
||||
|
||||
@@ -1,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([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 })],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user