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
129 changed files with 2775 additions and 6629 deletions
+1 -3
View File
@@ -5,7 +5,6 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +15,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -29,7 +28,6 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+9 -2
View File
@@ -1,5 +1,4 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -60,4 +59,12 @@ new sst.cloudflare.x.Astro("Web", {
},
})
createWebApp("app." + domain)
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
-18
View File
@@ -1,18 +0,0 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
+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="
}
}
+10 -18
View File
@@ -1343,28 +1343,20 @@ const onMessageDelta = (
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const pendingFinish = (() => {
const stopReason = event.delta?.stop_reason
if (stopReason === null || stopReason === undefined) return state.pendingFinish
const stopSequence = event.delta?.stop_sequence
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
raw: stopReason,
},
providerMetadata: finishMetadata,
}
})()
return [
{
...state,
usage,
pendingFinish,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
},
NO_EVENTS,
]
@@ -949,41 +949,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves terminal state across usage-only message deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "X" },
usage: { output_tokens: 8 },
},
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
-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 -22
View File
@@ -71,25 +71,4 @@ Environment options:
## Deployment
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
@@ -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 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
@@ -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 -2
View File
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
// The native Windows titlebar already includes the gap above the content panels.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "1px"
? "0px"
: "8px",
}}
>
+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 }])
})
+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)
}
})
})
+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()
-4
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,
+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, {})
}),
-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>
}
File diff suppressed because it is too large Load Diff
-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
+2 -119
View File
@@ -1,10 +1,7 @@
export * as SessionStore from "./store.js"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode-ai/schema/project"
import { Workspace } from "@opencode-ai/schema/workspace"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionHistory } from "./history.js"
@@ -14,45 +11,8 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql.js"
import { fromRow } from "./info.js"
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
anchor: Session.ListAnchor.pipe(Schema.optional),
}
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
}
}
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
@@ -95,83 +55,6 @@ const layer = Layer.effect(
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
list: Effect.fn("SessionStore.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("SessionStore.messages")(function* (input) {
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
+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 })
+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]), [
[Bus.node, 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],
})
)
+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 }),
}),
)
-62
View File
@@ -1207,68 +1207,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(Layer.empty).live(
"merges MCP defaults into the winning configured server without changing runtime overrides",
() =>
Effect.gen(function* () {
const entries = [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
},
}),
}),
}),
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { catalog: 40 },
servers: {
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
},
}),
}),
}),
]
const original = JSON.stringify(entries)
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const check = yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual({
type: "local",
command: ["later"],
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
})
yield* check.dispose
const runtime = {
type: "local",
command: ["runtime"],
disabled: true,
timeout: { catalog: 60 },
} satisfies ConfigMCP.Local
yield* service.add("resources", runtime)
yield* service.reload()
yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual(runtime)
})
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed(entries),
}),
),
)
expect(JSON.stringify(entries)).toBe(original)
}),
)
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
+2 -12
View File
@@ -297,10 +297,7 @@ describe("ModelsDev Service", () => {
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -390,10 +387,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -446,10 +440,6 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
}),
)
+2 -2
View File
@@ -21,7 +21,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Effect, Layer, LayerMap, Stream } from "effect"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = LanguageModel.make({
@@ -66,7 +66,7 @@ const it = testEffect(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[LocationServiceMap.node, locations],
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
+5 -5
View File
@@ -35,8 +35,8 @@ import { Workspace } from "@opencode-ai/core/workspace"
import { Expected } from "./lib/session-message"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } from "./lib/project"
import { promptLocationLayer } from "./fixture/prompt-location"
import { globalProjectLayer } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
const it = testEffect(
@@ -52,8 +52,8 @@ const it = testEffect(
]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectNode],
[LocationServiceMap.node, promptLocationNode],
[Project.node, globalProjectLayer],
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
@@ -73,7 +73,7 @@ const projectIt = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
[LocationServiceMap.node, promptLocationNode],
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
+3 -13
View File
@@ -26,9 +26,7 @@ import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, SessionInbox.node, Job.node, KV.node, Session.node]),
),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
)
describe("SessionExecution lifecycle", () => {
@@ -94,8 +92,6 @@ describe("SessionExecution lifecycle", () => {
: Deferred.succeed(interruptedRunning, undefined).pipe(Effect.andThen(Effect.never)),
)
const execution = Context.get(context, SessionExecution.Service)
const completedActive = execution.isActive(completed)
expect(yield* completedActive).toBe(false)
yield* execution.resume(interrupted).pipe(Effect.forkScoped)
const completing = yield* execution.resume(completed).pipe(Effect.forkIn(scope))
yield* Deferred.await(interruptedRunning)
@@ -103,22 +99,17 @@ describe("SessionExecution lifecycle", () => {
// The write-ahead claim exists WHILE the turns run — no shutdown hook involved.
expect(yield* claims(database)).toEqual({ [interrupted]: true, [completed]: true })
expect(yield* completedActive).toBe(true)
expect(yield* execution.isActive(interrupted)).toBe(true)
// A drain that finishes on its own releases its claim.
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(completing)
yield* execution.awaitIdle(completed)
expect((yield* claims(database))[completed]).toBe(false)
expect(yield* completedActive).toBe(false)
expect(yield* execution.isActive(interrupted)).toBe(true)
// Teardown interruption (graceful twin of an unclean death) preserves the claim
// for the next server start.
yield* Scope.close(scope, Exit.void)
expect((yield* claims(database))[interrupted]).toBe(true)
expect(yield* execution.isActive(interrupted)).toBe(false)
}),
)
@@ -155,7 +146,6 @@ describe("SessionExecution lifecycle", () => {
expect(yield* execution.interrupt(sessionID)).toBeFalse()
expect(yield* execution.active).not.toContain(sessionID)
expect(yield* execution.isActive(sessionID)).toBe(false)
}),
)
@@ -907,7 +897,7 @@ describe("SessionRestart background recovery", () => {
it.effect("retains a subagent completion marker when synthetic admission conflicts", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const admission = yield* SessionInbox.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const parent = Session.ID.make("ses_completion_conflict_parent")
@@ -930,7 +920,7 @@ describe("SessionRestart background recovery", () => {
yield* jobs.background(child)
const marker = (yield* jobs.pendingBackground)[0]
if (!marker) return yield* Effect.die("background record missing")
yield* admission.admit({
yield* SessionInbox.admit(database.db, bus, {
id: marker.notificationID,
sessionID: parent,
item: { type: "user", payload: { text: "User input" }, delivery: "steer" },
@@ -33,7 +33,7 @@ import { tempLocationLayer } from "./fixture/location"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
import { executeTool, registerToolPlugin } from "./lib/tool"
const readToolNode = makeLocationNode({
@@ -74,7 +74,7 @@ const testLayer = AppNodeBuilder.build(
Image.node,
]),
[
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
[Location.node, tempLocationLayer],
[Permission.node, permission],
+2 -2
View File
@@ -16,14 +16,14 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
@@ -23,7 +23,7 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
const active = new Set<Session.ID>()
const it = testEffect(
@@ -31,14 +31,13 @@ const it = testEffect(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.sync(() => active),
isActive: (sessionID) => Effect.sync(() => active.has(sessionID)),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
@@ -213,53 +212,16 @@ describe("Session.updateMessage", () => {
)
yield* complete(bus, created.id, messageID)
yield* Effect.forEach(
[
SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
SessionMessage.ToolStateRunning.make({ status: "running", input: {}, metadata: {} }),
],
Effect.fnUntraced(function* (state) {
const unfinished = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_unfinished",
name: "read",
state,
time: { created: created.time.created },
})
expect(
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [unfinished] })),
).toEqual(new Session.MessageToolIncompleteError({ sessionID: created.id, messageID }))
}),
)
}),
)
it.effect("accepts completed and failed tool content", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* start(bus, created.id, messageID)
yield* complete(bus, created.id, messageID)
const content = [
SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "result" }],
}),
SessionMessage.ToolStateError.make({ status: "error", input: {}, error: { type: "tool", message: "failed" } }),
].map((state) =>
SessionMessage.AssistantTool.make({
type: "tool",
id: `call_${state.status}`,
name: "read",
state,
time: { created: created.time.created },
}),
)
expect((yield* session.updateMessage({ sessionID: created.id, messageID, content })).content).toEqual(content)
const unfinished = SessionMessage.AssistantTool.make({
type: "tool",
id: "call_unfinished",
name: "read",
state: { status: "streaming", input: "" },
time: { created: created.time.created },
})
expect(
yield* Effect.flip(session.updateMessage({ sessionID: created.id, messageID, content: [unfinished] })),
).toEqual(new Session.MessageToolIncompleteError({ sessionID: created.id, messageID }))
}),
)
+3 -3
View File
@@ -19,13 +19,13 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
@@ -40,7 +40,7 @@ const itWithUnavailableDestination = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
[LocationServiceMap.node, unavailableLocations],
],
-976
View File
@@ -1,976 +0,0 @@
import { describe, expect } from "bun:test"
import { and, eq } from "drizzle-orm"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { ID, Info, Output } from "@opencode-ai/schema/shell"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "../src/bus.js"
import { Database } from "../src/database/database.js"
import { EventTable } from "../src/event/sql.js"
import { Image } from "../src/image.js"
import { PluginHooks } from "../src/plugin/hooks.js"
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
import { ProjectTable } from "../src/project/sql.js"
import { AbsolutePath, RelativePath } from "../src/schema.js"
import { InboxConflictError, NotFoundError, PromptConflictError } from "../src/session/error.js"
import { SessionEvent } from "../src/session/event.js"
import { SessionExecution } from "../src/session/execution.js"
import { SessionInbox } from "../src/session/inbox.js"
import { SessionMessage } from "../src/session/message.js"
import { SessionPrompt } from "../src/session/prompt.js"
import { SessionProjector } from "../src/session/projector.js"
import { SessionRevert } from "../src/session/revert.js"
import { SessionRunCoordinator } from "../src/session/run-coordinator.js"
import { SessionSchema } from "../src/session/schema.js"
import { Session } from "../src/session/session.js"
import { SessionTable } from "../src/session/sql.js"
import { SessionStore } from "../src/session/store.js"
import { Shell } from "../src/shell.js"
import { Skill } from "../src/skill.js"
import { Snapshot } from "../src/snapshot.js"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
const it = testEffect(
LayerNode.compile(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionInbox.node,
FSUtil.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[Global.node, tempGlobalLayer],
],
),
)
const sessionID = SessionSchema.ID.make("ses_owned")
const otherID = SessionSchema.ID.make("ses_owned_other")
const source = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const setup = Effect.fnUntraced(function* (options?: {
execution?: SessionExecution.Interface
shell?: Layer.Layer<Shell.Service>
snapshot?: (ref: Location.Ref) => Layer.Layer<Snapshot.Service>
}) {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const fs = yield* FSUtil.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: source.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach([sessionID, otherID], (id) =>
bus.publish(SessionEvent.Created, {
sessionID: id,
projectID: Project.ID.global,
location: source,
slug: "owned",
title: "Owned session",
version: "test",
}),
)
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
const locations: Location.Ref[] = []
const flushes: Location.Ref[] = []
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
const execution = SessionExecution.Service.of({
active: Effect.succeed(new Set<SessionSchema.ID>()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
awaitIdle: () => Effect.void,
interrupt: () => Effect.succeed(false),
wake: (id) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.list(database.db, id)
const events = yield* database.db
.select({ id: EventTable.id })
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, id),
eq(EventTable.type, Bus.versionedType(SessionEvent.InboxEnqueued.type, 1)),
),
)
.all()
.pipe(Effect.orDie)
wakes.push({ sessionID: id, pending: pending.map((item) => item.id), enqueued: events.length })
}),
})
const services = Layer.mergeAll(
Layer.succeed(PluginHooks.Service, hooks),
Layer.mock(Image.Service, {}),
Layer.mock(Skill.Service, {}),
options?.shell ?? Layer.mock(Shell.Service, {}),
)
const servicesFor = (ref: Location.Ref): Layer.Layer<Session.Services> => {
locations.push(ref)
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
Layer.provideMerge(
Layer.mergeAll(
services,
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
flushes.push(ref)
}),
}),
),
),
Layer.provide(
Layer.mergeAll(
Layer.succeed(Database.Service, database),
Layer.succeed(Bus.Service, bus),
Layer.succeed(FSUtil.Service, fs),
),
),
Layer.fresh,
)
}
const sessions = yield* Session.make(servicesFor).pipe(
Effect.satisfiesServicesType<
Bus.Service | SessionStore.Service | SessionExecution.Service | SessionInbox.Service | Scope.Scope
>(),
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
)
return { sessions, hooks, locations, flushes, wakes, db: database.db, bus, store }
})
describe("Session-owned handles", () => {
it.live("owns state changes and message editing without caller services or Location acquisition", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const model = { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") }
const messageID = SessionMessage.ID.create()
yield* fixture.bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: messageID,
agent: Agent.ID.make("build"),
model: { ...model, id: Model.ID.make("initial-model") },
})
yield* fixture.bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: messageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
yield* fixture.db
.update(SessionTable)
.set({ time_idle: 0 })
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
const { rename, switchAgent, switchModel, view, message, updateMessage } = handle
yield* Effect.gen(function* () {
yield* rename({ title: "Renamed" })
yield* switchAgent({ agent: Agent.ID.make("review") })
yield* switchModel({ model })
yield* switchModel({ model })
yield* view({ idle: 0 })
yield* view({ idle: 0 })
const content = [SessionMessage.AssistantText.make({ type: "text", text: "Edited" })]
expect((yield* updateMessage({ messageID, content })).content).toEqual(content)
expect(yield* message(messageID)).toMatchObject({ type: "assistant", content })
}).pipe(Effect.satisfiesServicesType<never>(), Effect.setContext(Context.empty()))
const session = yield* handle.get()
expect(session).toMatchObject({ title: "Renamed", agent: "review", model })
expect(session.time.viewed && DateTime.toEpochMillis(session.time.viewed)).toBe(0)
expect(yield* fixture.sessions.forSession(otherID).message(messageID)).toBeUndefined()
expect((yield* fixture.sessions.forSession(otherID).get()).title).toBe("Owned session")
expect(fixture.locations).toEqual([])
expect(fixture.wakes).toEqual([])
const events = yield* fixture.db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.all()
.pipe(Effect.orDie)
expect(events.filter((event) => event.type === Bus.versionedType(SessionEvent.Viewed.type, 1))).toHaveLength(1)
expect(
events.filter((event) => event.type === Bus.versionedType(SessionEvent.ModelSelected.type, 1)),
).toHaveLength(1)
}),
)
it.live("acquires Location only for new prompt preparation and persists before waking", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const { get, prompt } = handle
expect(handle.id).toBe(sessionID)
expect((yield* get().pipe(Effect.satisfiesServicesType<never>())).location).toEqual(source)
const synthetic = yield* handle.synthetic({ text: "Background result", resume: false })
expect(fixture.locations).toEqual([])
expect(fixture.wakes).toEqual([])
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
expect(fixture.flushes).toEqual([source])
calls.push(event.prompt.text)
event.prompt.text += " prepared"
}),
)
const first = yield* prompt({
id: SessionMessage.ID.make("msg_owned_prepared"),
text: "Original",
files: [{ uri: new URL("./session-owned.test.ts", import.meta.url).href }],
})
const retried = yield* fixture.sessions.forSession(sessionID).prompt({
id: first.id,
text: "Ignored retry",
files: [{ uri: "file:///missing-owned-retry" }],
delivery: "queue",
})
expect(retried).toEqual(first)
expect(first.payload.text).toBe("Original prepared")
expect(first.payload.files?.[0]?.mime).toBe("text/plain")
expect(Buffer.from(first.payload.files?.[0]?.data ?? "", "base64").toString()).toBe(
yield* Effect.promise(() => Bun.file(import.meta.path).text()),
)
expect(calls).toEqual(["Original"])
expect(fixture.locations).toEqual([source])
expect(fixture.flushes).toEqual([source])
expect(fixture.wakes).toEqual([
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
{ sessionID, pending: [synthetic.id, first.id], enqueued: 2 },
])
expect(yield* SessionInbox.find(fixture.db, first.id)).toEqual(first)
expect(yield* fixture.store.context(sessionID)).toEqual([])
}),
)
it.live("keeps the first admission across handles, including delivered retries and identity conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const first = fixture.sessions.forSession(sessionID)
const second = fixture.sessions.forSession(sessionID)
const other = fixture.sessions.forSession(otherID)
const prompt = yield* first.prompt({ text: "Keep this", metadata: { source: "first" }, resume: false })
const retry = { id: prompt.id, text: "Ignore this", metadata: { source: "retry" }, resume: false }
expect(yield* second.prompt({ ...retry, delivery: "queue" })).toEqual(prompt)
const conflict = yield* other.prompt(retry).pipe(Effect.flip)
expect(conflict).toBeInstanceOf(PromptConflictError)
expect(conflict).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: otherID, messageID: prompt.id })
expect(yield* second.synthetic(retry).pipe(Effect.flip)).toMatchObject({
_tag: "Session.SyntheticConflictError",
sessionID,
inputID: prompt.id,
})
const synthetic = yield* first.synthetic({ text: "Original completion", description: "Job", resume: false })
expect(yield* second.synthetic({ ...retry, id: synthetic.id })).toEqual(synthetic)
expect(yield* first.inbox()).toEqual([prompt, synthetic])
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
// Delivered identity must be recoverable from the message, without retained enqueue history.
yield* fixture.db
.delete(EventTable)
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, Bus.versionedType(SessionEvent.InboxEnqueued.type, 1)),
),
)
.run()
.pipe(Effect.orDie)
expect((yield* second.prompt({ ...retry, files: [{ uri: "file:///missing-owned-retry" }] })).payload).toEqual(
prompt.payload,
)
expect((yield* second.synthetic({ ...retry, id: synthetic.id })).payload).toEqual(synthetic.payload)
expect(yield* other.synthetic({ ...retry, id: synthetic.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.SyntheticConflictError",
sessionID: otherID,
inputID: synthetic.id,
})
expect(yield* second.prompt({ ...retry, id: synthetic.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.PromptConflictError",
sessionID,
messageID: synthetic.id,
})
expect(yield* second.inbox()).toEqual([])
expect(yield* fixture.store.context(sessionID)).toMatchObject([
{ id: prompt.id, text: "Keep this", metadata: { source: "first" } },
{ id: synthetic.id, text: "Original completion", description: "Job" },
])
expect(fixture.locations).toEqual([source])
}),
)
it.live("reads fresh placement through an existing handle after a projected move", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
yield* handle.prompt({ text: "Before move", resume: false })
const get = handle.get()
const prompt = handle.prompt({ text: "After move", resume: false })
const destination = Location.Ref.make({ directory: AbsolutePath.make("/project/moved") })
yield* fixture.bus.publish(SessionEvent.Moved, {
sessionID,
location: destination,
projectID: Project.ID.global,
subpath: RelativePath.make("moved"),
})
expect((yield* get).location).toEqual(destination)
expect(fixture.locations).toEqual([source])
yield* prompt
expect(fixture.locations).toEqual([source, destination])
expect(fixture.flushes).toEqual([source, destination])
expect((yield* fixture.sessions.forSession(otherID).get()).location).toEqual(source)
}),
)
it.live("keeps prompt wakes independent of shell work across handles", () =>
Effect.gen(function* () {
const blocked = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const started = Info.make({
id: ID.make("sh_owned"),
command: "echo owned",
cwd: source.directory,
shell: "sh",
file: "/project/shell.out",
status: "running",
metadata: { sessionID, background: true },
time: { started: 0 },
})
const fixture = yield* setup({
shell: Layer.mock(Shell.Service, {
create: (input) =>
Effect.sync(() => {
expect(input).toEqual({
command: started.command,
cwd: source.directory,
timeout: 0,
metadata: { sessionID, background: true },
})
return started
}),
result: () =>
Deferred.succeed(blocked, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({
info: Info.make({ ...started, status: "exited", exit: 0, time: { started: 0, completed: 1 } }),
capture: { output: "owned", truncated: false },
}),
),
output: () => Effect.succeed(Output.make({ output: "owned", cursor: 5, size: 5, truncated: false })),
}),
})
const shell = yield* fixture.sessions
.forSession(sessionID)
.shell({ id: Event.ID.make("evt_owned_shell"), command: started.command })
.pipe(Effect.forkScoped)
yield* Deferred.await(blocked)
const admitted = yield* fixture.sessions.forSession(sessionID).prompt({ text: "Admit while the shell runs" })
expect(yield* SessionInbox.find(fixture.db, admitted.id)).toEqual(admitted)
expect(fixture.wakes).toEqual([{ sessionID, pending: [admitted.id], enqueued: 1 }])
const other = yield* fixture.sessions.forSession(otherID).prompt({ text: "Independent Session" })
expect(fixture.wakes).toEqual([
{ sessionID, pending: [admitted.id], enqueued: 1 },
{ sessionID: otherID, pending: [other.id], enqueued: 1 },
])
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(shell)
expect(fixture.wakes).toEqual([
{ sessionID, pending: [admitted.id], enqueued: 1 },
{ sessionID: otherID, pending: [other.id], enqueued: 1 },
])
expect(yield* fixture.store.context(sessionID)).toMatchObject([
{ type: "shell", shellID: started.id, status: "exited", output: { output: "owned" } },
])
expect(yield* fixture.sessions.forSession(sessionID).inbox()).toMatchObject([
{ id: admitted.id, type: "user" },
{ type: "synthetic", payload: { metadata: { source: "shell", shellID: started.id, state: "completed" } } },
])
}),
)
it.live("allows a prompt hook to admit synthetic input through another handle for the same Session", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const nested = fixture.sessions.forSession(sessionID)
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.gen(function* () {
expect(event.sessionID).toBe(sessionID)
yield* nested.synthetic({ text: "Admitted by hook", resume: false })
event.prompt.text += " prepared"
}).pipe(Effect.orDie),
)
const prompt = yield* handle.prompt({ text: "Original", resume: false })
expect(yield* handle.inbox()).toMatchObject([
{ type: "synthetic", payload: { text: "Admitted by hook" } },
{ id: prompt.id, type: "user", payload: { text: "Original prepared" } },
])
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
expect(yield* fixture.store.context(sessionID)).toMatchObject([
{ type: "synthetic", text: "Admitted by hook" },
{ type: "user", text: "Original prepared" },
])
expect(fixture.locations).toEqual([source])
}),
)
it.live("mutates only this handle's pending inbox and preserves public conflict tags", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const second = fixture.sessions.forSession(sessionID)
const queued = yield* handle.synthetic({ text: "Queued", delivery: "queue", resume: false })
const steer = yield* handle.prompt({ text: "Steer", resume: false })
const compact = yield* handle.compact({ delivery: "queue" })
yield* second.steerInbox(queued.id)
yield* second.queueInbox(steer.id)
expect(yield* handle.inbox()).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: steer.id, delivery: "queue" },
{ id: compact.id, type: "compaction", delivery: "queue" },
])
expect(fixture.wakes).toHaveLength(2)
expect(yield* fixture.sessions.forSession(otherID).cancelInbox(queued.id).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
sessionID: otherID,
inboxID: queued.id,
})
yield* second.cancelInbox(compact.id)
const cancelled = yield* handle.cancelInbox(compact.id).pipe(Effect.flip)
expect(cancelled).toBeInstanceOf(InboxConflictError)
expect(cancelled).toMatchObject({ _tag: "Session.InboxConflictError", sessionID, inboxID: compact.id })
expect(yield* handle.compact({ id: steer.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.CompactionConflictError",
sessionID,
inputID: steer.id,
})
expect(yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")).toBe(1)
expect(yield* second.queueInbox(queued.id).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
sessionID,
inboxID: queued.id,
})
expect(yield* handle.inbox()).toMatchObject([{ id: steer.id, delivery: "queue" }])
yield* second.cancelInbox(steer.id)
expect(yield* handle.inbox()).toEqual([])
const missingID = SessionSchema.ID.make("ses_owned_missing")
const missing = yield* fixture.sessions.forSession(missingID).inbox().pipe(Effect.flip)
expect(missing).toBeInstanceOf(NotFoundError)
expect(missing).toMatchObject({ _tag: "Session.NotFoundError", sessionID: missingID })
expect(fixture.locations).toEqual([source])
}),
)
it.live("joins same-ID resumes without transferring execution ownership to a cancelled caller", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const joining = yield* Deferred.make<void>()
const drains: SessionSchema.ID[] = []
const resumes: SessionSchema.ID[] = []
const interrupts: Array<{ sessionID: SessionSchema.ID; options?: { readonly continue?: boolean } }> = []
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, never>({
drain: (id) =>
Effect.sync(() => void drains.push(id)).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Deferred.await(release)),
),
})
const fixture = yield* setup({
execution: SessionExecution.Service.of({
active: coordinator.active,
isActive: coordinator.isActive,
resume: (id) =>
Effect.gen(function* () {
resumes.push(id)
if (resumes.length === 2) yield* Deferred.succeed(joining, undefined)
yield* coordinator.run(id)
}),
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
interrupt: (id, options) =>
Effect.sync(() => void interrupts.push({ sessionID: id, options })).pipe(
Effect.andThen(coordinator.interrupt(id)),
),
}),
})
const first = yield* fixture.sessions.forSession(sessionID).resume().pipe(Effect.forkScoped)
yield* Deferred.await(started)
const second = yield* fixture.sessions.forSession(sessionID).resume().pipe(Effect.forkScoped)
yield* Deferred.await(joining)
yield* Fiber.interrupt(second)
const cancelled = yield* Fiber.await(second)
expect(Exit.isFailure(cancelled) && Cause.hasInterruptsOnly(cancelled.cause)).toBe(true)
expect(yield* coordinator.active).toEqual(new Set([sessionID]))
expect(drains).toEqual([sessionID])
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* fixture.sessions.forSession(sessionID).wait()
expect(drains).toEqual([sessionID])
expect(yield* coordinator.active).toEqual(new Set())
expect(yield* fixture.sessions.forSession(sessionID).interrupt({ continue: true })).toBe(false)
expect(yield* fixture.sessions.forSession(sessionID).interrupt()).toBe(false)
expect(interrupts).toEqual([
{ sessionID, options: { continue: true } },
{ sessionID, options: undefined },
])
expect(fixture.locations).toEqual([])
}),
)
it.live("keeps preparation interruptible without admitting input or committing a staged revert", () =>
Effect.gen(function* () {
const fixture = yield* setup({
snapshot: () => Layer.mock(Snapshot.Service, { capture: () => Effect.undefined }),
})
const handle = fixture.sessions.forSession(sessionID)
const boundary = yield* handle.synthetic({ text: "Revert boundary", resume: false })
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
yield* handle.revert.stage({ messageID: boundary.id, files: false })
const entered = yield* Deferred.make<void>()
const hook = yield* fixture.hooks.register("session", "prompt", () =>
Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)),
)
const submission = yield* handle.prompt({ text: "Cancelled before admission" }).pipe(Effect.forkScoped)
yield* Deferred.await(entered)
yield* Fiber.interrupt(submission)
const cancelled = yield* Fiber.await(submission)
expect(Exit.isFailure(cancelled) && Cause.hasInterruptsOnly(cancelled.cause)).toBe(true)
expect(yield* handle.inbox()).toEqual([])
expect((yield* handle.get()).revert?.messageID).toBe(boundary.id)
expect(yield* fixture.store.context(sessionID)).toMatchObject([{ id: boundary.id }])
expect(fixture.wakes).toEqual([])
yield* hook.dispose
yield* handle.revert.clear()
expect((yield* handle.get()).revert).toBeUndefined()
expect(yield* fixture.store.context(sessionID)).toMatchObject([{ id: boundary.id }])
yield* handle.revert.stage({ messageID: boundary.id, files: false })
const acquisitions = fixture.locations.length
yield* fixture.sessions.forSession(sessionID).revert.commit()
expect((yield* handle.get()).revert).toBeUndefined()
expect(yield* fixture.store.context(sessionID)).toEqual([])
expect(fixture.locations).toHaveLength(acquisitions)
}),
)
it.live("selects the destination's constructed revert operations after a move", () =>
Effect.gen(function* () {
const captures: Location.Ref[] = []
const fixture = yield* setup({
snapshot: (ref) =>
Layer.mock(Snapshot.Service, {
capture: () =>
Effect.sync(() => {
captures.push(ref)
return undefined
}),
}),
})
const handle = fixture.sessions.forSession(sessionID)
const boundary = yield* handle.synthetic({ text: "Revert boundary", resume: false })
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
yield* handle.revert.stage({ messageID: boundary.id, files: false })
const destination = Location.Ref.make({ directory: AbsolutePath.make("/project/moved") })
yield* fixture.bus.publish(SessionEvent.Moved, {
sessionID,
location: destination,
projectID: Project.ID.global,
subpath: RelativePath.make("moved"),
})
yield* handle.revert.stage({ messageID: boundary.id, files: false })
yield* handle.revert.clear()
expect(captures).toEqual([source, destination])
expect(fixture.locations).toEqual([source, destination, destination])
expect(fixture.flushes).toEqual([source, destination, destination])
expect((yield* handle.get()).revert).toBeUndefined()
}),
)
})
describe("SessionPrompt construction", () => {
it.live("captures preparation dependencies without admitting input and checks readiness on every call", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const calls: string[] = []
yield* fixture.hooks.register("session", "prompt", (event) =>
Effect.sync(() => {
calls.push("hook")
event.prompt.text += " prepared"
}),
)
const { prepare } = yield* SessionPrompt.Service.pipe(
Effect.provide(
SessionPrompt.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(PluginHooks.Service, fixture.hooks),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
calls.push("ready")
}),
}),
Layer.mock(Image.Service, {}),
Layer.mock(Skill.Service, {}),
),
),
),
),
)
expect(calls).toEqual([])
const input = { text: "Original", files: [{ uri: new URL("./session-owned.test.ts", import.meta.url).href }] }
const request = { sessionID, messageID: SessionMessage.ID.create(), input }
const items = yield* Effect.forEach([0, 1], () => prepare(request)).pipe(
Effect.satisfiesServicesType<never>(),
Effect.setContext(Context.empty()),
)
expect(calls).toEqual(["ready", "hook", "ready", "hook"])
expect(items[0]).toEqual(items[1])
expect(items[0]).toMatchObject({ type: "user", payload: { text: "Original prepared" }, delivery: "steer" })
expect(items[0]?.payload.files?.[0]?.mime).toBe("text/plain")
expect(input.text).toBe("Original")
expect(yield* fixture.sessions.forSession(sessionID).inbox()).toEqual([])
expect(fixture.wakes).toEqual([])
}),
)
})
describe("SessionRevert construction", () => {
it.live("captures dependencies without work, then checks readiness on every stage and clear", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const boundary = yield* handle.synthetic({ text: "Revert boundary", resume: false })
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
const calls: string[] = []
const revert = yield* SessionRevert.make().pipe(
Effect.provide(
Layer.merge(
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
calls.push("flush")
}),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
Effect.sync(() => {
calls.push("capture")
return Snapshot.ID.make("captured-tree")
}),
diff: () =>
Effect.sync(() => {
calls.push("diff")
return []
}),
restore: () =>
Effect.sync(() => {
calls.push("restore")
}),
}),
),
),
)
expect(calls).toEqual([])
const unrelated = Layer.merge(Layer.mock(PluginSupervisor.Service, {}), Layer.mock(Snapshot.Service, {}))
const session = yield* handle.get()
yield* revert
.stage({ session, messageID: boundary.id, files: false })
.pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["flush", "capture", "capture", "diff"])
const staged = yield* handle.get()
expect(staged.revert?.snapshot).toBe(Snapshot.ID.make("captured-tree"))
yield* revert.clear(staged).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
const cleared = yield* handle.get()
expect(cleared.revert).toBeUndefined()
yield* revert.clear(cleared).pipe(Effect.satisfiesServicesType<never>(), Effect.provide(unrelated))
expect(calls).toEqual(["flush", "capture", "capture", "diff", "flush", "restore", "flush"])
}),
)
})
describe("SessionInbox command contracts", () => {
it.live("captures the provided Inbox service when constructing Session", () =>
Effect.gen(function* () {
const admission = yield* SessionInbox.Service
const cancelled: SessionMessage.ID[] = []
const fixture = yield* setup().pipe(
Effect.provideService(
SessionInbox.Service,
SessionInbox.Service.of({
...admission,
cancel: (input) =>
admission.cancel(input).pipe(Effect.tap(() => Effect.sync(() => cancelled.push(input.id)))),
}),
),
)
const handle = fixture.sessions.forSession(sessionID)
const pending = yield* handle.synthetic({ text: "Pending", resume: false })
yield* handle.cancelInbox(pending.id).pipe(Effect.setContext(Context.empty()))
expect(cancelled).toEqual([pending.id])
expect(yield* handle.inbox()).toEqual([])
expect(fixture.wakes).toEqual([])
}),
)
it.live("captures the host dependencies for detached commands", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const { list, admit, reconcile, admitCompaction, cancel, steer, queue } = yield* SessionInbox.Service
const other = yield* SessionInbox.make()
expect(yield* SessionInbox.list(fixture.db, sessionID)).toEqual([])
yield* Effect.gen(function* () {
expect(yield* list(sessionID)).toEqual([])
const user = yield* admit({
id: SessionMessage.ID.create(),
sessionID,
item: { type: "user", payload: { text: "Captured services" }, delivery: "queue" },
})
expect(yield* reconcile({ id: user.id, sessionID, type: "user", delivery: "queue" })).toEqual(user)
yield* steer({ id: user.id, sessionID })
yield* queue({ id: user.id, sessionID })
yield* cancel({ id: user.id, sessionID })
const [compaction, duplicate] = yield* Effect.all(
[
admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "queue" }),
other.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "queue" }),
],
{ concurrency: "unbounded" },
)
expect(compaction).toEqual(duplicate)
yield* cancel({ id: compaction.id, sessionID })
expect(yield* list(sessionID)).toEqual([])
}).pipe(Effect.satisfiesServicesType<never>(), Effect.setContext(Context.empty()))
expect(yield* SessionInbox.list(fixture.db, sessionID)).toEqual([])
expect(fixture.wakes).toEqual([])
}),
)
it.live("returns checked user and synthetic admissions and typed pending or delivered conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const admission = yield* SessionInbox.Service
const user = yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID,
item: { type: "user", payload: { text: "Keep user input" }, delivery: "steer" },
})
.pipe(
Effect.satisfiesSuccessType<SessionInbox.User>(),
Effect.satisfiesErrorType<SessionInbox.LifecycleConflict>(),
)
const synthetic = yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID,
item: { type: "synthetic", payload: { text: "Keep synthetic input" }, delivery: "steer" },
})
.pipe(Effect.satisfiesSuccessType<SessionInbox.Synthetic>())
yield* Effect.forEach([false, true], (delivered) =>
Effect.gen(function* () {
if (delivered) yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
const reconciled = yield* admission
.reconcile({
id: user.id,
sessionID,
type: "user",
delivery: "steer",
})
.pipe(Effect.satisfiesSuccessType<SessionInbox.User | undefined>())
expect(reconciled).toMatchObject({
id: user.id,
sessionID,
type: "user",
payload: user.payload,
delivery: "steer",
})
if (!delivered) expect(reconciled).toEqual(user)
yield* Effect.forEach([user, synthetic], (original) =>
Effect.gen(function* () {
expect(
yield* admission.admit({
id: original.id,
sessionID,
item: { type: original.type, payload: { text: "Ignored retry" }, delivery: "queue" },
}),
).toMatchObject({ id: original.id, sessionID, type: original.type, payload: original.payload })
yield* Effect.forEach(
[
{ sessionID: otherID, type: original.type },
{ sessionID, type: original.type === "user" ? ("synthetic" as const) : ("user" as const) },
],
(conflict) =>
Effect.gen(function* () {
expect(
yield* admission
.reconcile({
...conflict,
id: original.id,
delivery: "steer",
})
.pipe(Effect.flip),
).toBeInstanceOf(SessionInbox.LifecycleConflict)
expect(
yield* admission
.admit({
id: original.id,
sessionID: conflict.sessionID,
item: { type: conflict.type, payload: { text: "Conflicting input" }, delivery: "steer" },
})
.pipe(Effect.flip),
).toMatchObject({ _tag: "SessionInbox.LifecycleConflict", id: original.id })
}),
)
}),
)
}),
)
expect(fixture.locations).toEqual([])
expect(fixture.wakes).toEqual([])
}),
)
it.live("checks the winner of concurrent admissions before returning it", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const admission = yield* SessionInbox.Service
const other = yield* SessionInbox.make()
const id = SessionMessage.ID.create()
const requests = [
{ sessionID, item: { type: "user", payload: { text: "First" }, delivery: "steer" } },
{ sessionID, item: { type: "user", payload: { text: "Retry" }, delivery: "queue" } },
{ sessionID, item: { type: "synthetic", payload: { text: "Other type" }, delivery: "steer" } },
{ sessionID: otherID, item: { type: "user", payload: { text: "Other Session" }, delivery: "steer" } },
] satisfies Array<{ sessionID: SessionSchema.ID; item: SessionInbox.Item }>
const results = yield* Effect.forEach(
requests,
(request, index) => (index % 2 === 0 ? admission : other).admit({ id, ...request }).pipe(Effect.exit),
{ concurrency: "unbounded" },
)
const stored = yield* SessionInbox.find(fixture.db, id)
expect(stored).toBeDefined()
expect(results.some(Exit.isSuccess)).toBe(true)
results.forEach((result, index) => {
if (Exit.isSuccess(result)) {
expect(stored).toEqual(result.value)
expect(result.value.sessionID).toBe(requests[index]?.sessionID)
expect(result.value.type).toBe(requests[index]?.item.type)
return
}
expect(Cause.hasDies(result.cause)).toBe(false)
expect(Cause.hasFails(result.cause)).toBe(true)
})
expect(
(yield* SessionInbox.list(fixture.db, sessionID)).length +
(yield* SessionInbox.list(fixture.db, otherID)).length,
).toBe(1)
}),
)
it.live("exposes failed pending transitions as typed conflicts and rolls back their events", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const admission = yield* SessionInbox.Service
const pending = yield* admission.admit({
id: SessionMessage.ID.create(),
sessionID,
item: { type: "user", payload: { text: "Pending" }, delivery: "queue" },
})
const input = { id: pending.id, sessionID }
yield* Effect.forEach([admission.cancel, admission.steer, admission.queue], (mutation) =>
Effect.gen(function* () {
expect(yield* mutation({ ...input, sessionID: otherID }).pipe(Effect.flip)).toMatchObject({
_tag: "SessionInbox.LifecycleConflict",
id: pending.id,
})
}),
)
yield* admission.steer(input)
expect(yield* admission.steer(input).pipe(Effect.flip)).toBeInstanceOf(SessionInbox.LifecycleConflict)
yield* admission.queue(input)
expect(yield* admission.queue(input).pipe(Effect.flip)).toBeInstanceOf(SessionInbox.LifecycleConflict)
yield* admission.cancel(input)
expect(yield* admission.cancel(input).pipe(Effect.flip)).toBeInstanceOf(SessionInbox.LifecycleConflict)
expect(yield* SessionInbox.list(fixture.db, sessionID)).toEqual([])
expect(
(yield* fixture.db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(EventTable.seq)
.all()
.pipe(Effect.orDie))
.filter((event) => event.type.startsWith("session.inbox."))
.map((event) => event.type),
).toEqual([
Bus.versionedType(SessionEvent.InboxEnqueued.type, 1),
Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1),
Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1),
Bus.versionedType(SessionEvent.InboxCancelled.type, 1),
])
}),
)
it.live("does not turn unrelated projector defects into conflicts", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const admission = yield* SessionInbox.Service
const pending = yield* admission.admit({
id: SessionMessage.ID.create(),
sessionID,
item: { type: "user", payload: { text: "Pending" }, delivery: "queue" },
})
const defect = new Error("Projector failed")
yield* fixture.bus.project(SessionEvent.InboxEnqueued, () => Effect.die(defect))
yield* fixture.bus.project(SessionEvent.InboxCancelled, () => Effect.die(defect))
expect(
yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID,
item: { type: "user", payload: { text: "Rolled back" }, delivery: "steer" },
})
.pipe(Effect.catchDefect(Effect.succeed)),
).toBe(defect)
expect(yield* admission.cancel({ id: pending.id, sessionID }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
defect,
)
expect(yield* SessionInbox.list(fixture.db, sessionID)).toEqual([pending])
}),
)
})
+5 -26
View File
@@ -21,7 +21,6 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionStateTable,
@@ -33,10 +32,9 @@ import { testEffect } from "./lib/effect"
import { Snapshot } from "@opencode-ai/core/snapshot"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
[[Bus.node, Bus.configured({ persist: true })]],
),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
const sessionID = Session.ID.make("ses_projector_test")
@@ -89,9 +87,8 @@ describe("SessionProjector", () => {
Effect.gen(function* () {
const db = yield* seedSession()
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* inbox.admitCompaction({ id: inputID, sessionID, delivery: "queue" })
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
@@ -280,9 +277,7 @@ describe("SessionProjector", () => {
yield* db.run(sql`update session_message set data = '{"time":{"created":0}}' where id = ${messageID}`)
const sessions = yield* Session.Service
const store = yield* SessionStore.Service
const expected = { _tag: "Session.MessageDecodeError", sessionID, messageID }
expect(yield* store.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.messages({ sessionID }).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.context(sessionID).pipe(Effect.flip)).toMatchObject(expected)
expect(yield* sessions.message({ sessionID, messageID }).pipe(Effect.catchDefect(Effect.succeed))).toMatchObject(
@@ -291,28 +286,12 @@ describe("SessionProjector", () => {
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("checks session existence before resolving a missing message cursor", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const missing = Session.ID.make("ses_missing")
expect(
yield* sessions
.messages({
sessionID: missing,
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
})
.pipe(Effect.flip),
).toEqual(new Session.NotFoundError({ sessionID: missing }))
}).pipe(Effect.provide(sessionsLayer)),
)
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* inbox.admit({
const admitted = yield* SessionInbox.admit(db, bus, {
id,
sessionID,
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
+30 -60
View File
@@ -7,11 +7,8 @@ import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/schema/location"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -20,9 +17,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRevert } from "@opencode-ai/core/session/revert"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
@@ -33,7 +28,6 @@ import { Image } from "@opencode-ai/core/image"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Skill } from "@opencode-ai/core/skill"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -46,7 +40,6 @@ const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.sync(() => new Set(activeSessions)),
isActive: (sessionID) => Effect.sync(() => activeSessions.has(sessionID)),
resume: (sessionID) =>
Effect.sync(() => {
executionCalls.push(sessionID)
@@ -64,60 +57,37 @@ const execution = Layer.succeed(
awaitIdle: () => Effect.void,
}),
)
const locations = makeGlobalNode({
service: LocationServiceMap.Service,
layer: Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const shared = Layer.mergeAll(
Layer.succeed(Database.Service, database),
Layer.succeed(Bus.Service, bus),
Layer.succeed(FSUtil.Service, fs),
)
return yield* LayerMap.make(
(_ref: Location.Ref) =>
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.suspend(() => {
let ready = false
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
Layer.provideMerge(
Layer.mergeAll(
LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), [
[Bus.node, Layer.succeed(Bus.Service, bus)],
]),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
ready
? Effect.succeed(
content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content,
)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
),
),
Layer.provide(shared),
Layer.fresh,
)
}) as unknown as Layer.Layer<LocationServices>,
)
}),
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// These operations resolve Location services lazily and must wait for plugin-projected state.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.unwrap(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
ready
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
)
}),
) as unknown as Layer.Layer<LocationServices>,
),
deps: [Database.node, Bus.node, FSUtil.node],
})
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
+2 -2
View File
@@ -15,7 +15,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
const closed: Session.ID[] = []
@@ -39,7 +39,7 @@ const it = testEffect(
LocationServiceMap.node,
]),
[
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
[SessionModelTransport.node, transport],
],
@@ -18,7 +18,6 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRevert } from "@opencode-ai/core/session/revert"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -61,9 +60,6 @@ describe("Session.revert files", () => {
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
const services = LocationServiceMap.Service.get(created.location)
const revert = yield* SessionRevert.Service.pipe(Effect.provide(services))
expect(yield* SessionRevert.Service.pipe(Effect.provide(services))).toBe(revert)
yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
@@ -73,30 +73,22 @@ describe("SessionRunCoordinator", () => {
Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)),
),
})
const firstActive = coordinator.isActive("first")
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(yield* firstActive).toBe(false)
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
expect(Array.from(yield* coordinator.active)).toEqual(["first"])
expect(yield* firstActive).toBe(true)
expect(yield* coordinator.isActive("second")).toBe(false)
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
yield* Deferred.await(secondStarted)
expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"])
expect(yield* coordinator.isActive("second")).toBe(true)
yield* Deferred.succeed(firstGate, undefined)
yield* Fiber.join(first)
expect(Array.from(yield* coordinator.active)).toEqual(["second"])
expect(yield* firstActive).toBe(false)
expect(yield* coordinator.isActive("second")).toBe(true)
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(second)
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(yield* coordinator.isActive("second")).toBe(false)
}),
)
@@ -113,12 +105,10 @@ describe("SessionRunCoordinator", () => {
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(yield* coordinator.isActive("failure")).toBe(false)
const died = yield* coordinator.run("defect").pipe(Effect.exit)
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(yield* coordinator.isActive("defect")).toBe(false)
expect(settled).toHaveLength(2)
}),
)
@@ -622,7 +612,6 @@ describe("SessionRunCoordinator", () => {
yield* coordinator.interrupt("session", "user")
expect(settled).toHaveLength(0)
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
expect(yield* coordinator.isActive("session")).toBe(true)
// Repeating the interrupt during cleanup stays an immediate no-op.
yield* coordinator.interrupt("session", "user")
@@ -632,7 +621,6 @@ describe("SessionRunCoordinator", () => {
expect(settled).toEqual(["user"])
expect(yield* coordinator.active).toEqual(new Set())
expect(yield* coordinator.isActive("session")).toBe(false)
}),
)
@@ -648,7 +636,6 @@ describe("SessionRunCoordinator", () => {
yield* coordinator.wake("session")
yield* Deferred.await(settling)
expect(yield* coordinator.isActive("session")).toBe(true)
// The owner has exited; this wake lands on the settling execution's doorbell.
yield* coordinator.wake("session")
// The interrupt claims it: settle must not start a successor for the dead intent.
@@ -658,7 +645,6 @@ describe("SessionRunCoordinator", () => {
expect(drains).toBe(1)
expect(yield* coordinator.active).toEqual(new Set())
expect(yield* coordinator.isActive("session")).toBe(false)
}),
)
@@ -42,7 +42,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import path from "node:path"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { promptLocationNode } from "./fixture/prompt-location"
import { promptLocationLayer } from "./fixture/prompt-location"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -125,7 +125,6 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
})
return SessionExecution.Service.of({
active: coordinator.active,
isActive: coordinator.isActive,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
@@ -156,7 +155,7 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
]),
[
[Bus.node, Bus.configured({ persist: true })],
[LocationServiceMap.node, promptLocationNode],
[LocationServiceMap.node, promptLocationLayer],
[LayerNodePlatform.llmClient, llmClient],
[Permission.node, permission],
[Catalog.node, promptCatalog],
+12 -16
View File
@@ -81,7 +81,7 @@ import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq, sql } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { promptLocationNode } from "./fixture/prompt-location"
import { promptLocationLayer } from "./fixture/prompt-location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Expected } from "./lib/session-message"
import { permissionLayer } from "./lib/permission"
@@ -447,7 +447,6 @@ const layer = Layer.unwrap(
})
return SessionExecution.Service.of({
active: coordinator.active,
isActive: coordinator.isActive,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
@@ -462,7 +461,6 @@ const layer = Layer.unwrap(
Form.node,
SessionProjector.node,
SessionStore.node,
SessionInbox.node,
Agent.node,
Catalog.node,
Tool.node,
@@ -486,7 +484,7 @@ const layer = Layer.unwrap(
[
...replacements,
[Bus.node, Bus.configured({ persist: true })],
[LocationServiceMap.node, promptLocationNode],
[LocationServiceMap.node, promptLocationLayer],
[Catalog.node, promptCatalog],
[SessionExecution.node, execution],
],
@@ -518,7 +516,6 @@ const insertSession = (id: Session.ID) =>
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const bus = yield* Bus.Service
const sessionInbox = yield* SessionInbox.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
@@ -550,7 +547,6 @@ const setup = Effect.gen(function* () {
return Object.assign(state, {
db,
bus,
sessionInbox,
session,
llm,
requests: llm.requests,
@@ -1391,12 +1387,12 @@ describe("SessionRunnerLLM", () => {
s.systemLoadHook = Effect.sync(() => {
reads++
})
const compaction = yield* s.sessionInbox.admitCompaction({
const compaction = yield* SessionInbox.admitCompaction(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* s.sessionInbox.admit({
yield* SessionInbox.admit(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -1424,7 +1420,7 @@ describe("SessionRunnerLLM", () => {
scenario("delivers a queued move atomically at the idle boundary", function* (s) {
const inboxID = SessionMessage.ID.create()
yield* s.sessionInbox.admit({
yield* SessionInbox.admit(s.db, s.bus, {
id: inboxID,
sessionID,
item: {
@@ -1460,7 +1456,7 @@ describe("SessionRunnerLLM", () => {
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.sessionInbox.admit({
yield* SessionInbox.admit(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -1492,7 +1488,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
yield* s.sessionInbox.admit({
yield* SessionInbox.admit(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -1525,12 +1521,12 @@ describe("SessionRunnerLLM", () => {
const stream = yield* s.llm.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
const compaction = yield* s.sessionInbox.admitCompaction({
const compaction = yield* SessionInbox.admitCompaction(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* s.sessionInbox.admit({
yield* SessionInbox.admit(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -3211,7 +3207,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
yield* s.sessionInbox.admit({
yield* SessionInbox.admit(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -3323,7 +3319,7 @@ describe("SessionRunnerLLM", () => {
scenario("a steer-scoped drain runs a queued manual compaction next in line", function* (s) {
// Admit without waking so the steer-scoped drain below is the first consumer.
const compaction = yield* s.sessionInbox.admitCompaction({
const compaction = yield* SessionInbox.admitCompaction(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
@@ -3344,7 +3340,7 @@ describe("SessionRunnerLLM", () => {
scenario("a steer-scoped drain leaves a compaction parked behind a queued prompt", function* (s) {
yield* s.session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
const compaction = yield* s.sessionInbox.admitCompaction({
const compaction = yield* SessionInbox.admitCompaction(s.db, s.bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
-1
View File
@@ -42,7 +42,6 @@ const executionLayer = Layer.effect(
})
return SessionExecution.Service.of({
active: coordinator.active,
isActive: coordinator.isActive,
resume: coordinator.run,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle,
+20 -34
View File
@@ -4,10 +4,7 @@ import { Effect, Layer, LayerMap } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "@opencode-ai/core/bus"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
@@ -18,15 +15,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Skill } from "@opencode-ai/core/skill"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const info = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
@@ -34,41 +32,29 @@ const info = Skill.Info.make({
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
})
const locations = makeGlobalNode({
service: LocationServiceMap.Service,
layer: Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const skills = SessionPrompt.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node])),
Layer.succeed(FSUtil.Service, fs),
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
),
),
)
return yield* LayerMap.make(
(_ref: Location.Ref) =>
// These tests need skill activation and prompt preparation from the same location services.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
skills as unknown as Layer.Layer<LocationServices>,
)
}),
const skills = Layer.mergeAll(
LayerNode.compile(PluginHooks.node),
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// The skill endpoint only needs the location-scoped Skill service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
skills as unknown as Layer.Layer<LocationServices>,
),
deps: [FSUtil.node],
})
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[LocationServiceMap.node, locations],
[Project.node, globalProjectNode],
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
-190
View File
@@ -1,190 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Event } from "@opencode-ai/schema/event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const seedSessions = (rows: { id: string; updated: number }[]) =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const directory = AbsolutePath.make("/project")
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: directory, sandboxes: [] }).run()
yield* Effect.forEach(rows, (row) =>
Effect.gen(function* () {
const sessionID = Session.ID.make(row.id)
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory },
slug: "store-test",
version: "test",
})
yield* bus.replay({
id: Event.ID.create(),
created: row.updated,
aggregateID: sessionID,
seq: 1,
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
data: { sessionID, title: row.id },
})
}),
)
return bus
})
describe("SessionStore", () => {
it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
Effect.gen(function* () {
yield* seedSessions([
{ id: "ses_d", updated: 20 },
{ id: "ses_z", updated: 10 },
{ id: "ses_a", updated: 30 },
{ id: "ses_c", updated: 20 },
{ id: "ses_y", updated: 10 },
{ id: "ses_e", updated: 30 },
{ id: "ses_b", updated: 20 },
])
const store = yield* SessionStore.Service
expect((yield* store.list()).map((session) => String(session.id))).toEqual([
"ses_e",
"ses_a",
"ses_d",
"ses_c",
"ses_b",
"ses_z",
"ses_y",
])
expect((yield* store.list({ order: "asc" })).map((session) => String(session.id))).toEqual([
"ses_y",
"ses_z",
"ses_b",
"ses_c",
"ses_d",
"ses_a",
"ses_e",
])
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
{ order: "asc", direction: "next", ids: ["ses_d", "ses_a"] },
{ order: "asc", direction: "previous", ids: ["ses_z", "ses_b"] },
{ order: "desc", direction: "next", ids: ["ses_b", "ses_z"] },
{ order: "desc", direction: "previous", ids: ["ses_a", "ses_d"] },
]
yield* Effect.forEach(pages, (page) =>
Effect.gen(function* () {
const sessions = yield* store.list({
order: page.order,
limit: 2,
anchor: { id: Session.ID.make("ses_c"), time: 20, direction: page.direction },
})
expect(sessions.map((session) => String(session.id))).toEqual(page.ids)
}),
)
}),
)
it.effect("pages messages by durable sequence, not timestamp or ID, and scopes cursor lookup", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_messages")
const foreignID = Session.ID.make("ses_foreign")
const bus = yield* seedSessions([
{ id: sessionID, updated: 0 },
{ id: foreignID, updated: 0 },
])
const store = yield* SessionStore.Service
yield* Effect.forEach(
[
{ id: "evt_z", created: 300 },
{ id: "evt_b", created: 700 },
{ id: "evt_x", created: 100 },
{ id: "evt_c", created: 400 },
{ id: "evt_w", created: 200 },
{ id: "evt_a", created: 600 },
{ id: "evt_y", created: 500 },
],
(event, index) =>
bus.replay({
id: Event.ID.make(event.id),
created: event.created,
aggregateID: sessionID,
seq: index + 2,
type: Bus.versionedType(SessionEvent.Synthetic.type, 1),
data: { sessionID, text: event.id },
}),
)
yield* bus.publish(
SessionEvent.Synthetic,
{ sessionID: foreignID, text: "foreign" },
{
id: Event.ID.make("evt_foreign"),
},
)
expect((yield* store.messages({ sessionID })).map((message) => String(message.id))).toEqual([
"msg_y",
"msg_a",
"msg_w",
"msg_c",
"msg_x",
"msg_b",
"msg_z",
])
expect((yield* store.messages({ sessionID, order: "asc" })).map((message) => String(message.id))).toEqual([
"msg_z",
"msg_b",
"msg_x",
"msg_c",
"msg_w",
"msg_a",
"msg_y",
])
const pages: { order: "asc" | "desc"; direction: "next" | "previous"; ids: string[] }[] = [
{ order: "asc", direction: "next", ids: ["msg_w", "msg_a"] },
{ order: "asc", direction: "previous", ids: ["msg_b", "msg_x"] },
{ order: "desc", direction: "next", ids: ["msg_x", "msg_b"] },
{ order: "desc", direction: "previous", ids: ["msg_a", "msg_w"] },
]
yield* Effect.forEach(pages, (page) =>
Effect.gen(function* () {
const messages = yield* store.messages({
sessionID,
order: page.order,
limit: 2,
cursor: { id: SessionMessage.ID.make("msg_c"), direction: page.direction },
})
expect(messages.map((message) => String(message.id))).toEqual(page.ids)
}),
)
expect(yield* store.messages({ sessionID: Session.ID.make("ses_missing") })).toEqual([])
expect(
yield* store.messages({
sessionID,
cursor: { id: SessionMessage.ID.make("msg_missing"), direction: "next" },
}),
).toEqual([])
expect(
yield* store.messages({
sessionID,
order: "asc",
cursor: { id: SessionMessage.ID.make("msg_foreign"), direction: "next" },
}),
).toEqual([])
}),
)
})
+2 -2
View File
@@ -19,14 +19,14 @@ import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectNode],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
+4 -2
View File
@@ -12,10 +12,12 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const awaited: Session.ID[] = []
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const execution = Layer.mock(SessionExecution.Service, {
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
})
@@ -23,7 +25,7 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectNode],
[Project.node, projects],
[SessionExecution.node, execution],
],
),
-2
View File
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Session } from "@opencode-ai/core/session"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
@@ -127,7 +126,6 @@ test("Core reuses the canonical shared schemas", async () => {
[Session.ID, schemaSession.Session.ID],
[Session.Info, schemaSession.Session.Info],
[Session.ListAnchor, schemaSession.Session.ListAnchor],
[Session.ListInput, SessionStore.ListInput],
[coreSessionInbox.Delivery, SessionInbox.Delivery],
[coreSessionInbox.Item, SessionInbox.Item],
[coreSessionInbox.User, SessionInbox.User],
-38
View File
@@ -437,44 +437,6 @@ describe("ReadTool", () => {
}),
)
it.effect("accepts PNG candidates at the exact base64 limit and skips them one byte below", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(
Uint8Array.from({ length: 16 * 4 }, () => 255),
16,
1,
)
const content = {
uri: "file:///wide.png",
content: Buffer.from(source.get_bytes()).toString("base64"),
encoding: "base64" as const,
mime: "image/png",
}
source.free()
const image = yield* Image.Service
for (const [maxWidth, padding] of [
[4, "=="],
[5, "="],
[6, ""],
] as const) {
yield* image.transform((draft) => draft.configure({ maxWidth, maxBase64Bytes: 1_024 }))
const candidate = yield* image.normalize("wide.png", content)
expect(candidate.mime).toBe("image/png")
expect(candidate.content.match(/=*$/)?.[0]).toBe(padding)
yield* image.transform((draft) => draft.configure({ maxBase64Bytes: candidate.content.length }))
expect(yield* image.normalize("wide.png", content)).toEqual(candidate)
yield* image.transform((draft) => draft.configure({ maxBase64Bytes: candidate.content.length - 1 }))
const smaller = yield* image.normalize("wide.png", content)
expect(smaller.mime).toBe("image/png")
expect(smaller.content.length).toBeLessThan(candidate.content.length)
}
}),
)
it.effect("drops images that cannot fit max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
+10 -44
View File
@@ -31,7 +31,6 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
@@ -114,7 +113,6 @@ const executionNode = makeGlobalNode({
})
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
@@ -773,8 +771,6 @@ describe("ShellTool", () => {
sessionID,
action: "shell",
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
agent: toolIdentity.agent,
source: { type: "tool", messageID: toolIdentity.messageID, id: "call-shell" },
},
])
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
@@ -932,15 +928,7 @@ describe("ShellTool", () => {
Effect.andThen(
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled).toMatchObject({
status: "error",
error: { message: `Working directory is not a directory: ${workdir}` },
})
expect(assertions.map((input) => input.action)).toEqual(["shell"])
}),
),
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
@@ -976,26 +964,23 @@ describe("ShellTool", () => {
)
it.live(
"deduplicates external directory approvals across workdir and directory-change commands",
"approves an external directory used by a directory-change command",
() =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
const command = isWindows
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
: `cd '${outside.path}' && pwd`
return withSession(active.path, (registry) =>
Effect.forEach([{ command }, { command, workdir: outside.path }], (input) =>
Effect.gen(function* () {
reset()
const settled = yield* executeTool(registry, call(input, "call-external-cd"))
expect(settled).toMatchObject({ status: "completed" })
executeTool(registry, call({ command }, "call-external-cd")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
sessionID,
agent: toolIdentity.agent,
source: { type: "tool", messageID: toolIdentity.messageID, id: "call-external-cd" },
})
}),
),
@@ -1294,40 +1279,21 @@ describe("ShellTool", () => {
)
it.live(
"authorizes the hook-edited command and workdir and reports its timeout",
"returns a useful timeout outcome",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const timeout = isWindows ? 3_000 : 500
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* hooks.register("shell", "create.before", (invocation) =>
Effect.sync(() => {
invocation.command = timeoutOutputCommand
invocation.cwd = tmp.path
invocation.timeout = timeout
}),
)
return yield* executeTool(registry, call({ command: helloCommand, workdir: "missing", timeout: 60_000 }))
}),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.metadata).not.toHaveProperty("exit")
const content = settled.content?.[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") throw new Error("Expected text content")
expect(content.text).toContain("before timeout")
expect(content.text).toContain(`Command exceeded timeout of ${timeout} ms.`)
expect(settled.content?.[0]).toMatchObject(Expected.text(expect.stringContaining("before timeout")))
expect(settled.content?.[1]).toMatchObject(Expected.text(expect.stringContaining("Command timed out")))
expect(assertions.map((input) => input.action)).toEqual(["shell"])
expect(assertions[0]?.resources).toEqual(
isWindows ? [idleCommand] : ["printf 'before timeout'", idleCommand],
)
}),
),
)
-1
View File
@@ -92,7 +92,6 @@ const executionNode = makeGlobalNode({
})
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),

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