Compare commits

..
Author SHA1 Message Date
Hona 3ab166579f docs(core): clarify Node compatibility guidance 2026-08-29 03:20:33 +00:00
Hona 370acf9070 docs(core): require Node-compatible runtime APIs 2026-08-29 02:59:44 +00:00
200 changed files with 3448 additions and 7363 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-No3mCuG2tGQauX1HUpO+rebiWh+rrpSHUCC7rtXFu1s=",
"aarch64-linux": "sha256-8joWv1iDkc6TejEukGBEX0wW8DPs55wJEPY7+9+HDdM=",
"aarch64-darwin": "sha256-4MWGFQUIP1Ae4dujztTb4G8/uDJx2wiOoypcPSUWDbw=",
"x86_64-darwin": "sha256-BtvnraCJmVagtA3Iv+EbWodjFG74sTd2Purqgo7Wkr4="
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
}
}
-1
View File
@@ -1,6 +1,5 @@
src/assets/theme.css
e2e/test-results
e2e/performance/results/
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
@@ -1,36 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
await page.addInitScript(() => {
const replace = Element.prototype.replaceChildren
Element.prototype.replaceChildren = function (this: Element, ...nodes) {
// The ref can run before data-component is assigned, so count on every target.
this.setAttribute("data-test-replacements", String(Number(this.getAttribute("data-test-replacements")) + 1))
return replace.apply(this, nodes)
}
})
const component = await mount("opencode-composer-flow--failed-submission-restoration")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
await expect(input).toHaveText("Preserve this draft on failure")
await expect(input).toHaveAttribute("data-test-replacements", "1")
await input.press("Home")
await input.press("Shift+ArrowRight")
await input.pressSequentially("XY")
await expect(input).toHaveText("XYreserve this draft on failure")
await expect(input).toHaveAttribute("data-test-replacements", "1")
// Closing the model picker restores the controller's saved caret through its editor ref.
await component.locator('[data-action="composer-model"]').click()
await page.getByRole("menu").getByRole("textbox").press("Escape")
await expect(input).toBeFocused()
await input.pressSequentially("!")
await expect(input).toHaveText("XY!reserve this draft on failure")
await component.getByRole("button", { name: "Send", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Submission failed; draft restored")
await expect(input).toHaveText("Preserve this draft on failure")
})
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
@@ -1,135 +0,0 @@
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
const host = document.createElement("main")
host.dataset.testid = "timeline-virtualizer-fixture"
host.dataset.scrolls = "0"
host.dataset.viewportResizes = "0"
host.style.cssText = "position:fixed;top:24px;right:24px;width:400px;z-index:1000"
document.body.appendChild(host)
function Fixture() {
const [state, setState] = createStore({ pinned: true, ready: false })
const rows = Array.from(
{ length: input.count },
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement
const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
projection: {
rows: () => rows,
rowByKey: () => rowByKey,
activeMessageID: () => undefined,
messageRowIndex: () => indexes,
messageLastRowIndex: () => indexes,
},
showHeader: () => false,
pinned: () => state.pinned,
scroll: () => ({ overflow: false, jump: false }),
setScrollRef: (element) => {
if (!element) return
viewport = element
resize.observe(element, { box: "border-box" })
},
setContentRef: (element) => {
content = element
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
},
onPin: () => setState("pinned", true),
onUnpin: () => setState("pinned", false),
onScheduleScrollState: (element) => {
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
host.dataset.lastScrollTop = String(element.scrollTop)
},
onResumeScroll: () => {},
onSelectionInteraction: () => {},
onUserScroll: () => {},
onHistoryScroll: () => {},
canRenderImmediately: () => input.immediate ?? false,
})
const resize = new ResizeObserver((entries) => {
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
})
const reveal = new MutationObserver(() => {
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
// Capture the first reveal, not a later frame after geometry has recovered.
const mounted = [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")]
host.dataset.firstReveal = JSON.stringify({
rows: mounted.map((element) => Number(element.firstElementChild!.getAttribute("data-index"))),
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
viewportHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
clipped: mounted
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
.map((element) => element.dataset.timelineKey),
})
})
onCleanup(() => {
resize.disconnect()
reveal.disconnect()
})
return (
<div data-testid="timeline-controls" data-pinned={state.pinned}>
<button type="button" onClick={() => setState("ready", true)}>
Complete Markdown
</button>
<button type="button" onClick={() => (container.style.display = "none")}>
Hide viewport
</button>
<button
type="button"
onClick={() => {
const parent = viewport.parentElement!
host.dataset.scrolls = "0"
// Keep the same scroller and complete Markdown while it has no layout box.
viewport.remove()
viewport.scrollTop = 0
setState("ready", true)
parent.prepend(viewport)
container.style.removeProperty("display")
}}
>
Reconnect ready rows
</button>
<div ref={container} style={{ height: "180px", width: "400px" }}>
<timeline.View
header={null}
workspaceSession={() => false}
deferred={() => false}
renderRow={(row) => (
<div
data-component="markdown"
data-markdown-ready={state.ready ? "" : undefined}
style={{ height: `${input.rowHeight}px` }}
>
{row().userMessageID}
</div>
)}
/>
</div>
</div>
)
}
render(
() => (
<LanguageProvider locale="en">
<Fixture />
</LanguageProvider>
),
host,
)
}
@@ -1,79 +0,0 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
})
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 100, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await expect(root).toHaveAttribute("data-observed-height", "180")
await expect(content).toHaveCSS("visibility", "hidden")
await expect(content.locator("[data-timeline-key]")).toHaveCount(4)
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
rows: [96, 97, 98, 99],
pendingMarkdown: 0,
clipped: [],
viewportHeight: 180,
})
})
for (const input of [
{ name: "offset-only", count: 1, rowHeight: 600 },
{ name: "zero-height", count: 4, rowHeight: 60 },
]) {
story(`reveals ready measured rows after an ${input.name} reconnect`, async ({ page }) => {
await page.evaluate(
async ({ fixture, input }) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer(input)
},
{ fixture, input },
)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await expect(root).toHaveAttribute("data-observed-height", "180")
await expect(content).toHaveCSS("visibility", "hidden")
await expect(content.locator("[data-timeline-key]")).toHaveCount(1)
if (input.name === "offset-only") {
await expect(root).toHaveAttribute("data-last-scroll-top", "484")
await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 })
await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
}
if (input.name === "zero-height") {
await root.getByRole("button", { name: "Hide viewport", exact: true }).click()
// Wait for ResizeObserver to clear the actual range, not just for display:none.
await expect(root).toHaveAttribute("data-observed-height", "0")
await expect(content.locator("[data-timeline-key]")).toHaveCount(0)
}
await expect(root).not.toHaveAttribute("data-first-reveal")
const resizes = await root.getAttribute("data-viewport-resizes")
await root.getByRole("button", { name: "Reconnect ready rows", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
rows: input.count === 1 ? [0] : [0, 1, 2, 3],
pendingMarkdown: 0,
clipped: [],
viewportHeight: 180,
...(input.name === "offset-only" ? { scrollTop: 0 } : {}),
})
if (input.name === "offset-only") {
// This repair must not depend on another native scroll or resize delivery.
await expect(root).toHaveAttribute("data-scrolls", "0")
await expect(root).toHaveAttribute("data-viewport-resizes", resizes!)
}
})
}
+4 -51
View File
@@ -65,7 +65,7 @@ The fixture requires every benchmark to call `report()`, automatically names and
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
```
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, optional trace path, and trace scope before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide; the default window is page lifetime. Tab-switch traces begin after scenario setup and include explicit interaction markers. Scenario metrics use their own narrower observation windows.
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
@@ -81,60 +81,13 @@ Committed smoke and regression tests continue to own correctness coverage for pa
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=20` for a baseline distribution. Warm scenarios prepare the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
Cold means the destination transcript has never rendered in that fresh browser context. Warm means its complex answer was rendered and ready before switching away and back. Both use the app's normal restored-tab data prefetch, which completes before measurement; neither includes app startup, the source session's Markdown engine initialization, or a cold backend fetch. The suite asserts no message fetch during either measured switch. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
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=20 --retries=0
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
```
**The tab-switch fixture is not an end-to-end cold-data benchmark.** It prefetches destination messages and returns full history. Measure cold API navigation, Home-row opening, and prefetched-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
For a repeatable tab-switch summary, run from `packages/app`:
```sh
bun run bench:tabs
```
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from prefetched tab remounts.
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
Complete original `BENCHMARK` JSON records, including samples, context, and failed records, are saved as `tab-switch-benchmark.jsonl` in Playwright's configured output directory (default: `e2e/test-results/performance`). Standard Playwright flags can override defaults when appended:
```sh
bun run bench:tabs --repeat-each=3 --output=e2e/test-results/tabs-smoke
```
Set `OPENCODE_PERFORMANCE_MEMORY=1` for an opt-in renderer-main-isolate heap and DOM sample after mounted content is ready and an explicit GC completes. Probe DOM references are released before collection. This is not total desktop memory; do not mix these diagnostic runs with unprofiled latency samples. Set `OPENCODE_PERFORMANCE_TRACE_DIR` for a separate Chrome trace of each tab interaction, starting after preparation, with `session-switch:start`, `session-switch:ready`, and `session-switch:stable` markers.
### Cache-Enabled HTTP Fixture
The default tab harness uses Playwright routing for API responses. Playwright routing disables the browser HTTP cache, including for unrelated SVG assets. To measure with HTTP caching enabled, the same API handlers and tab data can run on a real loopback HTTP endpoint:
```sh
bun run build
bun e2e/performance/tab-switch-server.ts --port 4639 --dist dist
```
With that fixture running, run the benchmark in a separate terminal from `packages/app`:
```powershell
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:4639"
$env:OPENCODE_PERFORMANCE_HTTP_FIXTURE = "1"
bun run bench:tabs
```
Use `--dist` to select a frozen production bundle when comparing revisions. An explicit `PLAYWRIGHT_BASE_URL` means the benchmark does not rebuild or start another preview. The fixture gives hashed assets immutable cache headers; it serves the deterministic read workload, not the live OpenCode service. Each test still gets a fresh browser context, and source-session setup still occurs before the measured switch. API responses use `no-store`, service workers remain blocked, and no destination Markdown is rendered before a cold switch. Records identify the transport as `http` or `playwright-route`; keep these series separate. Unset `OPENCODE_PERFORMANCE_HTTP_FIXTURE` when returning to the default routed harness.
## Retained renderer memory
Run the catalog workload against the production app bundle:
@@ -158,7 +111,7 @@ bunx playwright test --config e2e/performance/playwright.config.ts \
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
Trace capture follows [Puppeteer's tracing lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. V8 CPU sample stacks support attribution through the frozen build's source maps. Set `OPENCODE_PERFORMANCE_STACK_TRACE=1` only when per-event timeline stacks are needed; they add substantial overhead. Keep profiled runs separate from latency distributions, including when comparing the stack-capture modes.
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
+7 -17
View File
@@ -5,20 +5,16 @@ type BenchmarkFixtures = {
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
benchmarkResult: void
traceScope: "page" | "interaction"
}
export type PerformancePageDiagnostics = {
navigations: string[]
traceScope: "page" | "interaction"
startTrace: () => Promise<void>
stop: () => Promise<string | undefined>
}
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
export const benchmark = base.extend<BenchmarkFixtures>({
traceScope: ["page", { option: true }],
reportState: async ({}, use) => use({}),
report: async ({ reportState }, use) => {
await use((metrics, context = {}) => {
@@ -53,9 +49,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
},
{ auto: true },
],
page: async ({ page, traceScope }, use, testInfo) => {
page: async ({ page }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name, traceScope)
const diagnostics = await observePerformancePage(page, name)
try {
await use(page)
} finally {
@@ -79,30 +75,25 @@ function benchmarkName(testInfo: TestInfo) {
export { expect }
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
async function observePerformancePage(page: Page, name: string) {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopping: Promise<string | undefined> | undefined
const diagnostics: PerformancePageDiagnostics = {
navigations,
traceScope,
async startTrace() {
stopTrace ??= await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
},
stop() {
page.off("framenavigated", onNavigation)
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
},
}
pages.set(page, diagnostics)
if (traceScope === "page") await diagnostics.startTrace()
return diagnostics
}
@@ -139,7 +130,6 @@ async function reportPerformancePage(name: string, diagnostics: PerformancePageD
context: {
platform: process.platform,
trace,
traceScope: diagnostics.traceScope,
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
},
navigations: diagnostics.navigations,
+1 -3
View File
@@ -14,6 +14,7 @@ const categories = [
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
@@ -33,9 +34,6 @@ export async function startChromeTrace(page: Page, name: string): Promise<undefi
.map((category) => category.slice(1)),
includedCategories: [
...categories.filter((category) => !category.startsWith("-")),
...(process.env.OPENCODE_PERFORMANCE_STACK_TRACE === "1"
? ["disabled-by-default-devtools.timeline.stack"]
: []),
...(selectors
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
: []),
@@ -1,99 +0,0 @@
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
type BenchmarkRecord = {
status?: string
metrics?: { firstCorrectObservedMs?: unknown; stableObservedMs?: unknown } | null
}
export default class TabSwitchReporter implements Reporter {
private output = ""
private tests: TestCase[] = []
private results: { test: TestCase; status: TestResult["status"]; records: string[] }[] = []
onBegin(config: FullConfig, suite: Suite) {
this.output = config.projects[0].outputDir
this.tests = suite.allTests()
}
onTestEnd(test: TestCase, result: TestResult) {
this.results.push({
test,
status: result.status,
records: Buffer.concat(result.stdout.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk)))
.toString("utf8")
.split(/\r?\n/)
.filter((line) => line.startsWith("BENCHMARK "))
.map((line) => line.slice("BENCHMARK ".length)),
})
}
async onEnd(result: FullResult) {
const file = path.join(this.output, "tab-switch-benchmark.jsonl")
try {
await mkdir(this.output, { recursive: true })
await writeFile(file, this.results.flatMap((entry) => entry.records.map((raw) => `${raw}\n`)).join(""), "utf8")
} catch (error) {
console.error("Could not save tab-switch benchmark records:", error)
return { status: "failed" as const }
}
console.log(`\nTab-switch benchmark: ${result.status}`)
Array.from(new Set(this.tests.map((test) => test.title))).forEach((name) => {
const results = this.results.filter((entry) => entry.test.title === name)
const unrun = this.tests.filter(
(test) => test.title === name && !results.some((entry) => entry.test.id === test.id),
).length
const records = results.flatMap((entry) =>
entry.records.map((raw) => {
try {
return { status: entry.status, record: JSON.parse(raw) as BenchmarkRecord | null }
} catch {
return { status: entry.status, record: { status: "invalid JSON", metrics: null } }
}
}),
)
const passed = records.filter((entry) => entry.status === "passed" && entry.record?.status === "passed")
const valid = passed
.map((entry) => ({
firstCorrectObservedMs: entry.record?.metrics?.firstCorrectObservedMs,
stableObservedMs: entry.record?.metrics?.stableObservedMs,
}))
.filter(
(metrics): metrics is { firstCorrectObservedMs: number; stableObservedMs: number } =>
typeof metrics.firstCorrectObservedMs === "number" &&
Number.isFinite(metrics.firstCorrectObservedMs) &&
typeof metrics.stableObservedMs === "number" &&
Number.isFinite(metrics.stableObservedMs),
)
console.log(`\n${name}`)
console.log(` Tests: ${counts(results.map((entry) => entry.status))}; unrun=${unrun}`)
console.log(
` Records: ${counts(records.map((entry) => entry.record?.status ?? "missing status"))}; ` +
`missing=${results.filter((entry) => entry.records.length === 0).length + unrun}; ` +
`excluded=${records.length - valid.length}; invalid metrics=${passed.length - valid.length}`,
)
;(["firstCorrectObservedMs", "stableObservedMs"] as const).forEach((metric) => {
const values = valid.map((entry) => entry[metric]).sort((a, b) => a - b)
if (values.length === 0) {
console.log(` ${metric}: n=0, median=n/a, p95=n/a`)
return
}
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2
const p95 = values[Math.ceil(values.length * 0.95) - 1]
console.log(` ${metric}: n=${values.length}, median=${median.toFixed(2)} ms, p95=${p95.toFixed(2)} ms`)
})
})
console.log(`\nRaw BENCHMARK records: ${file}`)
}
}
function counts(statuses: string[]) {
return (
Array.from(new Set(statuses))
.map((status) => `${status}=${statuses.filter((value) => value === status).length}`)
.join(", ") || "none"
)
}
@@ -1,61 +0,0 @@
import path from "node:path"
import { parseArgs } from "node:util"
import { createMockServerHandler } from "../utils/mock-server"
import { fixture } from "./timeline/session-timeline-stress.fixture"
import { messages } from "./timeline/session-tab-switch.fixture"
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
const args = parseArgs({
args: Bun.argv.slice(2),
options: { port: { type: "string", default: "4639" }, dist: { type: "string", default: "dist" } },
})
const directory = path.resolve(args.values.dist)
const api = createMockServerHandler({
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
vcsDiff: createReviewDiffs(),
})
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(args.values.port),
idleTimeout: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"id":"evt_fixture_connected","type":"server.connected","data":{}}\n\n'),
)
},
}),
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
)
}
if (url.pathname.startsWith("/api/")) {
const response = await api.handler(request)
response.headers.set("cache-control", "no-store")
return response
}
const file = Bun.file(path.join(directory, url.pathname))
if (!url.pathname.endsWith("/") && (await file.exists())) {
return new Response(file, {
headers: {
"cache-control": url.pathname.startsWith("/_assets/") ? "public, max-age=31536000, immutable" : "no-cache",
},
})
}
return new Response(Bun.file(path.join(directory, "index.html")), { headers: { "cache-control": "no-cache" } })
},
})
console.log(`Tab fixture: ${server.url} (${directory})`)
const close = async () => {
await server.stop(true)
await api.dispose()
}
process.once("SIGINT", close)
process.once("SIGTERM", close)
@@ -1,33 +0,0 @@
import type { NavigationMilestoneSample } from "./navigation-milestones"
import { measureNavigationMilestones } from "./navigation-milestones"
import { benchmark, expect } from "../benchmark"
benchmark(
"navigation milestones start at mousedown and wait for the expected ready controls",
async ({ page, report }) => {
await page.setContent('<button id="open">Open</button><input id="editor" disabled><span id="model">Loading</span>')
const result = await measureNavigationMilestones(page, {
triggerSelector: "#open",
milestones: { editor: { selector: "#editor:enabled:focus" }, model: { selector: "#model", text: "Ready model" } },
navigate: async () => {
await page.getByRole("button", { name: "Open", exact: true }).dispatchEvent("mousedown", { button: 0 })
await page.locator("#editor").evaluate((element: HTMLInputElement) => {
element.disabled = false
element.focus()
})
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: { samples: NavigationMilestoneSample[] } })
.__navigationMilestones?.samples
return samples?.some((sample) => sample.milestones.editor && !sample.milestones.model)
})
await page.locator("#model").evaluate((element) => {
element.textContent = "Ready model"
})
},
})
expect(result.summary.all.firstObservedMs).not.toBeNull()
expect(result.summary.all.firstObservedMs).toBeGreaterThan(result.summary.milestones.editor.firstObservedMs!)
expect(await page.evaluate(() => "__navigationMilestones" in window)).toBe(false)
report(result)
},
)
@@ -36,7 +36,7 @@ export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
milestones: Record<string, { selector: string; visible?: boolean }>
navigate: () => Promise<void>
},
) {
@@ -47,19 +47,11 @@ export async function measureNavigationMilestones(
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string, text?: string) =>
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
if (text !== undefined && element.textContent?.replace(/\s+/g, " ").trim() !== text) return false
const rect = element.getBoundingClientRect()
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.top < innerHeight &&
rect.right > 0 &&
rect.left < innerWidth
)
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
@@ -69,9 +61,7 @@ export async function measureNavigationMilestones(
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false
? !document.querySelector(milestone.selector)
: visible(milestone.selector, milestone.text),
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
]),
)
samples.push({
@@ -103,46 +93,36 @@ export async function measureNavigationMilestones(
}, 0)
})
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.start")
sample()
}
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
},
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
)
try {
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
return (
samples &&
samples.length >= 3 &&
samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
)
})
const samples = await page.evaluate(
() => (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!.samples,
)
return { summary: summarizeNavigationMilestones(samples), samples }
} finally {
await page.evaluate(() => {
const host = window as Window & { __navigationMilestones?: NavigationMilestoneProbe }
host.__navigationMilestones?.stop()
delete host.__navigationMilestones
})
}
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
}
@@ -1,91 +0,0 @@
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const entry of ["home", "session"] as const) {
benchmark(`entry: new session from ${entry}`, async ({ page, report }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page, { sessionIDs: entry === "home" ? [] : [fixture.sourceID] })
await page.goto(entry === "home" ? "/" : stressSessionHref(fixture.sourceID))
if (entry === "session") await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const trigger = entry === "home" ? '[data-action="home-new-session"]' : 'button[aria-label="New session"]'
await expect(page.locator(trigger)).toBeVisible()
await expect(page.locator('[data-component="new-session"]')).toHaveCount(0)
const writes: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/") && !["GET", "HEAD", "OPTIONS"].includes(request.method()))
writes.push(request.method())
})
await benchmarkDiagnostics(page).startTrace()
const result = await measureNavigationMilestones(page, {
triggerSelector: trigger,
milestones: {
editor: {
selector: '[data-component="new-session"] [data-component="composer-editor"][contenteditable="true"]:focus',
},
model: { selector: '[data-component="new-session"] [data-action="composer-model"]', text: "Claude Opus 4.6" },
project: { selector: '[data-component="new-session"] [data-action="prompt-project"]' },
tab: { selector: '[data-titlebar-tab-link][href^="/new-session?draftId="]' },
},
navigate: () => page.locator(trigger).click(),
})
await benchmarkDiagnostics(page).stop()
const editor = page.locator('[data-component="new-session"] [data-component="composer-editor"]')
await expect(editor).toHaveText("")
await page.keyboard.type("Draft input")
await expect(editor).toHaveText("Draft input")
expect(writes).toEqual([])
report(
{
firstCorrectObservedMs: result.summary.all.firstObservedMs,
stableObservedMs: result.summary.all.stableObservedMs,
...result,
},
{ entry, data: "fixture", inputEvent: "mousedown" },
)
})
}
benchmark("entry: cold session from Home", async ({ page, report }) => {
const requests: string[] = []
await mockStressTimeline(page, {
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await installStressSessionTabs(page, { sessionIDs: [] })
await page.goto("/")
const selector = `[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"] [data-component="home-session-row"]`
await expect(page.locator(selector)).toBeVisible()
expect(requests).not.toContain(fixture.targetID)
const href = stressSessionHref(fixture.targetID)
await benchmarkDiagnostics(page).startTrace()
const result = await measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: [],
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1)!,
href,
triggerSelector: selector,
switch: async () => {
await page.locator(selector).click()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
},
})
await benchmarkDiagnostics(page).stop()
await expect(
page.locator(`[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-link][href="${href}"]`),
).toHaveCount(1)
expect(requests).toContain(fixture.targetID)
report(result, { entry: "home", data: "cold paginated fixture", inputEvent: "mousedown" })
})
@@ -1,129 +1,71 @@
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { expected, messages, workload } from "./session-tab-switch.fixture"
import {
createReviewDiffs,
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
const scenarios = [
{ cache: "cold", review: "closed" },
{ cache: "cold", review: "open" },
{ cache: "warm", review: "closed" },
{ cache: "warm", review: "open" },
{ cache: "warm", review: "resized" },
] as const
const viewport = { width: 1440, height: 900 }
const reviewDiffs = createReviewDiffs()
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
{ 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 },
]
scenarios.forEach((scenario) => {
benchmark(`tab switch: ${scenario.cache}, review ${scenario.review}`, async ({ page, report }, testInfo) => {
const requests = await prepareSessionTabs(page)
if (scenario.review === "open") await openReviewPane(page)
if (scenario.cache === "warm") {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
await expectReadyTimeline(page, fixture.targetID)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
if (scenario.review === "resized") await openReviewPane(page)
await expectReadyTimeline(page, fixture.sourceID)
await benchmarkDiagnostics(page).startTrace()
const requestsBefore = requests.length
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)
}
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const result = await measureSessionSwitch(page, {
destinationIDs: messages[fixture.targetID].map((message) => message.id),
sourceIDs: messages[fixture.sourceID].map((message) => message.id),
lastID: expected[fixture.targetID].lastID,
requiredPartID: expected[fixture.targetID].answerID,
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(requests).toHaveLength(requestsBefore)
await expectReadyTimeline(page, fixture.targetID)
report(
{
...result,
messageRequestsDuringSwitch: requests.length - requestsBefore,
rendererMemory:
process.env.OPENCODE_PERFORMANCE_MEMORY === "1" ? await retainedRendererMemory(page) : undefined,
},
{
...scenario,
...workload,
viewport,
browserVersion: page.context().browser()!.version(),
serviceWorkers: "blocked",
reviewFiles: scenario.review === "closed" ? 0 : reviewDiffs.length,
data: "prefetched",
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
requireReadyAnswer: true,
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,
)
if (testInfo.repeatEachIndex === 0) {
await page.screenshot({ path: testInfo.outputPath("destination.png") })
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
}
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 prepareSessionTabs(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") return
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (match) requests.push(decodeURIComponent(match[1]))
})
if (process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE !== "1")
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
// Return the full history so every scenario exercises a long loaded timeline.
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
vcsDiff: reviewDiffs,
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
// Restored tabs prefetch their data even when their transcript has never rendered.
const prefetch = page.waitForResponse((response) =>
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
)
await page.goto(stressSessionHref(fixture.sourceID))
expect(await (await prefetch).finished()).toBeNull()
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expectReadyTimeline(page, fixture.sourceID)
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
expect(requests.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
return requests
}
async function expectReadyTimeline(page: Page, sessionID: string) {
const answer = page.locator(`[data-timeline-part-id="${expected[sessionID].answerID}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(answer.getByRole("table")).toHaveCount(1)
await expect(answer.locator("pre")).toHaveCount(4)
await expect
.poll(() => answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })))
.toBe(true)
await waitForStableTimeline(page, expected[sessionID].lastID)
await expect(page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(
0,
)
}
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)
@@ -138,20 +80,4 @@ async function openReviewPane(page: Page) {
const text = document.querySelector("#review-panel")?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
await page.locator('[data-slot="session-chat-panel"]').evaluate(async (panel) => {
await Promise.all(panel.getAnimations().map((animation) => animation.finished))
})
}
async function retainedRendererMemory(page: Page) {
const cdp = await page.context().newCDPSession(page)
try {
await cdp.send("HeapProfiler.collectGarbage")
return {
heap: await cdp.send("Runtime.getHeapUsage"),
dom: await cdp.send("Memory.getDOMCounters"),
}
} finally {
await cdp.detach()
}
}
@@ -2,77 +2,66 @@ import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
for (const tag of ["a", "button"] as const) {
benchmark(
`starts at ${tag} mousedown and excludes hidden or unfinished destination content`,
async ({ page, report }) => {
await page.setContent(`
<${tag} id="destination" ${tag === "a" ? 'href="/session/destination"' : 'type="button"'}>Destination</${tag}>
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("#destination")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
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)
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
triggerSelector: tag === "button" ? "#destination" : undefined,
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page
.getByRole(tag === "a" ? "link" : "button", { name: "Destination", exact: true })
.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"
})
},
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!)
expect(await page.evaluate(() => "__sessionSwitchProbe" in window)).toBe(false)
report(result)
},
)
}
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
@@ -14,132 +14,124 @@ async function installSessionSwitchProbe(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
},
) {
await page.evaluate(
({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, triggerSelector, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
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"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
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"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
: undefined
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
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 reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
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)
})
: undefined
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
: undefined
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
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)
)
}
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const trigger = event.target instanceof Element ? event.target.closest(triggerSelector ?? "a") : undefined
if (!trigger || (!triggerSelector && trigger.getAttribute("href") !== href)) return
started = performance.now()
performance.mark("session-switch:start", { startTime: started })
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
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)
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
}
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)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
Object.keys(initialReviewNodes).forEach((key) => (initialReviewNodes[key] = null))
},
}
},
input,
)
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
}
async function waitForStableSessionSwitch(page: Page) {
@@ -167,17 +159,9 @@ async function collectSessionSwitchResult(page: Page) {
const samples = await page.evaluate(() => {
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
probe.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
return probe.samples
})
const result = classifySessionSwitch(samples)
await page.evaluate(({ firstCorrectObservedMs, stableObservedMs }) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
if (firstCorrectObservedMs !== null)
performance.mark("session-switch:ready", { startTime: start + firstCorrectObservedMs })
if (stableObservedMs !== null) performance.mark("session-switch:stable", { startTime: start + stableObservedMs })
}, result)
return result
return classifySessionSwitch(samples)
}
export async function measureSessionSwitch(
@@ -188,7 +172,6 @@ export async function measureSessionSwitch(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
switch: () => Promise<void>
},
@@ -202,7 +185,6 @@ export async function measureSessionSwitch(
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
})
}
}
@@ -1,143 +0,0 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { fixture } from "./session-timeline-stress.fixture"
export const exchanges = 200
export const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((sessionID) => [
sessionID,
Array.from({ length: exchanges }, (_, index) => {
const seed = fixture.messages[fixture.targetID]
const user = seed[(index % (seed.length / 2)) * 2]!
const assistant = seed[(index % (seed.length / 2)) * 2 + 1]!
if (user.type !== "user" || assistant.type !== "assistant") throw new Error("Expected a user/assistant pair")
const suffix = `${sessionID}_${String(index).padStart(4, "0")}`
return [
{
...user,
id: `msg_user_${suffix}`,
time: { created: 1700000000000 + index * 10_000 },
},
{
...assistant,
id: `msg_assistant_${suffix}`,
time: { created: 1700000001000 + index * 10_000, completed: 1700000008000 + index * 10_000 },
content: [
...assistant.content
.filter((part) => part.type !== "text")
.map((part) => (part.type === "tool" ? { ...part, id: `${part.id}_${suffix}` } : part)),
{ type: "text", text: complexMarkdown(sessionID, index) },
],
},
] satisfies SessionMessageInfo[]
}).flat(),
]),
)
export const expected = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((sessionID) => [
sessionID,
{
lastID: messages[sessionID].at(-2)!.id,
answerID: `${messages[sessionID].at(-1)!.id}:text:0`,
},
]),
)
export const workload = {
fixture: "long-complex-markdown-v1",
exchangesPerSession: exchanges,
messagesPerSession: exchanges * 2,
history: "full fixture history in one response",
sessions: Object.fromEntries(
Object.entries(messages).map(([sessionID, items]) => [
sessionID,
{
payloadBytes: Buffer.byteLength(JSON.stringify(items)),
markdownBytes: items.reduce(
(total, message) =>
total +
(message.type === "assistant"
? message.content.reduce(
(size, part) => size + (part.type === "text" ? Buffer.byteLength(part.text) : 0),
0,
)
: 0),
0,
),
},
]),
),
}
function complexMarkdown(sessionID: string, index: number) {
return `## Renderer review ${sessionID} / ${index}
Preserve **semantic identity**, *measured geometry*, and ~~obsolete estimates~~ when switching sessions. The \`measureElement(node)\` result must agree with the [rendering contract](https://example.com/rendering/${sessionID}/${index}).
> A completed answer contains formatted prose, highlighted source, and structured results.
> Keep the previous view until the destination is ready, rather than exposing partially formatted content.
### Readiness checklist
- [x] Resolve the destination session and its messages.
- [x] Parse Markdown and highlight fenced code.
- [ ] Verify a different panel width.
- Preserve the bottom anchor.
- Reuse the measured rows when their width matches.
| Stage | Input | Expected result | Verification |
| :--- | ---: | :--- | :--- |
${Array.from({ length: 8 }, (_, row) => `| stage-${index}-${row} | ${index * 8 + row} | **ready** with \`row[${row}]\` | stable geometry and visible content |`).join("\n")}
### Implementation
\`\`\`tsx
import { For, Show, createMemo } from "solid-js"
type Row = { id: string; title: string; ready: boolean; height: number }
export function SessionRows${index}(props: { rows: Row[]; selected: string }) {
const visible = createMemo(() => props.rows.filter((row) => row.ready))
return (
<section aria-label="${sessionID}-${index}">
<For each={visible()}>{(row) => (
<article data-selected={row.id === props.selected}>
<h3>{row.title}</h3>
<Show when={row.height > 0} fallback={<span>Measuring</span>}>
<output>{row.height.toFixed(2)} pixels</output>
</Show>
</article>
)}</For>
</section>
)
}
\`\`\`
\`\`\`json
${JSON.stringify({ session: sessionID, exchange: index, stages: ["hydrate", "parse", "highlight", "measure"], viewport: { width: 1440, height: 900 }, cache: { markdown: true, geometry: true } }, null, 2)}
\`\`\`
\`\`\`sql
SELECT session_id, COUNT(*) AS messages, MAX(created_at) AS latest
FROM session_message
WHERE session_id = '${sessionID}' AND ordinal >= ${index}
GROUP BY session_id
ORDER BY latest DESC;
\`\`\`
### Verification
1. Open the long source session and wait for its final answer.
2. Select the destination tab, without changing the viewport.
3. Confirm that **all Markdown is ready** and the bottom anchor is correct.
\`\`\`bash
bun typecheck
bunx playwright test --config e2e/performance/playwright.config.ts
git diff --check # ${sessionID}-${index}
\`\`\`
**Review complete: ${sessionID} / ${index}.**
`
}
@@ -1,114 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import type { FullConfig, Suite, TestCase, TestResult } from "@playwright/test/reporter"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import TabSwitchReporter from "../tab-switch-reporter"
test("summarizes each scenario and saves complete records in the configured output directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
const output = path.join(root, "configured-output")
const log = spyOn(console, "log").mockImplementation(() => {})
try {
const reporter = new TabSwitchReporter()
const cases = Array.from(
{ length: 23 },
(_, index) => ({ id: String(index), title: index < 20 ? "cold" : "warm" }) as TestCase,
)
const records = cases.map((_, index) => {
const first = index < 20 ? 20 - index : [40, 0, 20][index - 20]
return JSON.stringify({
status: "passed",
metrics: {
firstCorrectObservedMs: first,
stableObservedMs: first * 2,
samples: [{ observedAtMs: first, destination: ["answer"], source: [] }],
},
extra: { preserved: "\u03b1" },
})
})
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
cases.forEach((item, index) => {
const bytes = Buffer.from(`BENCHMARK ${records[index]}\r\n`)
const split = bytes.indexOf(Buffer.from("\u03b1")) + 1
reporter.onTestEnd(item, {
status: "passed",
stdout:
index === 0
? [bytes.subarray(0, split), bytes.subarray(split)]
: ["other output\nBENCHMARK_PAGE {}\nBENCH", "MARK ", records[index], "\n"],
} as TestResult)
})
await reporter.onEnd({ status: "passed", startTime: new Date(0), duration: 0 })
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(`${records.join("\n")}\n`)
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
expect(summary).toContain("cold\n Tests: passed=20; unrun=0")
expect(summary).toContain("warm\n Tests: passed=3; unrun=0")
expect(summary).toContain("firstCorrectObservedMs: n=20, median=10.50 ms, p95=19.00 ms")
expect(summary).toContain("stableObservedMs: n=20, median=21.00 ms, p95=38.00 ms")
expect(summary).toContain("firstCorrectObservedMs: n=3, median=20.00 ms, p95=40.00 ms")
expect(summary).toContain("stableObservedMs: n=3, median=40.00 ms, p95=80.00 ms")
} finally {
log.mockRestore()
await rm(root, { recursive: true, force: true })
}
})
test("reports failures, missing records, and invalid metrics without discarding raw data", async () => {
const output = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
const log = spyOn(console, "log").mockImplementation(() => {})
try {
const reporter = new TabSwitchReporter()
const entries = [
{
status: "passed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":12,"stableObservedMs":24}}',
},
{
status: "failed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
},
{
status: "passed",
raw: '{"status":"failed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
},
{ status: "timedOut", raw: '{"status":"failed","metrics":null,"error":"Benchmark did not report metrics"}' },
{
status: "passed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":null,"stableObservedMs":40}}',
},
{ status: "failed", raw: '{"status":' },
{ status: "skipped", raw: undefined },
] as const
const cases = Array.from(
{ length: entries.length + 2 },
(_, index) => ({ id: String(index), title: index <= entries.length ? "cold" : "empty" }) as TestCase,
)
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
entries.forEach((entry, index) => {
reporter.onTestEnd(cases[index], {
status: entry.status,
stdout: entry.raw === undefined ? [] : [`BENCHMARK ${entry.raw}\n`],
} as TestResult)
})
await reporter.onEnd({ status: "interrupted", startTime: new Date(0), duration: 0 })
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(
entries.flatMap((entry) => (entry.raw === undefined ? [] : [`${entry.raw}\n`])).join(""),
)
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
expect(summary).toContain("Tab-switch benchmark: interrupted")
expect(summary).toContain("Tests: passed=3, failed=2, timedOut=1, skipped=1; unrun=1")
expect(summary).toContain("Records: passed=3, failed=2, invalid JSON=1; missing=2; excluded=5; invalid metrics=1")
expect(summary).toContain("firstCorrectObservedMs: n=1, median=12.00 ms, p95=12.00 ms")
expect(summary).toContain("stableObservedMs: n=1, median=24.00 ms, p95=24.00 ms")
expect(summary).toContain("empty\n Tests: none; unrun=1")
expect(summary).toContain("Records: none; missing=1; excluded=0; invalid metrics=0")
expect(summary).toContain("firstCorrectObservedMs: n=0, median=n/a, p95=n/a")
expect(summary).toContain("stableObservedMs: n=0, median=n/a, p95=n/a")
} finally {
log.mockRestore()
await rm(output, { recursive: true, force: true })
}
})
@@ -1,99 +0,0 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
for (const window of ["assistant-only", "mixed"] as const) {
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
// Both 20-message pages begin with an assistant; only page three supplies its parent.
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
const id = `msg_hydration_${index}`
const time = { created: 1700000000000 + index * 1_000 }
if (index === 0 || (window === "mixed" && index === 39))
return { id, type: "user", time, text: `Prompt ${index}` }
return {
id,
type: "assistant",
time: { ...time, completed: time.created + 500 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
}
})
const gates = [21, 1].map((index) => ({
before: messages[index]!.id,
parent: messages[index === 21 ? 1 : 0]!.id,
requested: Promise.withResolvers<void>(),
release: Promise.withResolvers<void>(),
}))
const requests: (string | undefined)[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
beforeMessagesResponse: async ({ before }) => {
requests.push(before)
if (!before) return
const gate = gates.find((gate) => gate.before === before)
if (!gate) throw new Error(`Unexpected older-page boundary: ${before}`)
gate.requested.resolve()
await gate.release.promise
},
pageMessages: (_, limit, before) => {
expect(limit).toBe(20)
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
})
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
const markdown = tail.locator('[data-component="markdown"]')
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
const viewport = page.locator(".scroll-view__viewport", { has: tail })
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
})
const expectReadyTail = async () => {
await expect(content).toHaveCSS("visibility", "visible")
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(markdown.getByRole("heading", { name: "Hydrated tail", exact: true })).toBeInViewport({ ratio: 1 })
await expect
.poll(() =>
viewport.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
)
.toBeLessThanOrEqual(1)
}
try {
await page.goto(stressSessionHref(session.id))
await gates[0]!.requested.promise
// This must pass while the first older response is still held.
await expectReadyTail()
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
if (window === "mixed")
await expect(
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
).toBeInViewport()
const original = await markdown.elementHandle()
for (const gate of gates) {
await gate.requested.promise
gate.release.resolve()
// Parent ownership proves the page reached the projection, not just the network.
await expect(orphan).toHaveAttribute("data-message-id", gate.parent)
await expectReadyTail()
expect(await markdown.evaluate((element, original) => element === original, original)).toBe(true)
}
expect(requests).toEqual([undefined, ...gates.map((gate) => gate.before)])
const ids = await content
.locator("[data-timeline-part-id]")
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-part-id")))
expect(new Set(ids).size).toBe(ids.length)
} finally {
gates.forEach((gate) => gate.release.resolve())
}
})
}
@@ -1,123 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { expected, messages } from "../performance/timeline/session-tab-switch.fixture"
import { installTimelineSettings, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
type Reveal = { pending: number; clipped: string[]; bottomError: number; tables: number; codeBlocks: number }
for (const width of [1440, 390]) {
test(`reveals measured Markdown after the worker completes at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(/markdown\.worker(?:-[^/?]+\.js|\.ts)(?:\?.*)?$/, async (route) => {
requested.resolve()
await release.promise
await route.continue()
})
await page.addInitScript((partID) => {
const observer = new MutationObserver(() => {
const answer = document.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const content = answer?.closest<HTMLElement>("[data-timeline-virtual-content]")
const root = content?.closest<HTMLElement>(".scroll-view__viewport")
if (!answer || !content || !root || !content.checkVisibility({ checkVisibilityCSS: true })) return
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
? [row.dataset.timelineKey!]
: [],
),
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
tables: answer.querySelectorAll("table").length,
codeBlocks: answer.querySelectorAll("pre").length,
}
observer.disconnect()
})
observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] })
}, expected[fixture.sourceID].answerID)
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
project: fixture.project,
provider: fixture.provider,
directory: fixture.directory,
pageMessages: () => ({ items: messages[fixture.sourceID] }),
})
await installTimelineSettings(page)
try {
await page.goto(stressSessionHref(fixture.sourceID), { waitUntil: "domcontentloaded" })
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
release.resolve()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
} finally {
release.resolve()
}
})
}
test("scrolls within a long answer without mounting unrelated history", async ({ page }) => {
await openTimeline(page, messages[fixture.sourceID])
const answer = page.locator(`[data-timeline-part-id="${expected[fixture.sourceID].answerID}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(answer.getByRole("table")).toHaveCount(1)
const scroller = page.locator(".scroll-view__viewport", { has: answer })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const rows = page.locator("[data-timeline-key]")
const keys = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-timeline-key")),
)
const top = await answer.evaluate((element) => element.getBoundingClientRect().top)
await scroller.hover()
await page.mouse.wheel(0, -240)
await expect.poll(() => answer.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top + 240, 0)
expect(
await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-key"))),
).toEqual(keys)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
})
test("fills a short cold transcript before revealing it", async ({ page }) => {
const history = messages[fixture.sourceID].slice(-6).map((message, index) => {
if (message.type === "user") return { ...message, text: `Prompt ${index}`, metadata: undefined }
if (message.type === "assistant")
return { ...message, content: [{ type: "text" as const, text: `**Answer ${index}**` }] }
return message
})
await openTimeline(page, history)
for (const message of history) {
if (message.type === "user") {
await expect(page.locator(`[data-timeline-row="UserMessage"][data-message-id="${message.id}"]`)).toBeInViewport()
}
if (message.type === "assistant") {
const answer = page.locator(`[data-timeline-part-id="${message.id}:text:0"]`)
await expect(answer).toBeInViewport()
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
}
}
})
async function openTimeline(page: Page, history: SessionMessageInfo[]) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
project: fixture.project,
provider: fixture.provider,
directory: fixture.directory,
pageMessages: () => ({ items: history }),
})
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
@@ -102,14 +102,6 @@ test("cramped tabs only show the close button for the active tab", async ({ page
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
for (const direction of ["ltr", "rtl"]) {
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
await page.setViewportSize({ width: 450, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
await page.setViewportSize({ width: 1280, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
}
})
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
@@ -296,12 +288,11 @@ async function mockServer(page: Page) {
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/inbox`)) return json(route, { data: [] })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
@@ -355,14 +355,6 @@ test.describe("smoke: session timeline", () => {
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
// The shell is below a long diff; reveal it rather than depending on offscreen overscan.
while ((await shell.count()) === 0) {
const before = await timelineState(page)
await timelineScroller(page).press("PageDown")
await expect.poll(async () => (await timelineState(page)).signature).not.toBe(before.signature)
}
await shell.scrollIntoViewIfNeeded()
await expect(shell).toBeInViewport()
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
@@ -702,7 +694,6 @@ async function expectSessionTimelineReady(
expectedMessageIDs: string[],
errors: string[],
) {
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
await waitForTimelineStable(page)
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
const currentState = await timelineState(page)
@@ -1,16 +1,10 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project and selects its model", async ({ page }) => {
// An empty draft must remain usable when the file viewer is unavailable.
await page.route(/(?:\/_assets\/file-(?!icon-)[^/]+\.js|\/session-ui\/src\/components\/file\.tsx)(?:\?|$)/, (route) =>
route.abort(),
)
await mockOpenCodeServer(page, {
directory,
project: {
@@ -66,7 +60,7 @@ test("creates a session in a new project and selects its model", async ({ page }
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
],
recent: [{ providerID: "opencode-go", modelID: "go-model-1" }],
recent: [],
variant: {},
}),
)
@@ -87,73 +81,11 @@ test("creates a session in a new project and selects its model", async ({ page }
await expectAppVisible(page.locator('[data-component="composer"]'))
const modelControl = page.locator('[data-action="composer-model"]')
await expect(modelControl).toContainText("Go Model 1")
await modelControl.click()
await page.locator('[data-option-key="opencode:free-model"]').click()
await expect(modelControl).toContainText("Free Model")
await modelControl.click()
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
await expect(modelControl).toContainText("Go Model 1")
})
test("restores each existing session's model and variant when switching tabs", async ({ page }) => {
const sessions = ["A", "B"].map((name) => ({
...fixture.sessions[0],
id: `ses_model_${name}`,
title: `Model ${name}`,
model: { id: `model-${name}`, providerID: "opencode", variant: "balanced" },
}))
await mockOpenCodeServer(page, {
...fixture,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: Object.fromEntries(
sessions.map((session) => [
session.model.id,
{
id: session.model.id,
name: session.title,
limit: { context: 200_000 },
variants: { balanced: {}, high: {} },
},
]),
),
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: sessions[0]!.model.id },
},
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
const hrefA = stressSessionHref(sessions[0]!.id)
const hrefB = stressSessionHref(sessions[1]!.id)
await page.goto(hrefA)
const composer = page.locator('[data-component="composer"]')
const modelControl = composer.locator('[data-action="composer-model"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await expect(modelControl).toHaveText("Model A")
await expect(variant).toHaveText("balanced")
await variant.click()
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
await expect(variant).toHaveText("high")
await page.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
await expect(page).toHaveURL(hrefB)
await expect(modelControl).toHaveText("Model B")
await expect(variant).toHaveText("balanced")
await page.locator(`[data-titlebar-tab-link][href="${hrefA}"]`).click()
await expect(page).toHaveURL(hrefA)
await expect(modelControl).toHaveText("Model A")
await expect(variant).toHaveText("high")
})
+8 -11
View File
@@ -47,6 +47,7 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
@@ -134,7 +135,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = createMockServerHandler(config)
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
page.on("close", () => void transport.dispose())
await page.route("**/api/**", async (route) => {
@@ -166,16 +173,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
export function createMockServerHandler(config: MockServerConfig) {
return HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, { cursors: new Map<string, string>(), nextCursor: 0 })),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
-2
View File
@@ -34,8 +34,6 @@
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
"bench:entry": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-entry-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
},
"license": "MIT",
@@ -150,6 +150,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderComposerEditor(element, props.controller.parts())
}}
data-component="composer-editor"
role="textbox"
@@ -278,15 +278,16 @@ export function createHomeSessionsController(home: HomeController) {
const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext()
if (!ctx) return
if (!options?.background) void ctx.data.session.message.sync(session.id).catch(() => undefined)
// Commit cache/project changes with navigation instead of rebuilding
// the outgoing Home list before leaving it.
ctx.data.session.remember(session)
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: connKey, sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
if (!options?.background) tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
if (!options?.background) ctx.projects.touch(directory)
tabs.select(tab)
})
},
archive: async (session: SessionInfo) => {
@@ -221,7 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
const current = createMemo(() => {
const current = () => {
const item = firstModel(
() => scope()?.model,
() => agent.current()?.model,
@@ -229,7 +229,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
if (!item) return
return models.find(item)
})
}
const configured = () => {
const item = agent.current()
@@ -1,117 +0,0 @@
import type { SessionInfo, SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
type Task =
| { id: string; type: "subagent"; label: string; agent?: string }
| { id: string; type: "shell"; label: string }
export function createSessionBackground(input: {
sessionID: () => string | undefined
messages: (id: string) => SessionMessageInfo[]
sessions: () => SessionInfo[]
status: (id: string) => "idle" | "running"
shells: () => ShellInfo[]
}) {
const history = createMemo(() => {
const completed = new Set<string>()
const subagents: { id: string; type: "subagent"; label: string; agent: string | undefined }[] = []
const shells: { partID: string; task: { id: string; type: "shell"; label: string } }[] = []
const id = input.sessionID()
const assistant = (id ? input.messages(id) : []).reduce<SessionMessageAssistant | undefined>((latest, message) => {
if (message.type === "synthetic") {
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
completed.add(message.metadata.childID)
if (message.metadata?.source === "shell") {
if (typeof message.metadata.shellID === "string") completed.add(message.metadata.shellID)
if (typeof message.metadata.jobID === "string") completed.add(message.metadata.jobID)
}
return latest
}
if (message.type !== "assistant") return latest
message.content.forEach((part) => {
if (part.type !== "tool" || (part.name !== "subagent" && part.name !== "shell")) return
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return
if (part.name === "subagent") {
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string") return
const description = part.state.input.description
const agent = part.state.input.agent
subagents.push({
id: sessionID,
type: "subagent",
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
})
return
}
const shellID = part.state.metadata.shellID
const command = part.state.input.command
shells.push({
partID: part.id,
task: {
id: typeof shellID === "string" ? shellID : part.id,
type: "shell",
label: typeof command === "string" ? command : part.id,
},
})
})
return message.time.completed === undefined ? message : latest
}, undefined)
return {
// Completion notices can identify the shell or its original tool call.
subagents: subagents.filter((task) => !completed.has(task.id)),
shells: shells
.filter((item) => !completed.has(item.partID) && !completed.has(item.task.id))
.map((item) => item.task),
blocking:
assistant?.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
if (part.name !== "shell" && part.name !== "subagent") return []
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
]
}) ?? [],
}
})
const blocking = createMemo(() => history().blocking)
const tasks = createMemo(() => {
const id = input.sessionID()
if (!id) return []
const current = history()
const active = input.sessions().flatMap((info) => {
if (info?.parentID !== id) return []
if (input.status(info.id) === "idle") return []
if (
current.blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
)
)
return []
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
})
const running = input.shells().flatMap((shell) => {
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
if (
current.blocking.some(
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
)
)
return []
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
})
return [
...new Map<string, Task>(
[...current.subagents, ...active, ...current.shells, ...running].map((task) => [task.id, task]),
).values(),
]
})
return { blocking, tasks }
}
+101 -9
View File
@@ -8,7 +8,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
export function createSessionRequestModel() {
@@ -47,12 +46,105 @@ export function createSessionRequestModel() {
const id = params.id
return !!id && !data.session.get(id)?.parentID
}
const background = createSessionBackground({
sessionID: () => (primary() ? params.id : undefined),
messages: data.session.message.list,
sessions: data.session.list,
status: data.session.status,
shells: () => data.shell.list({ directory: sdk().directory }),
const backgroundBlocking = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const assistant = data.session.message
.list(id)
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
if (assistant?.type !== "assistant") return []
return assistant.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
if (part.name !== "shell" && part.name !== "subagent") return []
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
]
})
})
const backgroundTasks = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const blocking = backgroundBlocking()
const messages = data.session.message.list(id)
const completed = new Set(
messages.flatMap((message) => {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell")
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
return []
}),
)
const backgrounded = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "subagent") return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
const agent = part.state.input.agent
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
},
]
})
})
const active = data.session.list().flatMap((info) => {
if (info?.parentID !== id) return []
if (data.session.status(info.id) === "idle") return []
if (
blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
)
)
return []
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
})
const backgroundShells = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
if (typeof shellID === "string" && completed.has(shellID)) return []
const command = part.state.input.command
return [
{
id: typeof shellID === "string" ? shellID : part.id,
type: "shell" as const,
label: typeof command === "string" ? command : part.id,
},
]
})
})
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
if (
blocking.some(
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
)
)
return []
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
})
return [
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
]
})
const moveToBackground = async () => {
if (!primary()) return
@@ -99,8 +191,8 @@ export function createSessionRequestModel() {
permissionRequest,
permissionResponding,
background: {
blocking: background.blocking,
tasks: background.tasks,
blocking: backgroundBlocking,
tasks: backgroundTasks,
move: moveToBackground,
},
decide,
@@ -5,27 +5,18 @@ import { createSessionResolution } from "./session-resolution"
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
const syncs = { session: 0, message: 0 }
let syncs = 0
const sessions = {
get: () => undefined,
sync: () => {
syncs.session++
syncs++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
}
const session = createSessionResolution(
() => undefined,
() => sessions,
)
const session = createSessionResolution(() => undefined, () => sessions)
expect(session()).toBeUndefined()
expect(syncs).toEqual({ session: 0, message: 0 })
expect(syncs).toBe(0)
dispose()
})
})
@@ -1,12 +1,9 @@
import { createMemo, createRenderEffect, createSignal, on, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { sessionNotFoundError } from "@/runtime/server/errors"
type SessionStore<T> = {
get: (id: string) => T | undefined
sync: (id: string, options?: { children?: boolean }) => Promise<unknown>
message: {
sync: (id: string) => Promise<unknown>
}
}
type Resolution<T> = { id: string; store: SessionStore<T> } & (
@@ -43,17 +40,13 @@ export function createSessionResolution<T>(
})
const [status, setStatus] = createSignal<Resolution<T>>()
// Start independent reads before constructing the selected view, including
// when its metadata is cached but its transcript has never been loaded.
createRenderEffect(
createEffect(
on([sessionID, sessions] as const, ([id, store]) => {
if (!id) return
let stale = false
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent.
void store.message.sync(id).catch(() => undefined)
if (cached() && !options?.children) {
setStatus({ id, store, state: "settled" })
return
@@ -79,17 +79,6 @@ describe("visibleTimelineMessages", () => {
?.map((message) => message.id),
).toEqual(["msg_2", "msg_5"])
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
expect([...projection.messageRowIndex()]).toEqual([
["msg_1", 0],
["msg_3", 2],
])
expect([...projection.messageLastRowIndex()]).toEqual([
["msg_1", 1],
["msg_3", 3],
])
expect([...projection.lastAssistantGroupKey()]).toEqual([["msg_1", "context:msg_5:tool_read"]])
expect(projection.rowByKey().get("user-message:msg_1")).toBe(projection.rows()[0])
expect(projection.rowByKey().size).toBe(projection.rows().length)
dispose()
})
})
@@ -21,7 +21,6 @@ import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
import { getReadyMarkdown, preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -317,16 +316,6 @@ type MessageTimelineProps = {
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session })
const tail = props.pinned ? controller.data.projection.rows().at(-1) : undefined
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
const message = controller.data.projection.messageByID().get(tail.group.ref.messageID)
if (message?.type === "assistant" && message.time.completed !== undefined) {
const content = Timeline.resolveContent(message, tail.group.ref.partID)
// Start the required worker job while the rest of the selected view is constructed.
if (content?.type === "text" && content.text.trim())
void preloadMarkdown(content.text, tail.group.ref.partID).catch(() => undefined)
}
}
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
@@ -411,38 +400,6 @@ function MessageTimelineView(
onSelectionInteraction: props.onSelectionInteraction,
onUserScroll: props.onUserScroll,
onHistoryScroll: props.onHistoryScroll,
canRenderImmediately: (row, disclosure) => {
if (row._tag === "TurnGap" || row._tag === "TurnDivider") return true
if (row._tag === "Notice") {
const message = messageByID().get(row.messageID)
return (
(message?.type === "system" || message?.type === "synthetic") &&
(message.description ?? message.text).length <= 1024
)
}
if (row._tag === "UserMessage") {
const message = messageByID().get(row.userMessageID)
if (message?.type !== "user" || message.text.length > 1024 || message.files?.length || message.agents?.length)
return false
const presentation = readPromptPresentation(message.metadata)
return (
(presentation?.displayText ?? message.text).length <= 1024 &&
!presentation?.comments?.length &&
!parseCommentNote(message.text)
)
}
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
const message = messageByID().get(row.group.ref.messageID)
if (message?.type !== "assistant" || message.time.completed === undefined) return false
const content = Timeline.resolveContent(message, row.group.ref.partID)
if (content?.type === "reasoning")
return !(disclosure[row.group.ref.partID] ?? props.data.reasoningMode() === "full")
return (
content?.type === "text" &&
content.text.length <= 1024 &&
!!getReadyMarkdown({ raw: content.text, src: content.text }, `${row.group.ref.partID}:0:full`)
)
},
setRevealMessage: props.setRevealMessage,
setScrollToEnd: props.setScrollToEnd,
})
+5 -2
View File
@@ -13,6 +13,7 @@ export {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const prepared = new Set<string>()
const [resource] = createResource(
() => input.session.identity.sessionID(),
@@ -29,12 +30,14 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
if (input.session.identity.sessionKey() === key) prepared.add(key)
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || !resource.loading
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
const messages = data.session.message.list(id)
return messages.length > 0 && !leadingTurnNeedsParent(messages)
})
const more = () => {
const id = input.session.identity.sessionID()
+24 -15
View File
@@ -96,29 +96,38 @@ export function createTimelineProjection(input: {
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
)
const indexes = createMemo(() => {
const rowByKey = new Map<string, TimelineRow.TimelineRow>()
const messageRowIndex = new Map<string, number>()
const messageLastRowIndex = new Map<string, number>()
const lastAssistantGroupKey = new Map<string, string>()
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
rowByKey.set(TimelineRow.key(row), row)
if (!("userMessageID" in row)) return
if (!messageRowIndex.has(row.userMessageID)) messageRowIndex.set(row.userMessageID, index)
messageLastRowIndex.set(row.userMessageID, index)
if (row._tag === "AssistantPart") lastAssistantGroupKey.set(row.userMessageID, row.group.key)
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
})
return { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey }
return result
})
const messageLastRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
if ("userMessageID" in row) result.set(row.userMessageID, index)
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
rows().forEach((row) => {
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
})
return result
})
return {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey: () => indexes().lastAssistantGroupKey,
lastAssistantGroupKey,
messageByID: sessionMessageByID,
messageRowIndex: () => indexes().messageRowIndex,
messageLastRowIndex: () => indexes().messageLastRowIndex,
rowByKey: () => indexes().rowByKey,
messageRowIndex,
messageLastRowIndex,
rowByKey,
rows,
sessionMessageByID,
userContextByID,
+65 -139
View File
@@ -55,10 +55,6 @@ type Input = {
onSelectionInteraction: (event: MouseEvent) => void
onUserScroll: (target?: EventTarget | null) => void
onHistoryScroll: () => void
canRenderImmediately?: (
row: TimelineRow.TimelineRow,
disclosure: Readonly<Record<string, boolean | undefined>>,
) => boolean
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
}
@@ -79,63 +75,17 @@ 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 [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const [overscan, setOverscan] = createSignal(2)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
})
const knownKeys = new Set(rowKeys())
const knownKeys = new Set(rows().map(TimelineRow.key))
const addedKeys = new Set<string>()
const getItemKey = createMemo(() => {
const keys = rowKeys()
keys
.filter((key) => !knownKeys.has(key))
.forEach((key) => {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => keys[index] ?? `removed:${index}`
})
const rangeExtractor = createMemo(() => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const initialTail = rendering.initialTail && input.pinned()
return (range: Range) => {
// Batch a bounded cheap suffix, but stop before unknown/large content.
// A large tail still mounts alone before estimates expose earlier history.
const start = Math.max(0, range.startIndex - 2)
const boundary = initialTail
? rows()
.slice(start, range.count)
.findLastIndex(
(row) =>
!(
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 16 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
: -1
const first = Math.min(range.count - 1, start + boundary + 1)
const indexes = initialTail
? Array.from({ length: range.count - first }, (_, index) => first + index)
: defaultRangeExtractor({ ...range, overscan: 2 })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
})
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let batchingColdSizes = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
@@ -143,16 +93,10 @@ export function createTimelineVirtualizer(input: Input) {
},
getScrollElement: () => listRoot() ?? null,
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
observeElementOffset: (instance, callback) =>
observeElementOffsetReconnectAware(instance, callback, () => {
if (input.pinned()) virtualizer.scrollToEnd()
settleColdBottom()
})
},
}),
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
@@ -166,17 +110,28 @@ export function createTimelineVirtualizer(input: Input) {
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined || coldPending) return size ?? fallbackItemSize
if (size !== undefined) return size
}
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (batchingColdSizes && input.pinned()) return
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
get getItemKey() {
return getItemKey()
const items = rows()
items
.map(TimelineRow.key)
.filter((key) => !knownKeys.has(key))
.forEach((key) => {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => {
const row = items[index]
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
get anchorTo() {
return input.pinned() ? "end" : "start"
@@ -190,7 +145,16 @@ export function createTimelineVirtualizer(input: Input) {
},
paddingEnd: 64,
get rangeExtractor() {
return rangeExtractor()
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 resizeItem = virtualizer.resizeItem
@@ -202,7 +166,7 @@ export function createTimelineVirtualizer(input: Input) {
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
if (virtualizer.itemSizeCache.get(key) === size) {
pendingSizes.delete(index)
return
}
@@ -214,19 +178,12 @@ export function createTimelineVirtualizer(input: Input) {
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
batchingColdSizes = coldPending && input.pinned()
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
})
})
batchingColdSizes = false
if (coldPending) pinColdBottom()
settleColdBottom()
if (coldPending) return
if (!input.pinned()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
@@ -260,73 +217,41 @@ export function createTimelineVirtualizer(input: Input) {
})
})
let coldPending = coldBottomMount
let settleQueued = false
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
// Report after core size adjustments finish so they cannot apply a delta
// twice. This avoids waiting a frame for the native scroll event.
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
}
const pendingMeasurements = () => {
const items = virtualizer.getVirtualItems()
return (
(rows().length > 0 && items.length === 0) ||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
)
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 (!coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
const root = listRoot()
if (!coldPending || !virtualContent?.isConnected || !root) return
if (virtualContent.querySelector(pendingMarkdown)) return
if (!root.clientHeight) return
// Markdown can finish before ResizeObserver delivers its new box. The
// normal measureElement path skips reads while scrolling; this gate needs
// current boxes before expanding the estimated range or revealing it.
virtualizer.elementsCache.forEach((element) => {
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
})
if (pendingSizes.size || pendingMeasurements()) return
pinColdBottom()
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
// The scroll event must update the range before newly exposed rows can reveal.
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
return
if (rendering.initialTail) {
setRendering("initialTail", false)
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleFrame = requestAnimationFrame(settleColdBottom)
return
}
settleFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleColdBottom()
return
}
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
virtualContent.style.removeProperty("visibility")
settleFrame = undefined
virtualContent?.style.removeProperty("visibility")
expandOverscan()
})
}
onMount(() => {
if (!coldPending || !virtualContent) return
contentObserver = new MutationObserver(settleColdBottom)
contentObserver.observe(virtualContent, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-markdown-ready"],
})
viewportObserver = new ResizeObserver(settleColdBottom)
const root = listRoot()
if (root) viewportObserver.observe(root)
settleColdBottom()
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
if (!coldBottomMount) expandOverscan()
})
let measuredSessionKey = input.sessionKey()
@@ -340,14 +265,12 @@ export function createTimelineVirtualizer(input: Input) {
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
root.style.overflowAnchor = "none"
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
}
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
@@ -355,11 +278,13 @@ 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 }) => {
@@ -376,6 +301,7 @@ export function createTimelineVirtualizer(input: Input) {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
setOverscan(20)
}
const releasePointer = () => {
pointerHeld = false
@@ -396,6 +322,7 @@ 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
@@ -411,7 +338,6 @@ export function createTimelineVirtualizer(input: Input) {
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
input.onScheduleScrollState(root)
input.onHistoryScroll()
}
@@ -550,9 +476,9 @@ 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!)
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
+2 -1
View File
@@ -11,8 +11,9 @@ import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/new-session/route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
+2 -3
View File
@@ -28,6 +28,7 @@ export function TabNavItem(props: {
onClose: () => void
onNavigate: () => void
active?: boolean
forceTruncate?: boolean
suppressNavigation?: boolean
dragging?: boolean
pressed?: boolean
@@ -97,13 +98,11 @@ export function TabNavItem(props: {
createEffect(() => {
title()
props.active
props.orientation
props.forceTruncate
editing()
scheduleTitleOverflow()
})
// The overflow fade changes title padding; observe the stable tab box, not that feedback.
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
onCleanup(() => {
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
+44 -1
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createResource, For, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@@ -25,6 +26,7 @@ function SessionTabSlot(props: {
id: string
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
fallbackTitle?: string
@@ -67,6 +69,7 @@ function SessionTabSlot(props: {
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
@@ -79,6 +82,7 @@ function SessionTabEntry(props: {
id: string
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
@@ -165,6 +169,7 @@ function SessionTabEntry(props: {
id={props.id}
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
orientation={props.orientation}
session={session()}
fallbackTitle={
@@ -233,15 +238,19 @@ export function TitlebarTabStrip(props: {
orientation?: "horizontal" | "vertical"
tabs: Tab[]
currentTab: Tab | undefined
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
onReorder: (keys: string[]) => void
onOverflowChange: (overflowing: boolean) => void
}) {
const global = useGlobal()
const language = useLanguage()
const command = useCommand()
const vertical = () => props.orientation === "vertical"
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
@@ -272,6 +281,38 @@ export function TitlebarTabStrip(props: {
if (next) props.onNavigate(next)
}
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(
vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
)
}
createResizeObserver(
() => [scrollRef, listRef],
() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
})
},
)
onMount(() => {
refreshOverflow()
})
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
})
createEffect(() => {
props.tabs.length
visibleTabIds()
refreshOverflow()
})
return (
<div
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
@@ -286,6 +327,7 @@ export function TitlebarTabStrip(props: {
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
}}
ref={scrollRef}
>
<DragDropProvider
sensors={[
@@ -356,6 +398,7 @@ export function TitlebarTabStrip(props: {
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
forceTruncate={props.forceTruncate}
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
onVisibleChange={(visible) => setVisibility(id, visible)}
+7 -1
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
@@ -336,6 +336,8 @@ export function Titlebar(props: {
].filter((v) => v !== undefined)
})
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
@@ -380,6 +382,8 @@ export function Titlebar(props: {
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
@@ -420,6 +424,8 @@ export function Titlebar(props: {
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={false}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
@@ -1,196 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo, SessionMessageAssistantTool, ShellInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSessionBackground } from "@/session/requests/background"
const tool = (
id: string,
name: string,
metadata: Record<string, string>,
input: Record<string, string> = {},
status: "completed" | "running" = "completed",
): SessionMessageAssistantTool => ({
id,
name,
type: "tool",
state:
status === "running"
? { status, input, metadata }
: { status, input, metadata, content: [{ type: "text", text: "backgrounded" }] },
time: { created: 0 },
})
const assistant = (id: string, content: SessionMessageAssistantTool[], completed?: number) => ({
id,
type: "assistant" as const,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 0, completed },
})
const session = (id: string): SessionInfo => ({
id,
title: id,
parentID: "root",
projectID: "project",
location: { directory: "/project" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
})
const shell = (id: string, command: string): ShellInfo => ({
id,
command,
status: "running",
cwd: "/project",
shell: "sh",
file: "output",
metadata: { sessionID: "root" },
time: { started: 0 },
})
const notification = (id: string, metadata: Record<string, string>) => ({
id,
type: "synthetic" as const,
text: "complete",
metadata,
time: { created: 0 },
})
describe("createSessionBackground", () => {
test("excludes completed children and shells using either shell or tool-call IDs", () => {
createRoot((dispose) => {
const background = createSessionBackground({
sessionID: () => "root",
messages: () => [
notification("before", { source: "subagent", childID: "before-child" }),
assistant("assistant", [
tool("before-part", "subagent", { status: "running", sessionID: "before-child" }),
tool("shell-part", "shell", { status: "running", shellID: "process" }),
tool("shell-call", "shell", { status: "running", shellID: "shell-id" }),
tool("legacy-call", "shell", { status: "running", shellID: "legacy-shell" }),
tool("child-part", "subagent", { status: "running", sessionID: "child" }, { agent: "explore" }),
]),
notification("shell-done", { source: "shell", jobID: "shell-part" }),
notification("shell-id-done", { source: "shell", shellID: "shell-id" }),
notification("legacy-done", { source: "shell", jobID: "legacy-shell" }),
],
sessions: () => [],
status: () => "idle",
shells: () => [],
})
expect(background.tasks()).toEqual([{ id: "child", type: "subagent", label: "child", agent: "explore" }])
dispose()
})
})
test("joins live tasks while idle without rescanning history, then switches sessions", () => {
createRoot((dispose) => {
const [store, setStore] = createStore({
id: "root" as string | undefined,
messages: [
assistant("assistant", [
tool("shell-part", "shell", { status: "running", shellID: "shell" }, { command: "old command" }),
tool("child-part", "subagent", { status: "running", sessionID: "child" }),
]),
],
sessions: [session("live-child"), session("child")],
status: { root: "idle", child: "idle", "live-child": "idle" } as Record<string, "idle" | "running">,
shells: [{ ...shell("shell", "command"), status: "exited" as ShellInfo["status"] }],
})
let scans = 0
const background = createSessionBackground({
sessionID: () => store.id,
messages: (id) => {
scans += 1
return id === "root" ? store.messages : []
},
sessions: () => store.sessions,
status: (id) => store.status[id],
shells: () => store.shells,
})
const blocking = background.blocking()
const initial = background.tasks()
expect(initial.map((task) => task.id)).toEqual(["child", "shell"])
setStore("status", { child: "running", "live-child": "running" })
expect(background.tasks().map((task) => task.id)).toEqual(["child", "live-child", "shell"])
setStore("shells", 0, "status", "running")
expect(background.tasks().at(-1)?.label).toBe("command")
setStore("sessions", 1, "title", "renamed")
expect(background.tasks()[0]?.label).toBe("renamed")
setStore("shells", 0, "command", "updated command")
const live = background.tasks()
expect(live).toEqual([
{ id: "child", type: "subagent", label: "renamed" },
{ id: "live-child", type: "subagent", label: "live-child" },
{ id: "shell", type: "shell", label: "updated command" },
])
expect(background.blocking()).toBe(blocking)
expect(scans).toBe(1)
setStore("id", "other")
expect(background.tasks()).toEqual([])
setStore("id", "root")
expect(background.tasks()).toEqual(live)
setStore("status", { child: "idle", "live-child": "idle" })
setStore("shells", 0, "status", "exited")
expect(background.tasks()).toEqual(initial)
expect(scans).toBe(3)
setStore("id", undefined)
expect(background.tasks()).toEqual([])
dispose()
})
})
test("tracks blocking, backgrounding, and completion through nested store updates", () => {
createRoot((dispose) => {
const [store, setStore] = createStore({
messages: [
assistant("earlier", [tool("old-part", "subagent", { sessionID: "old-child" }, {}, "running")]),
assistant("current", [
tool("child-part", "subagent", { sessionID: "child" }, {}, "running"),
tool("shell-part", "shell", {}, { command: "build" }, "running"),
]),
assistant("completed", [], 0),
],
notification: notification("notice", { source: "subagent", childID: "other-child" }),
status: { child: "running", "old-child": "running" } as Record<string, "idle" | "running">,
})
const messages = store.messages
const background = createSessionBackground({
sessionID: () => "root",
messages: () => [...store.messages, store.notification],
sessions: () => [session("child"), session("old-child")],
status: (id) => store.status[id],
shells: () => [shell("shell", "build")],
})
expect(background.blocking()).toEqual([
{ type: "subagent", partID: "child-part", id: "child", label: undefined },
{ type: "shell", partID: "shell-part", id: undefined, label: "build" },
])
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
setStore("messages", 1, "content", 0, "state", {
status: "completed",
input: { description: "background child" },
metadata: { status: "running", sessionID: "child" },
content: [{ type: "text", text: "backgrounded" }],
})
expect(store.messages).toBe(messages)
expect(background.blocking().map((task) => task.partID)).toEqual(["shell-part"])
setStore("status", "child", "idle")
expect(background.tasks().map((task) => task.id)).toEqual(["child", "old-child"])
expect(background.tasks()[0]?.label).toBe("background child")
setStore("notification", "metadata", "childID", "child")
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
setStore("messages", [0, 1], "time", "completed", 1)
expect(background.blocking()).toEqual([])
expect(background.tasks().map((task) => task.id)).toEqual(["old-child", "shell"])
dispose()
})
})
})
@@ -15,10 +15,8 @@ function createFixture(initial: Record<string, Session> = {}) {
const [cache, setCache] = createSignal(initial)
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
const resolves: string[] = []
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
return {
resolves,
messages,
sessions: {
get: (id: string) => cache()[id],
sync: (id: string) => {
@@ -27,12 +25,6 @@ function createFixture(initial: Record<string, Session> = {}) {
deferred.set(id, entry)
return entry.promise
},
message: {
sync: (id: string) => {
messages.syncs.push(id)
return messages.promise
},
},
},
settle(id: string) {
setCache({ ...cache(), [id]: sessionOf(id) })
@@ -59,34 +51,7 @@ const flush = async () => {
await Promise.resolve()
}
test("starts metadata and messages in parallel once the route has a session ID", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const [id, setId] = createSignal<string>()
const current = createSessionResolution(id, () => fixture.sessions)
expect(current()).toBeUndefined()
await flush()
expect(fixture.resolves).toEqual([])
expect(fixture.messages.syncs).toEqual([])
setId("ses_a")
expect(fixture.resolves).toEqual(["ses_a"])
expect(fixture.messages.syncs).toEqual(["ses_a"])
fixture.messages.resolve(undefined)
await flush()
expect(current()).toBeUndefined()
fixture.settle("ses_a")
await flush()
expect(current()?.id).toBe("ses_a")
dispose()
})
})
test("message failure does not fail metadata resolution", async () => {
test("resolves an uncached session", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const current = createSessionResolution(
@@ -94,10 +59,9 @@ test("message failure does not fail metadata resolution", async () => {
() => fixture.sessions,
)
await flush()
fixture.messages.reject(new Error("message sync failed"))
await flush()
expect(current()).toBeUndefined()
await flush()
expect(fixture.resolves).toEqual(["ses_a"])
fixture.settle("ses_a")
await flush()
@@ -118,15 +82,12 @@ test("re-resolves when navigating to an uncached session without a remount", asy
await flush()
expect(current()?.id).toBe("ses_a")
expect(fixture.resolves).toEqual([])
expect(fixture.messages.syncs).toEqual(["ses_a"])
expect(() => {
setId("ses_b")
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_b"])
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b"])
fixture.settle("ses_b")
await flush()
@@ -178,7 +139,6 @@ test("returning to a pruned session re-resolves instead of throwing not found",
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
fixture.settle("ses_a")
await flush()
@@ -207,7 +167,6 @@ test("revisiting a session whose resolution failed while unfocused retries clean
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
fixture.settle("ses_a")
await flush()
@@ -238,8 +197,6 @@ test("re-resolves against a replaced session store", async () => {
}).not.toThrow()
await flush()
expect(second.resolves).toEqual(["ses_a"])
expect(first.messages.syncs).toEqual(["ses_a"])
expect(second.messages.syncs).toEqual(["ses_a"])
second.settle("ses_a")
await flush()
@@ -198,41 +198,3 @@ test("stale pinned indexes do not produce missing virtual items after count shri
dispose()
})
})
test("snapshots materialize only measured rows and restore their current geometry", () => {
const options = {
count: 100,
getItemKey: (index: number) => `row-${index}`,
estimateSize: () => 60,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
}
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options)
expect(virtualizer.getTotalSize()).toBe(6000)
virtualizer.resizeItem(4, 100)
virtualizer.resizeItem(99, 140)
const measurements = virtualizer.getMeasurements()
const reads: number[] = []
virtualizer.getMeasurements = () =>
new Proxy(measurements, {
get(target, key, receiver) {
if (typeof key === "string" && /^\d+$/.test(key)) reads.push(Number(key))
return Reflect.get(target, key, receiver)
},
})
const snapshot = virtualizer.takeSnapshot()
expect(reads).toEqual([4, 99])
expect(snapshot).toEqual([
{ index: 4, key: "row-4", start: 240, size: 100, end: 340, lane: 0 },
{ index: 99, key: "row-99", start: 5980, size: 140, end: 6120, lane: 0 },
])
const restored = new Virtualizer<HTMLDivElement, HTMLDivElement>({ ...options, initialMeasurementsCache: snapshot })
expect(restored.getTotalSize()).toBe(6120)
expect(restored.takeSnapshot()).toEqual(snapshot)
restored.setOptions({ ...options, count: 10 })
restored.resizeItem(4, 80)
expect(restored.takeSnapshot()).toEqual([{ index: 4, key: "row-4", start: 240, size: 80, end: 320, lane: 0 }])
})
+5 -6
View File
@@ -98,13 +98,12 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
],
}),
]),
),
Effect.provide(
Observability.layer({
+5 -6
View File
@@ -30,13 +30,12 @@ export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
],
}),
]),
),
Effect.provide(NodeServices.layer),
)
+1
View File
@@ -0,0 +1 @@
Core must run on Node.js. Never use Bun globals or Bun-only imports in shared runtime code. Bun-specific adapters must have Node equivalents. This overrides the root Bun preference; tests and build scripts are exempt.
+2 -2
View File
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, server)
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
draft.set(name, server)
}
})
})
+13 -4
View File
@@ -1,11 +1,20 @@
import { buildLocationServiceMap } from "../location-services.js"
import { LocationServiceMap } from "../location-service-map.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
return LayerNode.compile(root, {
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
})
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
// Only build the location service map if it's actually needed
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
return LayerNode.compile(root, replacements)
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
}
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
}
export * as AppNodeBuilder from "./app-node-builder.js"
+3 -9
View File
@@ -78,15 +78,9 @@ export const make = Effect.gen(function* () {
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
]
for (const [mime, encode] of encoders) {
const candidate = encode()
// Base64 uses four bytes per three input bytes, including padding.
if (Math.ceil(candidate.length / 3) * 4 <= limits.maxBase64Bytes)
return {
...content,
content: Buffer.from(candidate).toString("base64"),
encoding: "base64" as const,
mime,
}
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return { ...content, content: candidate, encoding: "base64" as const, mime }
}
} finally {
resized.free()
+16 -13
View File
@@ -45,8 +45,6 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionInstructions } from "./session/instructions.js"
import { SessionGenerateNode } from "./session/generate-node.js"
import { SessionPromptNode } from "./session/prompt-node.js"
import { SessionRevertNode } from "./session/revert-node.js"
import { McpTool } from "./tool/mcp.js"
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
@@ -94,8 +92,6 @@ const nodes = [
Form.node,
Generate.node,
SessionGenerateNode.node,
SessionPromptNode.node,
SessionRevertNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
@@ -108,9 +104,9 @@ const nodes = [
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const graph = LayerNode.group(nodes)
export const graph = LayerNode.group<typeof nodes>(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = LayerNode.Error<typeof graph>
@@ -139,23 +135,29 @@ export interface Options {
// source still honors explicit plugin operations from wellknown and
// host-injected config.
const vanillaReplacements: LayerNode.Replacements = [
Config.node.replace(Config.configured({ project: false, global: false })),
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })),
[Config.node, Config.configured({ project: false, global: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
]
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}) {
const startedAt = performance.now()
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then instance bindings (which win over everything).
const replacements: LayerNode.Replacements = [
// defaults), then bound pairs (which win over everything).
const allReplacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
]
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
@@ -163,5 +165,6 @@ export function layer(ref: Location.Ref, options: Options = {}) {
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
}
+4 -5
View File
@@ -636,8 +636,8 @@ export const layer = (options?: Options) =>
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string, digest = bodyDigest(text)) {
yield* kv.set(key, { updatedAt: Date.now(), digest, body: text }).pipe(
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
@@ -681,13 +681,12 @@ export const layer = (options?: Options) =>
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
const digest = bodyDigest(text)
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === digest) return
if (!force && stored?.digest === bodyDigest(text)) return
yield* decodeCatalog(text)
yield* writeCache(text, digest)
yield* writeCache(text)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
+3 -9
View File
@@ -112,7 +112,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
const makeLayer = (options: Options = {}) =>
export const configured = (options: Options = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
@@ -361,14 +361,8 @@ const makeLayer = (options: Options = {}) =>
}),
)
export const layer = makeLayer()
export const configured = (options?: Options) =>
makeGlobalNode({
service: Service,
layer: options === undefined ? layer : makeLayer(options),
deps: [Bus.node, Global.node],
})
export const node = configured()
export const layer = configured()
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
-1
View File
@@ -62,7 +62,6 @@ export const root = Effect.fn("Project.root")(function* (
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
}
+615 -79
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Fiber, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -9,6 +9,7 @@ import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
@@ -17,30 +18,17 @@ import { SessionMessageTable, SessionTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import { upsertProject } from "./project/sql.js"
import path from "path"
import { fromRow } from "./session/info.js"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { SessionExecution } from "./session/execution.js"
import { SessionModelTransport } from "./session/model-transport.js"
import {
AttachmentError,
BusyError,
CompactionConflictError,
ForkEmptyError,
InboxConflictError,
MessageDecodeError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
SyntheticConflictError,
} from "./session/error.js"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
@@ -48,15 +36,22 @@ import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionRevert } from "./session/revert.js"
import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event"
import { Skill } from "./skill.js"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Shell } from "./shell.js"
import { Global } from "@opencode-ai/util/global"
import { ShellResult } from "./shell/result.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
@@ -107,29 +102,73 @@ type CreateBaseInput = {
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: SessionSchema.ID }
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
delivery?: SessionInbox.Delivery
}
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
boundary: Session.ForkRequestBoundary
}
export {
AttachmentError,
BusyError,
CompactionConflictError,
InboxConflictError,
MessageDecodeError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
SyntheticConflictError,
}
export { MessageDecodeError, NotFoundError }
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class SyntheticConflictError extends Schema.TaggedError<SyntheticConflictError>()(
"Session.SyntheticConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class AttachmentError extends Schema.TaggedError<AttachmentError>()("Session.AttachmentError", {
uri: Schema.String,
message: Schema.String,
}) {}
export class CompactionConflictError extends Schema.TaggedError<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
"Session.MessageNotAssistantError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
"Session.MessageIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
"Session.MessageToolIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
export class DestinationNotFoundError extends Schema.TaggedError<DestinationNotFoundError>()(
"Session.DestinationNotFoundError",
@@ -145,6 +184,8 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
"Session.DestinationUnavailableError",
{ directory: AbsolutePath },
) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -174,9 +215,19 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Info | undefined>
readonly updateMessage: (
input: Parameters<Session.Handle["updateMessage"]>[0] & { readonly sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["updateMessage"]>
readonly updateMessage: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
readonly content: readonly SessionMessage.AssistantContent[]
}) => Effect.Effect<
SessionMessage.Assistant,
| NotFoundError
| MessageNotFoundError
| BusyError
| MessageNotAssistantError
| MessageIncompleteError
| MessageToolIncompleteError
>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
@@ -213,9 +264,17 @@ export interface Interface {
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
@@ -230,9 +289,11 @@ export interface Interface {
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
readonly shell: (input: {
id?: Event.ID
sessionID: SessionSchema.ID
command: string
}) => Effect.Effect<void, NotFoundError>
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -247,15 +308,21 @@ export interface Interface {
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly synthetic: (
input: Parameters<Session.Handle["synthetic"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["synthetic"]>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.Synthetic, NotFoundError | SyntheticConflictError>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<SessionSchema.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
}) => Effect.Effect<Session.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
@@ -279,8 +346,6 @@ const layer = Layer.effect(
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const sessions = yield* Session.make((ref) => locations.get(ref))
const admission = yield* SessionInbox.Service
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
@@ -292,6 +357,30 @@ const layer = Layer.effect(
)
})
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) {
yield* result.get(input.sessionID)
return yield* new InboxConflictError(input)
})
const mutatePending = (
input: InboxItemRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
@@ -304,6 +393,7 @@ const layer = Layer.effect(
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,
@@ -391,13 +481,28 @@ const layer = Layer.effect(
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: (sessionID) => sessions.forSession(sessionID).get(),
get: Effect.fn("Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
environment: Effect.fn("Session.environment")(function* (input) {
yield* result.get(input.sessionID)
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
return yield* environments.get(input.sessionID)
}),
view: (input) => sessions.forSession(input.sessionID).view(input),
view: Effect.fn("Session.view")(function* (input) {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || input.idle > row.idle || (row.viewed !== null && row.viewed >= input.idle))
return yield* Effect.void
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID, idle: input.idle })
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
@@ -487,16 +592,44 @@ const layer = Layer.effect(
SessionHistory.decodeMessageRow,
)
}),
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
message: Effect.fn("Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
updateMessage: Effect.fn("Session.updateMessage")(function* (input) {
const ref = { sessionID: input.sessionID, messageID: input.messageID }
yield* result.get(ref.sessionID)
if ((yield* execution.active).has(ref.sessionID)) return yield* new BusyError({ sessionID: ref.sessionID })
const message = yield* result.message(ref)
if (!message) return yield* new MessageNotFoundError(ref)
if (message.type !== "assistant") return yield* new MessageNotAssistantError(ref)
if (!message.time.completed) return yield* new MessageIncompleteError(ref)
if (
input.content.some(
(content) =>
content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running"),
)
)
return yield* new MessageToolIncompleteError(ref)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
...ref,
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
})
const updated = yield* result.message(ref)
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
return updated
}),
context: Effect.fn("Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
queueInbox: (input) => sessions.forSession(input.sessionID).queueInbox(input.inboxID),
inbox: Effect.fn("Session.inbox")(function* (sessionID) {
yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID)
}),
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
log: (input) =>
Stream.unwrap(
result
@@ -508,7 +641,47 @@ const layer = Layer.effect(
Bus.isSynced(item) || isDurableSessionEvent(item),
),
),
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const session = yield* result.get(input.sessionID)
const messageID = input.id ?? SessionMessage.ID.create()
const admitted = yield* Effect.gen(function* () {
const existing = yield* SessionInbox.reconcile(db, {
id: messageID,
sessionID: session.id,
delivery: input.delivery ?? "steer",
})
if (existing) return existing
const item = yield* restore(
preparePrompt(input, messageID).pipe(
Effect.provide(locations.get(session.location)),
Effect.provideService(FSUtil.Service, fs),
),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
return yield* SessionInbox.admit(db, bus, {
id: messageID,
sessionID: session.id,
item,
})
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
// First admission wins: same-session reuse is idempotent and ignores the
// retried payload, metadata, and delivery mode.
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -536,7 +709,65 @@ const layer = Layer.effect(
},
})
}),
shell: (input) => sessions.forSession(input.sessionID).shell(input),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(locations.get(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID, background: true },
})
.pipe(
Effect.tapError((error) =>
result.synthetic({
sessionID: input.sessionID,
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: terminal.info,
output: preview,
})
yield* result
.synthetic({
...ShellResult.userNotification(terminal),
sessionID: input.sessionID,
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
}),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -557,9 +788,35 @@ const layer = Layer.effect(
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.Renamed, {
sessionID: input.sessionID,
title: input.title,
})
}),
move: Effect.fn("Session.move")(function* (input) {
const current = yield* result.get(input.sessionID)
const value = input.directory.trim()
@@ -585,6 +842,7 @@ const layer = Layer.effect(
)
}),
)
yield* persistProject(project)
const item = SessionInbox.Item.make({
type: "move",
payload,
@@ -604,19 +862,36 @@ const layer = Layer.effect(
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
}
yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
.pipe(Effect.orDie)
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
}),
)
yield* execution.wake(input.sessionID)
}),
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionInbox.admitCompaction(db, bus, {
id: inputID,
sessionID: input.sessionID,
delivery: input.delivery ?? "steer",
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
yield* execution.wake(input.sessionID)
return admitted
}),
wait: Effect.fn("Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.awaitIdle(sessionID)
}),
active: execution.active,
background: Effect.fn("Session.background")(function* (sessionID) {
yield* result.get(sessionID)
@@ -636,13 +911,78 @@ const layer = Layer.effect(
})
.pipe(Effect.catchTag("Session.SyntheticConflictError", Effect.die))
}),
resume: (sessionID) => sessions.forSession(sessionID).resume(),
synthetic: (input) => sessions.forSession(input.sessionID).synthetic(input),
interrupt: (sessionID, options) => sessions.forSession(sessionID).interrupt(options),
resume: Effect.fn("Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
synthetic: Effect.fn("Session.synthetic")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
type: "synthetic",
payload: {
text: input.text,
description: input.description,
metadata: input.metadata,
},
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
id: inputID,
sessionID: input.sessionID,
item: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
// First admission wins: same-session reuse is idempotent and ignores the
// retried payload, metadata, and delivery mode.
if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
yield* execution.wake(input.sessionID)
return admitted
}),
),
),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(execution.interrupt(sessionID, options)),
),
revert: {
stage: (input) => sessions.forSession(input.sessionID).revert.stage(input),
clear: (sessionID) => sessions.forSession(sessionID).revert.clear(),
commit: (sessionID) => sessions.forSession(sessionID).revert.commit(),
stage: Effect.fn("Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
if ((yield* execution.active).has(input.sessionID))
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
}),
commit: Effect.fn("Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
}),
},
})
@@ -650,6 +990,203 @@ const layer = Layer.effect(
}),
)
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
request: Parameters<Interface["prompt"]>[0],
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const materializeAttachment = Effect.fn("Session.materializeAttachment")(function* (
fs: FSUtil.Interface,
input: PromptInput.FileAttachment,
) {
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
name: undefined,
mime: undefined,
}
: yield* readFileAttachment(fs, input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
mention: input.mention,
})
})
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: string,
mime: string,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const service = yield* Image.Service
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* service.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
)
return { data: Base64.make(normalized.content), mime: normalized.mime }
})
const readFileAttachment = Effect.fn("Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
})
if (url.protocol !== "file:")
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
const target = yield* Effect.try({
try: () => {
url.search = ""
url.hash = ""
return fileURLToPath(url)
},
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
})
const info = yield* fs
.stat(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") {
const entries = yield* fs
.readDirectoryEntries(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
const bytes = yield* fs
.readFile(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
})
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
if (comma === -1) throw new Error("Invalid data URL")
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
return Buffer.from(decodeURIComponent(payload))
const bytes = Buffer.from(payload, "base64")
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
function positiveInt(value: string | null) {
if (value === null) return
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
export const node = makeGlobalNode({
service: Service,
layer,
@@ -661,7 +1198,6 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
SessionInbox.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
-69
View File
@@ -2,7 +2,6 @@ export * as SessionErrors from "./error.js"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Skill } from "@opencode-ai/schema/skill"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -11,35 +10,6 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Session.
sessionID: SessionSchema.ID,
}) {}
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
"Session.MessageNotAssistantError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
"Session.MessageIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
"Session.MessageToolIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class ForkEmptyError extends Schema.TaggedError<ForkEmptyError>()("Session.ForkEmptyError", {
sessionID: SessionSchema.ID,
}) {
@@ -82,42 +52,3 @@ export class UserInterruptedError extends Schema.TaggedError<UserInterruptedErro
return "Session interrupted by user"
}
}
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class SyntheticConflictError extends Schema.TaggedError<SyntheticConflictError>()(
"Session.SyntheticConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class AttachmentError extends Schema.TaggedError<AttachmentError>()("Session.AttachmentError", {
uri: Schema.String,
message: Schema.String,
}) {}
export class CompactionConflictError extends Schema.TaggedError<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
-4
View File
@@ -18,8 +18,6 @@ import { SessionInbox } from "./inbox.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Checks process-local ownership, including interruption cleanup and terminal settlement. */
readonly isActive: (sessionID: SessionSchema.ID) => Effect.Effect<boolean>
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
@@ -144,7 +142,6 @@ export const layer = Layer.effect(
return Service.of({
active: coordinator.active,
isActive: coordinator.isActive,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, "user")
@@ -181,7 +178,6 @@ export const noopLayer = Layer.succeed(
Service,
Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
@@ -157,7 +157,7 @@ export const layer = (options?: Options) =>
yield* notify(background)
return
}
if (yield* execution.isActive(recovery.childSessionID)) return
if ((yield* execution.active).has(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
+109 -141
View File
@@ -1,8 +1,7 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
CompactionPayload,
@@ -16,7 +15,7 @@ import {
User,
UserPayload,
} from "@opencode-ai/schema/session-inbox"
import { Database } from "../database/database.js"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
@@ -66,13 +65,6 @@ export class LifecycleConflict extends Schema.TaggedError<LifecycleConflict>()("
id: SessionMessage.ID,
}) {}
function matches<Type extends Item["type"]>(
stored: Info,
request: PendingRef & { readonly type: Type },
): stored is Extract<Info, { readonly type: Type }> {
return stored.sessionID === request.sessionID && stored.type === request.type
}
const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
@@ -124,7 +116,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
@@ -139,140 +131,89 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
type: "synthetic",
payload: decodeSynthetic(message),
})
return yield* new LifecycleConflict({ id })
return yield* Effect.die(new LifecycleConflict({ id }))
})
export type Interface = Effect.Success<ReturnType<typeof make>>
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInbox") {}
export const make = Effect.fn("SessionInbox.make")(function* () {
const database = yield* Database.Service
const db = database.db
const bus = yield* Bus.Service
/** First admission wins for matching Session and type, without preparing a new payload. */
const reconcile = Effect.fn("SessionInbox.reconcile")(function* <Type extends Item["type"]>(request: {
/** Reconciles pending or delivered work without preparing a new admission payload. */
export const reconcile = Effect.fn("SessionInbox.reconcile")(function* (
db: DatabaseService,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly type: Type
readonly delivery: Delivery
}) {
const existing =
(yield* find(db, request.id)) ?? (yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery))
if (existing === undefined) return undefined
if (existing.type === "compaction" || !matches(existing, request))
return yield* new LifecycleConflict({ id: request.id })
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
})
const admit = Effect.fn("SessionInbox.admit")(function* <Type extends Item["type"]>(request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item & { readonly type: Type }
}) {
const existing = yield* reconcile({ ...request, type: request.item.type, delivery: request.item.delivery })
if (existing !== undefined) return existing
const admitted = yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.map((event) =>
Info.make({
id: request.id,
sessionID: request.sessionID,
timeCreated: DateTime.makeUnsafe(event.created),
...request.item,
}),
),
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, request.id).pipe(
Effect.flatMap((stored) => (stored === undefined ? Effect.fail(defect) : Effect.succeed(stored))),
)
: Effect.die(defect),
),
)
if (!matches(admitted, { ...request, type: request.item.type }))
return yield* new LifecycleConflict({ id: request.id })
return admitted
})
const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly delivery: Delivery
}) {
return yield* serialized(
input.sessionID,
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* new LifecycleConflict({ id: input.id })
}
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
return yield* new LifecycleConflict({ id: input.id })
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
if (pending) return pending
return yield* admit({
id: input.id,
sessionID: input.sessionID,
item: { type: "compaction", payload: {}, delivery: Delivery.make(input.delivery) },
})
}),
)
})
const cancel = Effect.fn("SessionInbox.cancel")((input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
const steer = Effect.fn("SessionInbox.steer")((input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
const queue = Effect.fn("SessionInbox.queue")((input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
return {
list: (sessionID: SessionSchema.ID) => list(db, sessionID),
reconcile,
admit,
admitCompaction,
cancel,
steer,
queue,
}
return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery)
})
export const layer = Layer.effect(Service, make())
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
},
) {
const existing = yield* reconcile(db, { ...request, delivery: request.item.delivery })
if (existing !== undefined) return existing
return yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.map((event) =>
Info.make({
id: request.id,
sessionID: request.sessionID,
timeCreated: DateTime.makeUnsafe(event.created),
...request.item,
}),
),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
return yield* serialized(
input.sessionID,
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
if (pending) return pending
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}),
)
})
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
db: DatabaseService,
@@ -451,11 +392,39 @@ export const has = Effect.fn("SessionInbox.has")(function* (
})
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(
Effect.asVoid,
// Bus projectors abort their transaction through the defect channel.
Effect.catchDefect((defect) => (defect instanceof LifecycleConflict ? Effect.fail(defect) : Effect.die(defect))),
)
serialized(input.sessionID, effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
@@ -478,7 +447,6 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
Effect.orDie,
)
: Effect.die(defect),
),
-16
View File
@@ -1,16 +0,0 @@
export * as SessionPromptNode from "./prompt-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "../image.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Skill } from "../skill.js"
import { SessionPrompt } from "./prompt.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionPrompt.Service,
layer: SessionPrompt.layer,
deps: [FSUtil.node, PluginSupervisor.node, PluginHooks.node, Image.node, Skill.node],
})
-231
View File
@@ -1,231 +0,0 @@
export * as SessionPrompt from "./prompt.js"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Image } from "../image.js"
import { Mime } from "../mime.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Skill } from "../skill.js"
import { AttachmentError, SkillNotFoundError } from "./error.js"
export type Input = {
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
}
export const make = Effect.fn("SessionPrompt.make")(function* () {
const fs = yield* FSUtil.Service
const plugins = yield* PluginSupervisor.Service
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const skillService = yield* Skill.Service
const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
sessionID: Session.ID
messageID: SessionMessage.ID
input: Input
}) {
yield* plugins.flush
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID: request.messageID,
prompt: structuredClone({
text: request.input.text,
files: request.input.files?.slice(),
agents: request.input.agents?.slice(),
skills: request.input.skills?.slice(),
}),
metadata: structuredClone(request.input.metadata),
delivery: request.input.delivery ?? "steer",
})
const input = event.prompt
const files = input.files
? yield* Effect.forEach(input.files, materializeAttachment, { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return {
type: "user",
payload: SessionInbox.UserPayload.make({
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
}),
delivery: SessionInbox.Delivery.make(event.delivery),
} satisfies SessionInbox.Item
})
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
input: PromptInput.FileAttachment,
) {
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
name: undefined,
mime: undefined,
}
: yield* readFileAttachment(input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
mention: input.mention,
})
})
const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: string,
mime: string,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* image.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
)
return { data: Base64.make(normalized.content), mime: normalized.mime }
})
const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(function* (uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
})
if (url.protocol !== "file:")
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
const target = yield* Effect.try({
try: () => {
url.search = ""
url.hash = ""
return fileURLToPath(url)
},
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
})
const info = yield* fs
.stat(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") {
const entries = yield* fs
.readDirectoryEntries(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
const bytes = yield* fs
.readFile(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
})
return { prepare }
})
export type Interface = Effect.Success<ReturnType<typeof make>>
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}
export const layer = Layer.effect(Service, make())
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
if (comma === -1) throw new Error("Invalid data URL")
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
return Buffer.from(decodeURIComponent(payload))
const bytes = Buffer.from(payload, "base64")
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
function positiveInt(value: string | null) {
if (value === null) return
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
-15
View File
@@ -1,15 +0,0 @@
export * as SessionRevertNode from "./revert-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Snapshot } from "../snapshot.js"
import { SessionRevert } from "./revert.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionRevert.Service,
layer: SessionRevert.layer,
deps: [Database.node, Bus.node, PluginSupervisor.node, Snapshot.node],
})
+63 -76
View File
@@ -1,97 +1,28 @@
export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { RelativePath } from "../schema.js"
import { Snapshot } from "../snapshot.js"
import { SessionEvent } from "./event.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export { MessageNotFoundError }
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
interface BoundaryInput {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
}
export interface Interface {
readonly stage: (input: {
readonly session: SessionSchema.Info
readonly messageID: SessionMessage.ID
readonly files?: boolean
}) => Effect.Effect<SessionSchema.Revert, MessageNotFoundError | Snapshot.Error>
readonly clear: (session: SessionSchema.Info) => Effect.Effect<void, Snapshot.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export const make = Effect.fn("SessionRevert.make")(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const plugins = yield* PluginSupervisor.Service
const snapshot = yield* Snapshot.Service
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
yield* plugins.flush
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
: []
const revert = {
messageID: input.messageID,
snapshot: original,
files,
} satisfies SessionSchema.Info["revert"]
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
revert,
})
return revert
})
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
yield* plugins.flush
if (!session.revert) return
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
})
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
})
})
return { stage, clear }
})
export const layer = Layer.effect(Service, make())
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
if (!session.revert) return
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})
})
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
const db = (yield* Database.Service).db
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -122,3 +53,59 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
}
return files
})
export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
readonly session: SessionSchema.Info
readonly messageID: SessionMessage.ID
readonly files?: boolean
}) {
const snapshot = yield* Snapshot.Service
const bus = yield* Bus.Service
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
: []
const revert = {
messageID: input.messageID,
snapshot: original,
files,
} satisfies SessionSchema.Info["revert"]
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
revert,
})
return revert
})
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const snapshot = yield* Snapshot.Service
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
})
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
})
})
export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})
})
+1 -5
View File
@@ -7,8 +7,6 @@ import type { Promotable } from "./inbox.js"
export interface Coordinator<Key, E, Reason = never> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Checks ownership for one key, including cleanup and terminal settlement. */
readonly isActive: (key: Key) => Effect.Effect<boolean>
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
@@ -114,8 +112,6 @@ export const make = <Key, E, Reason = never>(options: {
Deferred.doneUnsafe(execution.done, exit)
}
const isActive = (key: Key) => Effect.sync(() => executions.has(key))
const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => {
const execution = executions.get(key)
@@ -170,5 +166,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
})
-416
View File
@@ -1,416 +0,0 @@
export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import { Event } from "@opencode-ai/schema/event"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
import {
BusyError,
CompactionConflictError,
InboxConflictError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SyntheticConflictError,
} from "./error.js"
import { SessionEvent } from "./event.js"
import { SessionExecution } from "./execution.js"
import { SessionInbox } from "./inbox.js"
import { SessionMessage } from "./message.js"
import { SessionPrompt } from "./prompt.js"
import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
export type Services = PluginSupervisor.Service | SessionPrompt.Service | SessionRevert.Service | Shell.Service
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
resume?: boolean
}
/**
* Build once in the host Scope: `const sessions = yield* Session.make(servicesFor)`.
* Use `sessions.forSession(id)` for handles that share host services and reload current state.
*/
export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer<Services>) {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const admission = yield* SessionInbox.Service
const scope = yield* Scope.Scope
const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
})
const message = Effect.fn("Session.message")(function* (sessionID: SessionSchema.ID, messageID: SessionMessage.ID) {
const stored = yield* store.message(messageID)
return stored?.sessionID === sessionID ? stored.message : undefined
})
const updateMessage = Effect.fn("Session.updateMessage")(function* (
sessionID: SessionSchema.ID,
input: { readonly messageID: SessionMessage.ID; readonly content: readonly SessionMessage.AssistantContent[] },
) {
const ref = { sessionID, messageID: input.messageID }
yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
const current = yield* message(sessionID, input.messageID)
if (!current) return yield* new MessageNotFoundError(ref)
if (current.type !== "assistant") return yield* new MessageNotAssistantError(ref)
if (!current.time.completed) return yield* new MessageIncompleteError(ref)
if (input.content.some(isUnfinishedTool)) return yield* new MessageToolIncompleteError(ref)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
...ref,
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
})
const updated = yield* message(sessionID, input.messageID)
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
return updated
})
const view = Effect.fn("Session.view")(function* (sessionID: SessionSchema.ID, input: { idle: number }) {
const session = yield* get(sessionID)
if (
session.time.idle === undefined ||
input.idle > DateTime.toEpochMillis(session.time.idle) ||
(session.time.viewed !== undefined && DateTime.toEpochMillis(session.time.viewed) >= input.idle)
)
return
yield* bus.publish(SessionEvent.Viewed, { sessionID, idle: input.idle })
})
const rename = Effect.fn("Session.rename")(function* (sessionID: SessionSchema.ID, input: { title: string }) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
) {
const session = yield* get(sessionID)
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: input.agent, previous: session.agent })
})
const switchModel = Effect.fn("Session.switchModel")(function* (
sessionID: SessionSchema.ID,
input: { model: Model.Ref },
) {
const session = yield* get(sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* bus.publish(SessionEvent.ModelSelected, { sessionID, model: input.model, previous: session.model })
})
const mutatePending = (
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
mutation: (input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
}) => Effect.Effect<void, SessionInbox.LifecycleConflict>,
) =>
mutation({ sessionID, id: inboxID }).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () =>
Effect.gen(function* () {
yield* get(sessionID)
return yield* new InboxConflictError({ sessionID, inboxID })
}),
),
)
const inbox = Effect.fn("Session.inbox")(function* (sessionID: SessionSchema.ID) {
yield* get(sessionID)
return yield* admission.list(sessionID)
})
const cancelInbox = Effect.fn("Session.cancelInbox")(
(sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.cancel),
Effect.uninterruptible,
)
const steerInbox = Effect.fn("Session.steerInbox")(function* (
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
) {
yield* mutatePending(sessionID, inboxID, admission.steer)
yield* execution.wake(sessionID)
}, Effect.uninterruptible)
const queueInbox = Effect.fn("Session.queueInbox")(
(sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.queue),
Effect.uninterruptible,
)
const prompt = Effect.fn("Session.prompt")((sessionID: SessionSchema.ID, input: PromptRequest) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const session = yield* get(sessionID)
const messageID = input.id ?? SessionMessage.ID.create()
const admitted = yield* Effect.gen(function* () {
const existing = yield* admission.reconcile({
id: messageID,
sessionID: session.id,
type: "user",
delivery: input.delivery ?? "steer",
})
if (existing) return existing
const item = yield* restore(
SessionPrompt.Service.use((preparation) => preparation.prepare({ sessionID, messageID, input })).pipe(
Effect.provide(servicesFor(session.location)),
),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(bus, session)
return yield* admission.admit({
id: messageID,
sessionID: session.id,
item,
})
}).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
)
if (input.resume !== false) yield* execution.wake(sessionID)
return admitted
}),
),
)
const shell = Effect.fn("Session.shell")(function* (
sessionID: SessionSchema.ID,
input: { id?: Event.ID; command: string },
) {
const session = yield* get(sessionID)
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(servicesFor(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID, background: true },
})
.pipe(
Effect.tapError((error) =>
synthetic(sessionID, {
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID,
shell: terminal.info,
output: preview,
})
yield* synthetic(sessionID, {
...ShellResult.userNotification(terminal),
resume: false,
}).pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
})
const compact = Effect.fn("Session.compact")(function* (
sessionID: SessionSchema.ID,
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
) {
yield* get(sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* admission
.admitCompaction({
id: inputID,
sessionID,
delivery: input.delivery ?? "steer",
})
.pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new CompactionConflictError({ sessionID, inputID })),
)
yield* execution.wake(sessionID)
return admitted
})
const wait = Effect.fn("Session.wait")(function* (sessionID: SessionSchema.ID) {
yield* get(sessionID)
yield* execution.awaitIdle(sessionID)
})
const resume = Effect.fn("Session.resume")(function* (sessionID: SessionSchema.ID) {
yield* get(sessionID)
yield* execution.resume(sessionID)
})
const synthetic = Effect.fn("Session.synthetic")(
(
sessionID: SessionSchema.ID,
input: {
id?: SessionMessage.ID
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
resume?: boolean
},
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* get(sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = {
type: "synthetic",
payload: SessionInbox.SyntheticPayload.make({
text: input.text,
description: input.description,
metadata: input.metadata,
}),
delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"),
} satisfies SessionInbox.Item
const admitted = yield* admission
.admit({
id: inputID,
sessionID,
item: admittedInput,
})
.pipe(
Effect.catchTag(
"SessionInbox.LifecycleConflict",
() => new SyntheticConflictError({ sessionID, inputID }),
),
)
if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID)
return admitted
}),
),
)
const interrupt = Effect.fn("Session.interrupt")(
(sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) =>
Effect.uninterruptible(execution.interrupt(sessionID, options)),
)
const stage = Effect.fn("Session.revert.stage")(function* (
sessionID: SessionSchema.ID,
input: { messageID: SessionMessage.ID; files?: boolean },
) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.Service.use((revert) =>
revert.stage({ session, messageID: input.messageID, files: input.files }),
).pipe(Effect.provide(servicesFor(session.location)))
})
const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(
Effect.provide(servicesFor(session.location)),
)
return yield* execution.wake(sessionID)
})
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.commit(bus, session)
})
const revert = { stage, clear, commit }
const operations = {
get,
message,
updateMessage,
view,
rename,
switchAgent,
switchModel,
inbox,
prompt,
synthetic,
shell,
compact,
wait,
resume,
interrupt,
cancelInbox,
steerInbox,
queueInbox,
revert,
}
const forSession = (sessionID: SessionSchema.ID) => {
const get = operations.get.bind(undefined, sessionID)
const message = operations.message.bind(undefined, sessionID)
const updateMessage = operations.updateMessage.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
const prompt = operations.prompt.bind(undefined, sessionID)
const synthetic = operations.synthetic.bind(undefined, sessionID)
const shell = operations.shell.bind(undefined, sessionID)
const compact = operations.compact.bind(undefined, sessionID)
const wait = operations.wait.bind(undefined, sessionID)
const resume = operations.resume.bind(undefined, sessionID)
const interrupt = operations.interrupt.bind(undefined, sessionID)
const cancelInbox = operations.cancelInbox.bind(undefined, sessionID)
const steerInbox = operations.steerInbox.bind(undefined, sessionID)
const queueInbox = operations.queueInbox.bind(undefined, sessionID)
const stage = operations.revert.stage.bind(undefined, sessionID)
const clear = operations.revert.clear.bind(undefined, sessionID)
const commit = operations.revert.commit.bind(undefined, sessionID)
const revert = { stage, clear, commit }
return {
id: sessionID,
get,
message,
updateMessage,
view,
rename,
switchAgent,
switchModel,
inbox,
prompt,
synthetic,
shell,
compact,
wait,
resume,
interrupt,
cancelInbox,
steerInbox,
queueInbox,
revert,
}
}
return { forSession }
})
export type Handle = ReturnType<Effect.Success<ReturnType<typeof make>>["forSession"]>
function isUnfinishedTool(content: SessionMessage.AssistantContent) {
return content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running")
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
+3 -5
View File
@@ -369,11 +369,9 @@ const layer = () =>
if (!oldest) break
yield* removeCommand(oldest)
}
// Keep exited history data-only. Interrupt last because finish may run on the timeout fiber.
const timeoutFiber = command.timeoutFiber
command.timeout = undefined
command.timeoutFiber = undefined
if (timeoutFiber) yield* Fiber.interrupt(timeoutFiber)
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
})
command.timeout = (duration) =>
+50 -53
View File
@@ -2,8 +2,6 @@ export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import type { Tool } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
@@ -109,56 +107,6 @@ export const Plugin = {
const permission = yield* Permission.Service
const config = yield* Config.Service
const prepare = Effect.fn("ShellTool.prepare")(function* (invocation: ShellCreateBefore, context: Tool.Context) {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
invocation.cwd = target.absolute
const timeout = invocation.timeout
const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
kind: "directory",
}),
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
// Approval can outlive the directory, so validate immediately before spawning.
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir !== "directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
return timeout
})
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(
function* (
sessionID: SessionSchema.ID,
@@ -203,6 +151,11 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
@@ -215,7 +168,51 @@ export const Plugin = {
},
(invocation) =>
Effect.gen(function* () {
finalTimeout = yield* prepare(invocation, context)
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
invocation.cwd = target.absolute
finalTimeout = invocation.timeout
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({
path: LocationMutation.resolvePath(target.absolute, directory),
kind: "directory",
}),
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter(
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir !== "directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
yield* context.progress({ shellID: info.id })
+2 -2
View File
@@ -22,8 +22,8 @@ const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
Global.node.replace(globalLayer),
Location.node.replace(locationLayer),
[Global.node, globalLayer],
[Location.node, locationLayer],
]) as unknown as Layer.Layer<unknown, never>,
)
@@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
[Bus.node, Bus.configured({ persist: true })],
]),
)
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
+10 -10
View File
@@ -100,14 +100,12 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number }
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [
Location.node.replace(locationLayer),
Bus.node.replace(Bus.configured({ persist: true })),
[Location.node, locationLayer],
[Bus.node, Bus.configured({ persist: true })],
]),
)
const itWithoutLocation = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
]),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
)
const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
@@ -633,7 +631,8 @@ describe("Bus", () => {
const continueRead = yield* Deferred.make<void>()
let pause = true
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(
[
Bus.node,
Bus.configured({
persist: true,
beforeAggregateRead: () =>
@@ -641,7 +640,7 @@ describe("Bus", () => {
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}),
),
],
])
yield* Effect.gen(function* () {
@@ -1319,7 +1318,7 @@ describe("Bus", () => {
it.effect("log replays across configured read pages", () =>
Effect.gen(function* () {
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })),
[Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
])
yield* Effect.gen(function* () {
@@ -1352,7 +1351,8 @@ describe("Bus", () => {
const releaseRead = yield* Deferred.make<void>()
const firstRead = yield* Ref.make(true)
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(
[
Bus.node,
Bus.configured({
persist: true,
beforeAggregateRead: () =>
@@ -1363,7 +1363,7 @@ describe("Bus", () => {
}),
),
}),
),
],
])
yield* Effect.gen(function* () {
+4 -4
View File
@@ -25,7 +25,7 @@ const locationLayer = Layer.succeed(
)
const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]),
[Location.node.replace(locationLayer)],
[[Location.node, locationLayer]],
)
const it = testEffect(catalogLayer)
@@ -48,7 +48,7 @@ describe("Catalog", () => {
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(locationLayer)]),
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
)
return Effect.gen(function* () {
@@ -78,7 +78,7 @@ describe("Catalog", () => {
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
Location.node.replace(locationLayer),
[Location.node, locationLayer],
]),
)
@@ -108,7 +108,7 @@ describe("Catalog", () => {
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
Location.node.replace(locationLayer),
[Location.node, locationLayer],
]),
)
+1 -1
View File
@@ -35,7 +35,7 @@ describe("CodeMode", () => {
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(Tool.node, [
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
]),
),
),
@@ -85,7 +85,7 @@ describe("CodeModeInstructions", () => {
execute: () => Effect.succeed({ output: "zeta" }),
}
const layer = AppNodeBuilder.build(Tool.node, [
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
])
return Effect.gen(function* () {
+11 -10
View File
@@ -43,10 +43,10 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
Mcp.node.replace(emptyMcpLayer),
Config.node.replace(emptyConfigLayer),
Location.node.replace(testLocationLayer),
ShellSelect.node.replace(shellLayer),
[Mcp.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
],
),
)
@@ -340,16 +340,17 @@ describeNative("ConfigCommandPlugin native watcher", () => {
ShellSelect.node,
]),
[
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
ShellSelect.node.replace(shellLayer),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[ShellSelect.node, shellLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
],
),
),
+4 -3
View File
@@ -40,12 +40,13 @@ const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
llmClient.replace(
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
),
Config.node.replace(config),
],
[Config.node, config],
]),
),
)
+12 -11
View File
@@ -55,12 +55,12 @@ function testLayer(
),
)
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Config.node.replace(Config.configured(options)),
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
Credential.node.replace(credentialNode),
WellKnown.node.replace(wellknownNode),
Watcher.node.replace(watcher),
[Config.node, Config.configured(options)],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
[Credential.node, credentialNode],
[WellKnown.node, wellknownNode],
[Watcher.node, watcher],
])
// Merge the watcher layer by reference so Watcher.Test resolves to the same
// memoized instance the built graph uses.
@@ -311,15 +311,16 @@ describe("Config", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
]),
),
)
+7 -4
View File
@@ -28,13 +28,13 @@ import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
ConfigPluginSource.node.replace(ConfigPluginSource.empty),
Global.node.replace(tempGlobalLayer),
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Global.node, tempGlobalLayer],
]),
)
const refreshNpm = makeGlobalNode({
@@ -65,7 +65,10 @@ const refreshNpm = makeGlobalNode({
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
),
)
+7 -6
View File
@@ -86,13 +86,14 @@ const discover = (directory: string, global: string) =>
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Location.node.replace(
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
Watcher.node.replace(Watcher.testLayer),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
[Watcher.node, Watcher.testLayer],
]),
),
)
+2 -2
View File
@@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(Snapshot.node, [
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })),
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
]),
),
)
@@ -44,9 +44,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
}
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
}).pipe(
Effect.provide(
AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]),
),
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
@@ -13,218 +13,131 @@ class OtherError {
readonly _tag = "OtherError"
}
// Keep intentionally invalid expressions out of runtime execution.
const contracts = (tag: LayerNode.Tag<"app"> | LayerNode.Tag<"other">, flag: boolean) => {
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const aLayer = Layer.succeed(A, A.of({}))
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
const cLayer = Layer.effect(
C,
Effect.gen(function* () {
yield* A
yield* B
return C.of({})
}),
)
const a = make({ service: A, layer: aLayer, deps: [] })
const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const ab = make({ name: "a-and-b", layer: Layer.mergeAll(aLayer, Layer.succeed(B, {})), deps: [] })
const failing = make({ service: A, layer: Layer.effect(A, Effect.fail(new LayerError())), deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
const inputA = LayerNode.unbound(A, tags.values.app)
const group = LayerNode.group([a, b])
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
const aLayer = Layer.succeed(A, A.of({}))
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
const cLayer = Layer.effect(
C,
Effect.gen(function* () {
yield* A
yield* B
return C.of({})
}),
)
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
const a = make({ service: A, layer: aLayer, deps: [] })
const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const failing = make({ service: A, layer: failingA, deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
const inputA = LayerNode.unbound(A, tags.values.app)
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
make({ name: "manual-a", layer: aLayer, deps: [] })
// @ts-expect-error A node must have a service or name
make({ layer: aLayer, deps: [] })
// @ts-expect-error Service and name are mutually exclusive
make({ service: A, name: "a", layer: aLayer, deps: [] })
// @ts-expect-error An explicit tagged contract requires a corresponding runtime tag
LayerNode.make<typeof aLayer, readonly [], typeof tags.values.app>({ service: A, layer: aLayer, deps: [] })
// @ts-expect-error B requires A
make({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error C requires A and B
make({ service: C, layer: cLayer, deps: [a] })
const erasedLayer: Layer.Any = bLayer
// @ts-expect-error Erasing a Layer's contract cannot hide its inputs and errors
make({ service: B, layer: erasedLayer, deps: [] })
make({ name: "manual-a", layer: aLayer, deps: [] })
LayerNode.compile(c) satisfies Layer.Layer<C, never, never>
LayerNode.compile(dependent) satisfies Layer.Layer<B, LayerError, never>
LayerNode.compile(group) satisfies Layer.Layer<A | B, never, never>
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<never>
// @ts-expect-error An empty graph cannot supply arbitrary services
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<A>
LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer<A, never, never>
// @ts-expect-error A is a private dependency, not a root output
LayerNode.compile(c) satisfies Layer.Layer<A | C>
// @ts-expect-error Dependency failures are not erased
LayerNode.compile(dependent) satisfies Layer.Layer<B>
// @ts-expect-error A node must have a service or name
make({ layer: aLayer, deps: [] })
const replacements: LayerNode.Replacements = [a.replace(aLayer), a.replace(ab), failing.replace(a)]
const replacement: LayerNode.Replacement = a.replace(Layer.mergeAll(aLayer, Layer.succeed(B, {})))
LayerNode.compile(a, { replacements: [...replacements, replacement] })
inputA.replace(a)
a.replace(a)
// @ts-expect-error Closed layer replacements must provide every source output
ab.replace(aLayer)
// @ts-expect-error Node replacements must provide every source output
ab.replace(a)
// @ts-expect-error Replacement must provide A
a.replace(Layer.succeed(B, {}))
// @ts-expect-error Node replacement must provide A
a.replace(b)
// @ts-expect-error Raw layers with inputs are not closed
a.replace(Layer.effect(A, Effect.as(B, A.of({}))))
// @ts-expect-error Replacement cannot introduce a new error
a.replace(Layer.effect(A, Effect.fail(new OtherError())))
// @ts-expect-error Service and name are mutually exclusive
make({ service: A, name: "a", layer: aLayer, deps: [] })
// @ts-expect-error B requires A
make({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error C requires A and B
make({ service: C, layer: cLayer, deps: [a] })
const closed = build(LayerNode.group([c]))
const closedWithError = build(LayerNode.group([dependent]))
const checkClosed: Layer.Layer<C, never, never> = closed
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
void checkClosed
void checkError
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
// @ts-expect-error Replacement must provide A
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
// @ts-expect-error Node replacement must provide A
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
void invalidNodeReplacement
// @ts-expect-error Replacement cannot introduce a new error
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
const invalidNodeErrorReplacement = () =>
// @ts-expect-error Node replacement cannot introduce a new error
a.replace(failing)
// @ts-expect-error Existing errors do not authorize unrelated replacement errors
failing.replace(Layer.effect(A, Effect.fail(new OtherError())))
// @ts-expect-error Every alternative of a node replacement must supply A
a.replace(flag ? a : b)
// @ts-expect-error Every alternative of a raw-layer replacement must supply A
a.replace(flag ? aLayer : Layer.succeed(B, {}))
// @ts-expect-error A valid alternative cannot hide a new error in another alternative
a.replace(flag ? a : failing)
a.replace(flag ? a : ab)
failing.replace(flag ? a : failing)
// @ts-expect-error Storing replacements must not erase their validation
const invalidStored: LayerNode.Replacements = [a.replace(b)]
// @ts-expect-error Raw tuples cannot be stored as opaque replacements
const rawStored: LayerNode.Replacements = [[a, aLayer]]
// @ts-expect-error Raw tuples cannot be supplied to compile
LayerNode.compile(a, { replacements: [[a, aLayer]] })
// @ts-expect-error Replacements are not structurally forgeable
const forged: LayerNode.Replacement = { source: a, target: a }
// @ts-expect-error Groups are not replaceable nodes
group.replace(a)
// @ts-expect-error Groups cannot be replacement targets
a.replace(group)
// @ts-expect-error Groups cannot be widened to nodes
const groupNode: LayerNode.Node<A | B, never, typeof tags.values.app> = group
// @ts-expect-error Graphs are opaque
const forgedGraph: LayerNode.Graph<A> = { name: "a" }
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
void invalidNodeErrorReplacement
const aContract: LayerNode.Node<A, never, typeof tags.values.app> = a
aContract.replace(aLayer)
// @ts-expect-error A method cannot be rebound to a declaration with a stronger contract
a.replace.call(ab, aLayer)
const detached = a.replace
// @ts-expect-error Replacement authority requires its checked receiver
detached(aLayer)
// @ts-expect-error Output narrowing cannot forget B before replacement
const narrowedOutput: LayerNode.Node<A, never, typeof tags.values.app> = ab
// @ts-expect-error Output widening cannot add B before replacement
const widenedOutput: LayerNode.Node<A | B, never, typeof tags.values.app> = a
// @ts-expect-error Error widening cannot authorize a new replacement error
const widenedError: LayerNode.Node<A, LayerError, typeof tags.values.app> = a
// @ts-expect-error Error narrowing cannot forget an existing failure
const narrowedError: LayerNode.Node<A, never, typeof tags.values.app> = failing
// @ts-expect-error Tag widening cannot authorize replacement across tags
const widenedTag: LayerNode.Node<A, never, LayerNode.Tag | undefined> = a
const unionTag = LayerNode.unbound(A, tag)
// @ts-expect-error Tag narrowing cannot forget a possible tag
const narrowedTag: LayerNode.Node<A, never, typeof tags.values.app> = unionTag
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
const outputProjection: LayerNode.Graph<A, never, typeof tags.values.app> = group
// @ts-expect-error Graph output projection cannot invent a service
const widenedGraph: LayerNode.Graph<A | B, never, typeof tags.values.app> = a
// @ts-expect-error A projected Graph has no replacement authority
outputProjection.replace(aLayer)
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
const request = scopedTags.make("request")
const global = scopedTags.make("global")
const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] })
const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({})))
const tagCLayer = Layer.effect(
TagC,
Effect.gen(function* () {
yield* TagA
yield* TagB
return TagC.of({})
}),
)
const choice = flag ? a : b
// @ts-expect-error Choosing one dependency does not provide both services
make({ service: C, layer: cLayer, deps: [choice] })
// @ts-expect-error A conditional root promises only outputs present in every alternative
LayerNode.compile(LayerNode.group([choice])) satisfies Layer.Layer<A | B>
const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] })
LayerNode.compile(conditional) satisfies Layer.Layer<never>
// @ts-expect-error A conditional implementation does not acquire both branches
LayerNode.compile(conditional) satisfies Layer.Layer<A | B>
LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer<A>
const dynamic: Array<typeof a> = []
// @ts-expect-error An unbounded array may contain no roots
LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer<A>
request({ service: TagB, layer: tagBLayer, deps: [globalA] })
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] })
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] })
const decorated = b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.void)))
LayerNode.compile(decorated) satisfies Layer.Layer<B>
b.replace(decorated)
// @ts-expect-error A layer mapper cannot be rebound to a weaker declaration
ab.mapLayer.call(a, (layer) => layer)
// @ts-expect-error mapLayer cannot add an input requirement
b.mapLayer((layer) => layer.pipe(Layer.tap(() => C)))
// @ts-expect-error mapLayer cannot grow the error channel
b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.fail(new OtherError()))))
// @ts-expect-error mapLayer cannot drop an output
ab.mapLayer(() => aLayer)
// @ts-expect-error Unbound declarations have no implementation to map
inputA.mapLayer((layer: Layer.Layer<A>) => layer)
// @ts-expect-error Tag configuration can only reference declared tags
LayerNode.tags({ request: ["missing"], global: [] })
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
const request = scopedTags.make("request")
const global = scopedTags.make("global")
const globalA = global({ service: A, layer: aLayer, deps: [] })
const requestA = request({ service: A, layer: aLayer, deps: [] })
const requestB = request({ service: B, layer: Layer.succeed(B, {}), deps: [] })
request({ service: B, layer: bLayer, deps: [globalA] })
request({ service: C, layer: cLayer, deps: [globalA, requestB] })
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] })
LayerNode.compile(LayerNode.group([globalA, requestB]), { shared: scopedTags.values.global }) satisfies Layer.Layer<
A | B
>
// @ts-expect-error Tag configuration can only reference declared tags
LayerNode.tags({ request: ["missing"], global: [] })
// @ts-expect-error Shared tags must be branded
LayerNode.compile(globalA, { shared: "global" })
// @ts-expect-error Replacement targets must keep the source tag
globalA.replace(requestA)
// @ts-expect-error Replacement targets must keep the source tag in either direction
requestA.replace(globalA)
// @ts-expect-error Every alternative must keep the source tag
globalA.replace(flag ? globalA : requestA)
// @ts-expect-error Providing only A leaves B missing
request({ service: C, layer: cLayer, deps: [globalA] })
// @ts-expect-error Providing only B leaves A missing
request({ service: C, layer: cLayer, deps: [requestB] })
// @ts-expect-error Duplicate A providers still leave B missing
request({ service: C, layer: cLayer, deps: [globalA, requestA] })
// @ts-expect-error A group with only A still leaves B missing
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA])] })
// @ts-expect-error Global cannot depend on request
global({ service: B, layer: bLayer, deps: [requestA] })
// @ts-expect-error Groups preserve their child tags
global({ service: B, layer: bLayer, deps: [LayerNode.group([requestA])] })
// @ts-expect-error An unrelated dependency cannot satisfy TagA
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
const globalScopedA = makeGlobalNode({ service: A, layer: aLayer, deps: [] })
const locationScopedA = makeLocationNode({ service: A, layer: aLayer, deps: [] })
makeGlobalNode({ service: B, layer: bLayer, deps: [globalScopedA] })
makeLocationNode({ service: B, layer: bLayer, deps: [globalScopedA] })
makeLocationNode({ service: B, layer: bLayer, deps: [locationScopedA] })
// @ts-expect-error Global nodes cannot depend on location nodes
makeGlobalNode({ service: B, layer: bLayer, deps: [locationScopedA] })
// @ts-expect-error B requires A
makeLocationNode({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error Providing only TagA leaves TagB missing
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
void [
invalidStored,
rawStored,
forged,
groupNode,
forgedGraph,
narrowedOutput,
widenedOutput,
widenedError,
narrowedError,
widenedTag,
narrowedTag,
widenedGraph,
]
}
// @ts-expect-error Providing only TagB leaves TagA missing
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
test("layer node type contracts compile", () => {
void contracts
})
// @ts-expect-error Duplicate TagA providers still leave TagB missing
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] })
// @ts-expect-error A group with only TagA still leaves TagB missing
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] })
// @ts-expect-error Global cannot depend on request
global({ service: TagB, layer: tagBLayer, deps: [requestA] })
// @ts-expect-error Groups preserve their child tags
global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] })
class ScopedA extends Context.Service<ScopedA, {}>()("test/ScopedA") {}
class ScopedB extends Context.Service<ScopedB, {}>()("test/ScopedB") {}
const scopedA = Layer.succeed(ScopedA, ScopedA.of({}))
const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({})))
const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] })
const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] })
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
// @ts-expect-error Global nodes cannot depend on location nodes
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
// @ts-expect-error ScopedB requires ScopedA
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] })
test("type exploration compiles", () => {})
@@ -1,21 +1,19 @@
import { describe, expect, test } from "bun:test"
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } from "effect"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "../../lib/effect"
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
class Memo extends Context.Service<Memo, Layer.MemoMap>()("test/LayerNodeMemo") {}
class Support extends Context.Service<Support, {}>()("test/LayerNodeSupport") {}
class Locations extends Context.Service<Locations, LayerMap.LayerMap<string, Value | Right, "failed location">>()(
"test/LayerNodeLocations",
) {}
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
const it = testEffect(Layer.empty)
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
const greetingLayer = Layer.effect(
Greeting,
@@ -25,443 +23,240 @@ const value = make({ service: Value, layer: valueLayer, deps: [] })
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
describe("layer node", () => {
it.effect("builds an untagged graph", () =>
Effect.gen(function* () {
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
const result = yield* Greeting.pipe(Effect.provide(LayerNode.compile(LayerNode.group([greeting]))))
expect(result.value).toBe("hello production")
}),
)
it.effect("exposes roots but hides transitive dependencies", () =>
Effect.gen(function* () {
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting])))
expect(Context.get(context, Greeting).value).toBe("hello production")
expect(Option.isNone(Context.getOption(context, Value))).toBe(true)
}),
)
it.effect("replaces exact declarations, not sibling names or native layer identities", () =>
Effect.gen(function* () {
const sibling = make({ service: Value, layer: valueLayer, deps: [] })
const target = make({ name: "different-name", layer: Layer.succeed(Value, { value: "replaced" }), deps: [] })
const left = make({
service: Left,
layer: Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
),
deps: [value],
})
const right = make({
service: Right,
layer: Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
),
deps: [sibling],
})
const context = yield* Layer.build(
LayerNode.compile(LayerNode.group([left, right]), { replacements: [value.replace(target)] }),
)
expect(Context.get(context, Left).value).toBe("replaced")
expect(Context.get(context, Right).value).toBe("production")
}),
)
it.effect("requires reachable unbound nodes to be replaced", () =>
Effect.gen(function* () {
const unbound = LayerNode.unbound(Value, tags.values.app)
const root = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
expect(() => LayerNode.compile(root)).toThrow("Unbound layer node: test/LayerNodeValue")
const result = yield* Greeting.pipe(
Effect.provide(LayerNode.compile(root, { replacements: [unbound.replace(value)] })),
)
expect(result.value).toBe("hello production")
}),
)
it.effect("replaces every use of a declaration with a stored closed-layer replacement", () =>
Effect.gen(function* () {
const replacements: LayerNode.Replacements = [value.replace(Layer.succeed(Value, { value: "replacement" }))]
const right = make({
service: Right,
layer: Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
),
deps: [value],
})
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting, right]), { replacements }))
expect(Context.get(context, Greeting).value).toBe("hello replacement")
expect(Context.get(context, Right).value).toBe("replacement")
}),
)
it.effect("uses the last replacement and ignores unreachable unbound defaults and cycles", () =>
Effect.gen(function* () {
const unbound = LayerNode.unbound(Value, tags.values.app)
const unused = make({ service: Value, layer: valueLayer, deps: [] })
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(greeting, {
replacements: [
value.replace(unbound),
unbound.replace(unused),
unused.replace(unbound),
value.replace(Layer.succeed(Value, { value: "last" })),
],
}),
),
)
expect(result.value).toBe("hello last")
}),
)
it.effect("resolves target chains independently of replacement order and treats self-replacement as identity", () =>
Effect.gen(function* () {
const middle = make({ service: Value, layer: Layer.succeed(Value, { value: "middle" }), deps: [] })
const target = make({ service: Value, layer: Layer.succeed(Value, { value: "target" }), deps: [] })
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(greeting, {
replacements: [target.replace(target), middle.replace(target), value.replace(middle)],
}),
),
)
expect(result.value).toBe("hello target")
}),
)
test("rejects reachable replacement and dependency cycles", () => {
const other = make({ service: Value, layer: valueLayer, deps: [] })
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(other), other.replace(value)] })).toThrow(
"Cycle detected in layer graph",
test("builds an untagged graph", async () => {
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(LayerNode.group([greeting]))),
)
const dependent = make({
service: Value,
layer: Layer.effect(
Value,
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("builds a dependency graph", async () => {
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("exposes roots but hides transitive dependencies", () => {
const layer = build(LayerNode.group([greeting]))
const check: Layer.Layer<Greeting> = layer
void check
})
test("preserves branch-specific implementations across roots", async () => {
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
const leftLayer = Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
)
const rightLayer = Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
)
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
const layer = build(LayerNode.group([left, right]))
const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
})
test("requires unbound nodes to be replaced before compilation", async () => {
const unbound = LayerNode.unbound(Value, tags.values.app)
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
const tree = LayerNode.group([greeting])
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("replaces a node with a closed layer", async () => {
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("hello simulation")
})
test("replaces every use of the same layer", async () => {
const leftLayer = Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
)
const rightLayer = Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
)
const left = make({ service: Left, layer: leftLayer, deps: [value] })
const right = make({ service: Right, layer: rightLayer, deps: [value] })
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
const layer = build(LayerNode.group([left, right]), [[value, replacement]])
const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
})
test("does not acquire an unused replacement", async () => {
let acquisitions = 0
const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
const replacement = Layer.effect(
Left,
Effect.sync(() => {
acquisitions++
return Left.of({ value: "replacement" })
}),
)
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
),
deps: [greeting],
)
expect(acquisitions).toBe(0)
})
test("replaces a node without acquiring its dependencies", async () => {
let acquisitions = 0
const dependencyLayer = Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "dependency" })
}),
)
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
const replacement = make({
service: Greeting,
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
deps: [],
})
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow(
"Cycle detected in layer graph",
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("replacement")
expect(acquisitions).toBe(0)
})
test("applies later replacements inside earlier replacement nodes", async () => {
const original = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
build(LayerNode.group([original]), [
[original, replacement],
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
]),
),
)
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const database = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
deps: [],
})
const users = location({
service: Users,
layer: Layer.effect(
Users,
Effect.gen(function* () {
const db = yield* Database
return Users.of({ list: Effect.succeed([db.name]) })
}),
),
deps: [database],
})
const app = location({
service: App,
layer: Layer.effect(
App,
Effect.gen(function* () {
const service = yield* Users
return App.of({ run: service.list })
}),
),
deps: [users],
})
const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global)
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
kind: "group",
dependencies: [],
})
expect(result.hoisted.dependencies).toEqual([database])
const layer = LayerNode.compile(result.node).pipe(
Layer.provide(LayerNode.compile(result.hoisted)),
) as unknown as Layer.Layer<App>
const program = Effect.gen(function* () {
const app = yield* App
return yield* app.run
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["Alice"])
})
test("rejects conflicting hoisted implementations", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const first = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "first" })),
deps: [],
})
const second = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "second" })),
deps: [],
})
const left = location({
service: Users,
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
deps: [first],
})
const right = location({
service: App,
layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
deps: [second],
})
expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
"Tag global has conflicting implementations for test/GraphDatabase",
)
})
it.effect("does not acquire replaced dependencies or unused replacement targets", () =>
Effect.gen(function* () {
const acquired: string[] = []
const dependency = make({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => {
acquired.push("old dependency")
return Value.of({ value: "dependency" })
}),
),
deps: [],
})
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(original, {
replacements: [
original.replace(Layer.succeed(Greeting, { value: "replacement" })),
value.replace(
Layer.effect(
Value,
Effect.sync(() => {
acquired.push("unused target")
return Value.of({ value: "unused" })
}),
),
),
],
}),
),
)
expect(result.value).toBe("replacement")
expect(acquired).toEqual([])
}),
)
test("treats dependency groups as transparent while hoisting", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const database = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
deps: [],
})
const users = location({
service: Users,
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
deps: [LayerNode.group([database])],
})
const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global)
it.effect("mapLayer preserves dependency wiring and replacement traversal", () =>
Effect.gen(function* () {
const acquired: string[] = []
const decorated = greeting.mapLayer((layer) =>
layer.pipe(
Layer.tap((context) =>
Effect.sync(() => {
acquired.push(Context.get(context, Greeting).value)
}),
),
),
)
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(greeting, {
replacements: [
greeting.replace(decorated),
value.replace(Layer.succeed(Value, { value: "mapped dependency" })),
],
}),
),
)
expect(result.value).toBe("hello mapped dependency")
expect(acquired).toEqual(["hello mapped dependency"])
}),
)
it.effect("memoizes shared wiring instead of expanding a diamond into a tree", () =>
Effect.gen(function* () {
const acquisitions: string[] = []
const shared = value.mapLayer((layer) =>
layer.pipe(Layer.tap(() => Effect.sync(() => acquisitions.push("shared")))),
)
const left = make({ name: "left", layer: Layer.empty, deps: [shared] })
const right = make({ name: "right", layer: Layer.empty, deps: [shared] })
yield* Layer.build(LayerNode.compile(LayerNode.group([left, right])))
expect(acquisitions).toEqual(["shared"])
}),
)
it.effect("preserves declared memo-service outputs rather than filtering them as build metadata", () =>
Effect.gen(function* () {
const supplied = yield* Layer.makeMemoMap
const memo = make({
service: Layer.CurrentMemoMap,
layer: Layer.succeed(Layer.CurrentMemoMap, supplied),
deps: [],
})
const observer = make({ service: Memo, layer: Layer.effect(Memo, Layer.CurrentMemoMap), deps: [memo] })
expect(yield* Memo.pipe(Effect.provide(LayerNode.compile(observer)))).toBe(supplied)
}),
)
it.effect("rejects one implementation wired to different effective dependencies in either memo domain", () =>
Effect.gen(function* () {
const other = make({ service: Value, layer: Layer.succeed(Value, { value: "other" }), deps: [] })
const sibling = make({ service: Greeting, layer: greetingLayer, deps: [other] })
const root = LayerNode.group([greeting, sibling])
expect(() => LayerNode.compile(root)).toThrow("wired to different dependencies")
expect(() => LayerNode.compile(root, { shared: tags.values.app })).toThrow("wired to different dependencies")
const result = yield* Greeting.pipe(
Effect.provide(LayerNode.compile(root, { replacements: [value.replace(other)] })),
)
expect(result.value).toBe("hello other")
}),
)
it.effect("starts dependencies in parallel and nested group roots in order", () =>
Effect.gen(function* () {
const valueStarted = yield* Deferred.make<void>()
const greetingStarted = yield* Deferred.make<void>()
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const events: string[] = []
const value = make({
service: Value,
layer: Layer.effect(
Value,
Effect.gen(function* () {
yield* Deferred.succeed(valueStarted, undefined)
yield* Deferred.await(greetingStarted)
return Value.of({ value: "value" })
}),
),
deps: [],
})
const greeting = make({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.gen(function* () {
yield* Deferred.succeed(greetingStarted, undefined)
yield* Deferred.await(valueStarted)
return Greeting.of({ value: "greeting" })
}),
),
deps: [],
})
const first = make({
service: Left,
layer: Layer.effect(
Left,
Effect.gen(function* () {
yield* Value
yield* Greeting
events.push("first started")
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(releaseFirst)
events.push("first finished")
return Left.of({ value: "first" })
}),
),
deps: [value, greeting],
})
const second = make({
service: Right,
layer: Layer.effect(
Right,
Effect.sync(() => {
expect(events).toEqual(["first started", "first finished"])
events.push("second started")
return Right.of({ value: "second" })
}),
),
deps: [],
})
const fiber = yield* Layer.build(LayerNode.compile(LayerNode.group([LayerNode.group([first]), second]))).pipe(
Effect.forkChild,
)
yield* Deferred.await(firstStarted)
expect(events).toEqual(["first started"])
yield* Deferred.succeed(releaseFirst, undefined)
const context = yield* Fiber.join(fiber)
expect(events).toEqual(["first started", "first finished", "second started"])
expect(Context.get(context, Left).value).toBe("first")
expect(Context.get(context, Right).value).toBe("second")
}),
)
;[false, true].forEach((topLevel) => {
it.effect(
`LayerMap isolates builds and retains resources ${topLevel ? "with" : "without"} a top-level global owner`,
() =>
Effect.gen(function* () {
const acquired = { global: 0, local: 0, support: 0 }
const released: string[] = []
const startup: string[] = []
yield* Effect.gen(function* () {
const memoMap = yield* Layer.makeMemoMap
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const support = LayerNode.make({
service: Support,
layer: Layer.effect(
Support,
Effect.acquireRelease(
Effect.sync(() => {
acquired.support++
return Support.of({})
}),
() =>
Effect.sync(() => {
released.push("support")
}),
),
),
deps: [],
})
const value = global({
service: Value,
layer: Layer.effect(
Value,
Effect.andThen(
Support,
Effect.acquireRelease(
Effect.sync(() => {
startup.push("global")
return Value.of({ value: `global-${++acquired.global}` })
}),
(value) =>
Effect.sync(() => {
released.push(value.value)
}),
),
),
),
deps: [support],
})
const local = location({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.gen(function* () {
yield* Value
return yield* Effect.acquireRelease(
Effect.sync(() => Greeting.of({ value: `local-${++acquired.local}` })),
(value) =>
Effect.sync(() => {
released.push(value.value)
}),
)
}),
),
deps: [LayerNode.group([value])],
})
const root = location({
service: Right,
layer: Layer.effect(
Right,
Effect.gen(function* () {
const local = yield* Greeting
if (local.value === "local-2") return yield* Effect.fail("failed location" as const)
return Right.of(local)
}),
),
deps: [local],
})
// Every key builds the same compiled Layer, not a new graph per lookup.
const compiled = LayerNode.compile(LayerNode.group([value, root]), { shared: tags.values.global })
const locations = location({
service: Locations,
layer: Layer.effect(
Locations,
Effect.gen(function* () {
startup.push("map")
expect(Option.getOrUndefined(yield* Effect.serviceOption(Layer.CurrentMemoMap))).toBe(memoMap)
return yield* LayerMap.make((_: string) => compiled, { idleTimeToLive: Duration.infinity })
}),
),
deps: [],
})
const scope = yield* Effect.scope
const context = yield* Layer.buildWithMemoMap(
LayerNode.compile(LayerNode.group([locations, ...(topLevel ? [value] : [])]), {
shared: tags.values.global,
}),
memoMap,
scope,
)
expect(startup).toEqual(topLevel ? ["map", "global"] : ["map"])
const map = Context.get(context, Locations)
const first = yield* map.contextEffect("first").pipe(Effect.scoped)
expect(Option.getOrUndefined(Context.getOption(context, Value))).toBe(
topLevel ? Context.get(first, Value) : undefined,
)
expect(Option.isNone(Context.getOption(first, Greeting))).toBe(true)
expect(Context.get(first, Right).value).toBe("local-1")
expect(yield* map.contextEffect("failed").pipe(Effect.scoped, Effect.flip)).toBe("failed location")
expect(released).toEqual(["local-2"])
expect(Context.get(yield* map.contextEffect("first").pipe(Effect.scoped), Right)).toBe(
Context.get(first, Right),
)
const second = yield* map.contextEffect("second").pipe(Effect.scoped)
expect(Context.get(second, Value)).toBe(Context.get(first, Value))
expect(Context.get(second, Right)).not.toBe(Context.get(first, Right))
expect(acquired).toEqual({ global: 1, local: 3, support: 1 })
yield* map.invalidate("first")
expect(released).toEqual(["local-2", "local-1"])
expect(Context.get(yield* map.contextEffect("second").pipe(Effect.scoped), Right)).toBe(
Context.get(second, Right),
)
const rebuilt = yield* map.contextEffect("first").pipe(Effect.scoped)
expect(Context.get(rebuilt, Right).value).toBe("local-4")
expect(Context.get(rebuilt, Value)).toBe(Context.get(first, Value))
expect(acquired).toEqual({ global: 1, local: 4, support: 1 })
expect(released).not.toContain("global-1")
}).pipe(Effect.scoped)
expect(released.toSorted()).toEqual(["global-1", "local-1", "local-2", "local-3", "local-4", "support"])
}),
)
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
kind: "group",
dependencies: [],
})
})
})
@@ -1,23 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer, Option } from "effect"
import { Context, Effect, Layer, LayerMap, Option } from "effect"
import { Node } from "@opencode-ai/util/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { buildLocationServiceMap } from "@opencode-ai/core/location-services"
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../../fixture/tmpdir"
import { testEffect } from "../../lib/effect"
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("test/NodeBuildB") {}
const it = testEffect(Layer.empty)
describe("node build", () => {
test("does not build a location service map when the graph does not require it", async () => {
const result = Node.makeGlobalNode({
@@ -34,7 +31,7 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain")
})
test("detects cycles through a replaced location service map", () => {
test("detects cycles through a replaced location service map", async () => {
const a = Node.makeGlobalNode({
service: CycleA,
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
@@ -48,49 +45,31 @@ describe("node build", () => {
),
deps: [a],
})
const mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap()))
const mapLayer = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const service = yield* CycleB
return yield* LayerMap.make(
(ref: Location.Ref) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
}),
),
{ idleTimeToLive: "1 minute" },
)
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
)
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow(
"Cycle detected in layer graph",
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
"Cycle detected in layer tree",
)
})
it.effect("supplies the lazy map when only a replacement introduces the dependency", () =>
Effect.gen(function* () {
const original = Node.makeGlobalNode({
service: Result,
layer: Layer.succeed(Result, { value: "original" }),
deps: [],
})
const replacement = Node.makeGlobalNode({
service: Result,
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "has map" }))),
deps: [LocationServiceMap.node],
})
const result = yield* Result.pipe(Effect.provide(AppNodeBuilder.build(original, [original.replace(replacement)])))
expect(result.value).toBe("has map")
}),
)
it.effect("caller replacements override the lazy default without building any locations", () =>
Effect.gen(function* () {
const acquisitions: string[] = []
const override = buildLocationServiceMap().pipe(
Layer.tap(() =>
Effect.sync(() => {
acquisitions.push("caller map")
}),
),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LocationServiceMap.node, [LocationServiceMap.node.replace(override)]),
)
expect(Context.get(context, LocationServiceMap.Service)).toBeDefined()
expect(acquisitions).toEqual(["caller map"])
}),
)
test("shares top-level project even when the location service map is built first", async () => {
test("shares top-level project with location services", async () => {
await using tmp = await tmpdir()
let acquisitions = 0
const projectLayer = Layer.effect(
@@ -105,8 +84,8 @@ describe("node build", () => {
}),
)
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [
Project.node.replace(projectLayer),
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
[Project.node, projectLayer],
])
const program = Effect.gen(function* () {
yield* Project.Service
+2 -2
View File
@@ -21,8 +21,8 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
)
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
Location.node.replace(activeLocation),
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
[Location.node, activeLocation],
[Environment.node, transformEnvironmentFiles(transformFiles)],
]),
)
}
+20 -14
View File
@@ -77,15 +77,16 @@ describe("FileSystemSearch", () => {
workspaceID: Workspace.ID.make("wrk_test"),
})
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
),
),
),
Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))),
],
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
@@ -102,7 +103,8 @@ describe("FileSystemSearch", () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
@@ -112,8 +114,8 @@ describe("FileSystemSearch", () => {
),
),
),
),
Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
@@ -135,15 +137,17 @@ describe("FileSystemSearch", () => {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
),
Ripgrep.node.replace(
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
@@ -165,7 +169,7 @@ describe("FileSystemSearch", () => {
grep: () => Effect.succeed([]),
}),
),
),
],
])
yield* Effect.gen(function* () {
@@ -204,15 +208,17 @@ describe("FileSystemSearch", () => {
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
),
Ripgrep.node.replace(
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
@@ -228,7 +234,7 @@ describe("FileSystemSearch", () => {
grep: () => Effect.succeed([]),
}),
),
),
],
])
yield* Effect.gen(function* () {
@@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr
import { Config } from "@opencode-ai/core/config"
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -129,7 +129,7 @@ function provide(
vcs?: Location.Interface["vcs"],
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins: typeof pluginNode = pluginNode,
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
) {
const locationLayer = Layer.succeed(
Location.Service,
@@ -138,10 +138,10 @@ function provide(
const built = AppNodeBuilder.build(
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
[
Config.node.replace(config),
Location.node.replace(locationLayer),
PluginSupervisor.node.replace(plugins),
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
[Config.node, config],
[Location.node, locationLayer],
[PluginSupervisor.node, plugins],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
],
)
return Effect.provide(built)
@@ -154,7 +154,7 @@ function withTmp<A, E, R>(
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: typeof pluginNode
plugins?: LocationNode<PluginSupervisor.Service>
},
) {
return Effect.acquireRelease(
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] })
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
+10 -32
View File
@@ -1,40 +1,18 @@
import { Bus } from "@opencode-ai/core/bus"
import { Image } from "@opencode-ai/core/image"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
import { Skill } from "@opencode-ai/core/skill"
import type { Location } from "@opencode-ai/schema/location"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only prompt preparation services.
export const promptLocationNode = makeGlobalNode({
service: LocationServiceMap.Service,
layer: Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
return yield* LayerMap.make(
(_ref: Location.Ref) =>
SessionPrompt.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), {
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
}),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
),
),
) as Layer.Layer<LocationServices>,
)
}),
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
) as Layer.Layer<LocationServices>,
),
deps: [Bus.node, FSUtil.node],
})
)
+1 -1
View File
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
type ConfigInput = typeof Info.Encoded
+3 -3
View File
@@ -34,7 +34,7 @@ const instances = Layer.effect(
(ref: Location.Ref) =>
Instance.layer(ref, {
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
replacements: [Global.node.replace(tempGlobalLayer)],
replacements: [[Global.node, tempGlobalLayer]],
}),
{ idleTimeToLive: Duration.infinity },
),
@@ -42,8 +42,8 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
]),
)
+6 -5
View File
@@ -23,13 +23,14 @@ import { Bus } from "../src/bus"
// Config the host hands the vanilla instance explicitly: a value and an
// explicit plugin removal, both of which must survive discovery: false.
const hostConfig: LayerNode.Replacements = [
Config.node.replace(
[
Config.node,
Config.configured({
project: false,
global: false,
content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }),
}),
),
],
]
// Same directory contents, two instances: one vanilla, one with discovery.
@@ -42,7 +43,7 @@ const instances = Layer.effect(
// "bare" exercises the vanilla defaults themselves: no caller Config.
discovery: name !== "vanilla" && name !== "bare",
// Caller replacements win over the vanilla defaults.
replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])],
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
})
},
{ idleTimeToLive: Duration.infinity },
@@ -51,8 +52,8 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
]),
)
@@ -33,18 +33,19 @@ const instructionLayer = (input: {
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
Global.node.replace(
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
),
Location.node.replace(input.locationServiceLayer),
Watcher.node.replace(watcher),
...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []),
],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
],
),
watcher,
+1 -1
View File
@@ -24,7 +24,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
[Bus.node, Bus.configured({ persist: true })],
]),
)
@@ -30,8 +30,8 @@ const locationLayer = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ config: temporary, tmp: temporary })),
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
]),
)
+1 -1
View File
@@ -27,7 +27,7 @@ const failingCredentialNode = makeGlobalNode({
deps: [],
})
const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]),
)
function eventually<A, E, R>(
+8 -21
View File
@@ -1,24 +1,11 @@
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
import { upsertProject } from "@opencode-ai/core/project/sql"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Effect, Layer } from "effect"
export const globalProjectNode = makeGlobalNode({
service: Project.Service,
layer: Layer.effect(
Project.Service,
Effect.gen(function* () {
const database = yield* Database.Service
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => {
const project = { id: Project.ID.global, directory, canonical: directory }
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
},
})
}),
),
deps: [Database.node],
})
export const globalProjectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
}),
)
@@ -13,16 +13,15 @@ import { it } from "./lib/effect"
const provide = (directory: string, workspaceID?: Workspace.ID) =>
Effect.provide(
LayerNode.compile(FileSystem.node, {
replacements: [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
LayerNode.compile(FileSystem.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
],
}),
]),
)
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+3 -3
View File
@@ -51,12 +51,12 @@ import { Tool } from "../src/tool"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
const activityLocations = Layer.effect(
@@ -77,7 +77,7 @@ const activityLocations = Layer.effect(
)
const itWithActivity = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
LocationServiceMap.node.replace(activityLocations),
[LocationServiceMap.node, activityLocations],
]),
)
+10 -11
View File
@@ -13,21 +13,20 @@ import { it } from "./lib/effect"
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, {
replacements: [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
),
],
}),
]),
)
}
+1 -1
View File
@@ -23,7 +23,7 @@ const projectLayer = Layer.succeed(
}),
}),
)
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)]))
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>

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