Compare commits

...
Author SHA1 Message Date
Kit Langton cbbb83ec1c refactor(core): reuse Markdown chunk byte counts 2026-08-28 23:51:02 -04:00
Kit Langton cf2c3a536d refactor(core): reuse catalog response digest (#46071) 2026-08-28 23:41:49 -04:00
opencode-agent[bot] 7852cecd72 chore: update nix node_modules hashes 2026-08-29 03:33:15 +00:00
Kit Langton 6cfffeb031 refactor(core): avoid encoding rejected image candidates (#46073) 2026-08-28 23:32:58 -04:00
Kit Langton 6e954f75ee refactor(core): isolate Session admission and controls (#46019)
Separate ID-bound Session policy from host routing. Bind Inbox and Location preparation dependencies at construction, preserve admission and execution semantics, and cover the extracted ownership contracts directly.
2026-08-28 23:22:25 -04:00
Kit Langton 0116a98371 refactor(tui): share app lifecycle test fixture
Reuse a file-local fixture for renderer, HTTP server, event stream, app startup, and teardown across lifecycle tests. Preserve scenario-specific handlers, configuration, deferred responses, and assertions while removing 187 lines of repeated setup.
2026-08-28 23:19:49 -04:00
Kit Langton a38cbd42aa refactor(core): isolate shell tool preparation
Name the tool-owned pre-spawn preparation boundary while preserving hook edits, permission ordering, directory validation, and effective timeout reporting. Strengthen the existing regression assertions.
2026-08-28 23:17:48 -04:00
Luke Parker 4ab31867c4 fix(app): reduce session-switch latency (#46044) 2026-08-29 13:16:53 +10:00
Luke Parker 51a082cea3 fix(core): release exited shell execution state (#46058) 2026-08-29 03:08:50 +00:00
108 changed files with 5656 additions and 2248 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
"x86_64-linux": "sha256-No3mCuG2tGQauX1HUpO+rebiWh+rrpSHUCC7rtXFu1s=",
"aarch64-linux": "sha256-8joWv1iDkc6TejEukGBEX0wW8DPs55wJEPY7+9+HDdM=",
"aarch64-darwin": "sha256-4MWGFQUIP1Ae4dujztTb4G8/uDJx2wiOoypcPSUWDbw=",
"x86_64-darwin": "sha256-BtvnraCJmVagtA3Iv+EbWodjFG74sTd2Purqgo7Wkr4="
}
}
+1
View File
@@ -1,5 +1,6 @@
src/assets/theme.css
e2e/test-results
e2e/performance/results/
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
@@ -1,5 +1,36 @@
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")
@@ -0,0 +1,135 @@
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,
)
}
@@ -0,0 +1,79 @@
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!)
}
})
}
+51 -4
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, 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.
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.
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,13 +81,60 @@ 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=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.
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`.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
```
**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:
@@ -111,7 +158,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 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.
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.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
+17 -7
View File
@@ -5,16 +5,20 @@ 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 = {}) => {
@@ -49,9 +53,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
},
{ auto: true },
],
page: async ({ page }, use, testInfo) => {
page: async ({ page, traceScope }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name)
const diagnostics = await observePerformancePage(page, name, traceScope)
try {
await use(page)
} finally {
@@ -75,25 +79,30 @@ function benchmarkName(testInfo: TestInfo) {
export { expect }
async function observePerformancePage(page: Page, name: string) {
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
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
}
@@ -130,6 +139,7 @@ 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,
+3 -1
View File
@@ -14,7 +14,6 @@ const categories = [
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
@@ -34,6 +33,9 @@ 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"]
: []),
@@ -0,0 +1,99 @@
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"
)
}
@@ -0,0 +1,61 @@
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)
@@ -0,0 +1,33 @@
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 }>
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
navigate: () => Promise<void>
},
) {
@@ -47,11 +47,19 @@ export async function measureNavigationMilestones(
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string) =>
const visible = (selector: string, text?: 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()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.top < innerHeight &&
rect.right > 0 &&
rect.left < innerWidth
)
})
const sample = () => {
if (!running || started === undefined) return
@@ -61,7 +69,9 @@ 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.visible === false
? !document.querySelector(milestone.selector)
: visible(milestone.selector, milestone.text),
]),
)
samples.push({
@@ -93,36 +103,46 @@ export async function measureNavigationMilestones(
}, 0)
})
}
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 },
)
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)
;(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 },
)
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 }
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
})
}
}
@@ -0,0 +1,91 @@
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,71 +1,129 @@
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { benchmark, benchmarkDiagnostics, expect } 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 = [
{ 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 },
]
{ 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" })
scenarios.forEach((scenario) => {
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
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
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),
})
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,
},
testInfo,
)
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
if (testInfo.repeatEachIndex === 0) {
await page.screenshot({ path: testInfo.outputPath("destination.png") })
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
}
})
})
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)
@@ -80,4 +138,20 @@ 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,66 +2,77 @@ import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
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}>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
await page.evaluate(() => {
document.querySelector("#destination")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
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"
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"
})
},
})
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,124 +14,132 @@ async function installSessionSwitchProbe(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
},
) {
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(() => {
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 = () => {
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 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)
})
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 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,
})
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 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 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)
}
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
// 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,
)
}
async function waitForStableSessionSwitch(page: Page) {
@@ -159,9 +167,17 @@ 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
})
return classifySessionSwitch(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
}
export async function measureSessionSwitch(
@@ -172,6 +188,7 @@ export async function measureSessionSwitch(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
switch: () => Promise<void>
},
@@ -185,6 +202,7 @@ export async function measureSessionSwitch(
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
})
}
}
@@ -0,0 +1,143 @@
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}.**
`
}
@@ -0,0 +1,114 @@
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 })
}
})
@@ -0,0 +1,99 @@
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())
}
})
}
@@ -0,0 +1,123 @@
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,6 +102,14 @@ 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 }) => {
@@ -288,11 +296,12 @@ 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/question/request")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/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,6 +355,14 @@ 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)
@@ -694,6 +702,7 @@ 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,10 +1,16 @@
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: {
@@ -60,7 +66,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: [],
recent: [{ providerID: "opencode-go", modelID: "go-model-1" }],
variant: {},
}),
)
@@ -81,11 +87,73 @@ 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")
})
+11 -8
View File
@@ -47,7 +47,6 @@ 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(
@@ -135,13 +134,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
const transport = createMockServerHandler(config)
page.on("close", () => void transport.dispose())
await page.route("**/api/**", async (route) => {
@@ -173,6 +166,16 @@ 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,6 +34,8 @@
"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,7 +150,6 @@ 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,16 +278,15 @@ export function createHomeSessionsController(home: HomeController) {
const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.data.session.remember(session)
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: connKey, sessionId: session.id })
return
}
ctx.projects.touch(directory)
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.
void startTransition(() => {
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
tabs.select(tab)
if (!options?.background) tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
if (!options?.background) ctx.projects.touch(directory)
})
},
archive: async (session: SessionInfo) => {
@@ -221,7 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
const current = () => {
const current = createMemo(() => {
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()
@@ -0,0 +1,117 @@
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 }
}
+9 -101
View File
@@ -8,6 +8,7 @@ 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() {
@@ -46,105 +47,12 @@ export function createSessionRequestModel() {
const id = params.id
return !!id && !data.session.get(id)?.parentID
}
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 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 moveToBackground = async () => {
if (!primary()) return
@@ -191,8 +99,8 @@ export function createSessionRequestModel() {
permissionRequest,
permissionResponding,
background: {
blocking: backgroundBlocking,
tasks: backgroundTasks,
blocking: background.blocking,
tasks: background.tasks,
move: moveToBackground,
},
decide,
@@ -5,18 +5,27 @@ import { createSessionResolution } from "./session-resolution"
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
let syncs = 0
const syncs = { session: 0, message: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs++
syncs.session++
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).toBe(0)
expect(syncs).toEqual({ session: 0, message: 0 })
dispose()
})
})
@@ -1,9 +1,12 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createMemo, createRenderEffect, 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> } & (
@@ -40,13 +43,17 @@ export function createSessionResolution<T>(
})
const [status, setStatus] = createSignal<Resolution<T>>()
createEffect(
// Start independent reads before constructing the selected view, including
// when its metadata is cached but its transcript has never been loaded.
createRenderEffect(
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,6 +79,17 @@ 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,6 +21,7 @@ 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"
@@ -316,6 +317,16 @@ 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} />
)
@@ -400,6 +411,38 @@ 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,
})
+2 -5
View File
@@ -13,7 +13,6 @@ 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(),
@@ -30,14 +29,12 @@ 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()
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)
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || !resource.loading
})
const more = () => {
const id = input.session.identity.sessionID()
+15 -24
View File
@@ -96,38 +96,29 @@ export function createTimelineProjection(input: {
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
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>()
rows().forEach((row, index) => {
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
result.set(row.userMessageID, 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)
})
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 { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey }
})
return {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey,
lastAssistantGroupKey: () => indexes().lastAssistantGroupKey,
messageByID: sessionMessageByID,
messageRowIndex,
messageLastRowIndex,
rowByKey,
messageRowIndex: () => indexes().messageRowIndex,
messageLastRowIndex: () => indexes().messageLastRowIndex,
rowByKey: () => indexes().rowByKey,
rows,
sessionMessageByID,
userContextByID,
+139 -65
View File
@@ -55,6 +55,10 @@ 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
}
@@ -75,17 +79,63 @@ export function createTimelineVirtualizer(input: Input) {
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [overscan, setOverscan] = createSignal(2)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const knownKeys = new Set(rows().map(TimelineRow.key))
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 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() {
@@ -93,10 +143,16 @@ export function createTimelineVirtualizer(input: Input) {
},
getScrollElement: () => listRoot() ?? null,
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) =>
observeElementOffsetReconnectAware(instance, callback, () => {
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (input.pinned()) virtualizer.scrollToEnd()
}),
settleColdBottom()
})
},
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
@@ -110,28 +166,17 @@ 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) return size
if (size !== undefined || coldPending) return size ?? fallbackItemSize
}
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() {
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)
}
return getItemKey()
},
get anchorTo() {
return input.pinned() ? "end" : "start"
@@ -145,16 +190,7 @@ export function createTimelineVirtualizer(input: Input) {
},
paddingEnd: 64,
get 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,
)
}
return rangeExtractor()
},
})
const resizeItem = virtualizer.resizeItem
@@ -166,7 +202,7 @@ export function createTimelineVirtualizer(input: Input) {
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if (virtualizer.itemSizeCache.get(key) === size) {
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
pendingSizes.delete(index)
return
}
@@ -178,12 +214,19 @@ 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.
@@ -217,41 +260,73 @@ export function createTimelineVirtualizer(input: Input) {
})
})
let settleFrame: number | undefined
let overscanFrame: number | undefined
let overscanTimer: number | undefined
const expandOverscan = () => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
// Let the visible rows paint before building the normal interaction buffer.
overscanTimer = window.setTimeout(() => {
overscanTimer = undefined
setOverscan(20)
}, 0)
})
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)
)
}
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleFrame = requestAnimationFrame(settleColdBottom)
return
}
settleFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
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)
settleColdBottom()
return
}
settleFrame = undefined
virtualContent?.style.removeProperty("visibility")
expandOverscan()
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
virtualContent.style.removeProperty("visibility")
})
}
onMount(() => {
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
if (!coldBottomMount) expandOverscan()
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()
})
let measuredSessionKey = input.sessionKey()
@@ -265,12 +340,14 @@ 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
@@ -278,13 +355,11 @@ export function createTimelineVirtualizer(input: Input) {
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
setOverscan(20)
}
const handleListTouchStart = (event: TouchEvent) => {
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
setOverscan(20)
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
@@ -301,7 +376,6 @@ export function createTimelineVirtualizer(input: Input) {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
setOverscan(20)
}
const releasePointer = () => {
pointerHeld = false
@@ -322,7 +396,6 @@ export function createTimelineVirtualizer(input: Input) {
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
setOverscan(20)
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
@@ -338,6 +411,7 @@ 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()
}
@@ -476,9 +550,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!)
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
+1 -2
View File
@@ -11,9 +11,8 @@ 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(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
+3 -2
View File
@@ -28,7 +28,6 @@ export function TabNavItem(props: {
onClose: () => void
onNavigate: () => void
active?: boolean
forceTruncate?: boolean
suppressNavigation?: boolean
dragging?: boolean
pressed?: boolean
@@ -98,11 +97,13 @@ export function TabNavItem(props: {
createEffect(() => {
title()
props.forceTruncate
props.active
props.orientation
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)
+1 -44
View File
@@ -1,6 +1,5 @@
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, For, onCleanup, 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"
@@ -26,7 +25,6 @@ function SessionTabSlot(props: {
id: string
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
fallbackTitle?: string
@@ -69,7 +67,6 @@ function SessionTabSlot(props: {
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
@@ -82,7 +79,6 @@ function SessionTabEntry(props: {
id: string
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
@@ -169,7 +165,6 @@ function SessionTabEntry(props: {
id={props.id}
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
orientation={props.orientation}
session={session()}
fallbackTitle={
@@ -238,19 +233,15 @@ 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)
@@ -281,38 +272,6 @@ 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"}
@@ -327,7 +286,6 @@ 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={[
@@ -398,7 +356,6 @@ 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)}
+1 -7
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import { createEffect, createMemo, createResource, Match, 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,8 +336,6 @@ 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"
@@ -382,8 +380,6 @@ export function Titlebar(props: {
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
@@ -424,8 +420,6 @@ 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" })
@@ -0,0 +1,196 @@
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,8 +15,10 @@ 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) => {
@@ -25,6 +27,12 @@ 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) })
@@ -51,7 +59,34 @@ const flush = async () => {
await Promise.resolve()
}
test("resolves an uncached session", async () => {
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 () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const current = createSessionResolution(
@@ -59,9 +94,10 @@ test("resolves an uncached session", async () => {
() => fixture.sessions,
)
expect(current()).toBeUndefined()
await flush()
expect(fixture.resolves).toEqual(["ses_a"])
fixture.messages.reject(new Error("message sync failed"))
await flush()
expect(current()).toBeUndefined()
fixture.settle("ses_a")
await flush()
@@ -82,12 +118,15 @@ 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()
@@ -139,6 +178,7 @@ 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()
@@ -167,6 +207,7 @@ 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()
@@ -197,6 +238,8 @@ 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,3 +198,41 @@ 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 }])
})
+9 -3
View File
@@ -78,9 +78,15 @@ 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 = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return { ...content, content: candidate, encoding: "base64" as const, mime }
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,
}
}
} finally {
resized.free()
+4
View File
@@ -45,6 +45,8 @@ 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"
@@ -92,6 +94,8 @@ const nodes = [
Form.node,
Generate.node,
SessionGenerateNode.node,
SessionPromptNode.node,
SessionRevertNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
+5 -4
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) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string, digest = bodyDigest(text)) {
yield* kv.set(key, { updatedAt: Date.now(), digest, body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
@@ -681,12 +681,13 @@ 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 === bodyDigest(text)) return
if (!force && stored?.digest === digest) return
yield* decodeCatalog(text)
yield* writeCache(text)
yield* writeCache(text, digest)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
+1
View File
@@ -62,6 +62,7 @@ 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>
}
+79 -615
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Fiber, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -9,7 +9,6 @@ import { Workspace } from "@opencode-ai/schema/workspace"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
@@ -18,17 +17,30 @@ import { SessionMessageTable, SessionTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
import { Agent } from "@opencode-ai/schema/agent"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import { upsertProject } from "./project/sql.js"
import path from "path"
import { fromRow } from "./session/info.js"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { SessionExecution } from "./session/execution.js"
import { SessionModelTransport } from "./session/model-transport.js"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
import {
AttachmentError,
BusyError,
CompactionConflictError,
ForkEmptyError,
InboxConflictError,
MessageDecodeError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
SyntheticConflictError,
} from "./session/error.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
@@ -36,22 +48,15 @@ import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
import { SessionRevert } from "./session/revert.js"
import { Session } from "@opencode-ai/schema/session"
import { Session } from "./session/session.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event"
import { Skill } from "./skill.js"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Shell } from "./shell.js"
import { Global } from "@opencode-ai/util/global"
import { ShellResult } from "./shell/result.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
import { InstructionEntry } from "./session/instruction-entry.js"
@@ -102,73 +107,29 @@ type CreateBaseInput = {
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
delivery?: SessionInbox.Delivery
}
type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: SessionSchema.ID }
type ForkInput = {
sessionID: SessionSchema.ID
boundary: Session.ForkRequestBoundary
boundary: SessionSchema.ForkRequestBoundary
}
export { MessageDecodeError, NotFoundError }
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class SyntheticConflictError extends Schema.TaggedError<SyntheticConflictError>()(
"Session.SyntheticConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class AttachmentError extends Schema.TaggedError<AttachmentError>()("Session.AttachmentError", {
uri: Schema.String,
message: Schema.String,
}) {}
export class CompactionConflictError extends Schema.TaggedError<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
"Session.MessageNotAssistantError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
"Session.MessageIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
"Session.MessageToolIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
export {
AttachmentError,
BusyError,
CompactionConflictError,
InboxConflictError,
MessageDecodeError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
SyntheticConflictError,
}
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
export class DestinationNotFoundError extends Schema.TaggedError<DestinationNotFoundError>()(
"Session.DestinationNotFoundError",
@@ -184,8 +145,6 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
"Session.DestinationUnavailableError",
{ directory: AbsolutePath },
) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -215,19 +174,9 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Info | undefined>
readonly updateMessage: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
readonly content: readonly SessionMessage.AssistantContent[]
}) => Effect.Effect<
SessionMessage.Assistant,
| NotFoundError
| MessageNotFoundError
| BusyError
| MessageNotAssistantError
| MessageIncompleteError
| MessageToolIncompleteError
>
readonly updateMessage: (
input: Parameters<Session.Handle["updateMessage"]>[0] & { readonly sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["updateMessage"]>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
@@ -264,17 +213,9 @@ export interface Interface {
void,
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
@@ -289,11 +230,9 @@ export interface Interface {
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (input: {
id?: Event.ID
sessionID: SessionSchema.ID
command: string
}) => Effect.Effect<void, NotFoundError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -308,21 +247,15 @@ export interface Interface {
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.Synthetic, NotFoundError | SyntheticConflictError>
readonly synthetic: (
input: Parameters<Session.Handle["synthetic"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["synthetic"]>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Session.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
}) => Effect.Effect<SessionSchema.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
@@ -346,6 +279,8 @@ const layer = Layer.effect(
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const sessions = yield* Session.make((ref) => locations.get(ref))
const admission = yield* SessionInbox.Service
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
@@ -357,30 +292,6 @@ const layer = Layer.effect(
)
})
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) {
yield* result.get(input.sessionID)
return yield* new InboxConflictError(input)
})
const mutatePending = (
input: InboxItemRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
@@ -393,7 +304,6 @@ const layer = Layer.effect(
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
const projected = yield* bus
.publish(
SessionEvent.Created,
@@ -481,28 +391,13 @@ const layer = Layer.effect(
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
get: (sessionID) => sessions.forSession(sessionID).get(),
environment: Effect.fn("Session.environment")(function* (input) {
yield* result.get(input.sessionID)
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
return yield* environments.get(input.sessionID)
}),
view: Effect.fn("Session.view")(function* (input) {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || input.idle > row.idle || (row.viewed !== null && row.viewed >= input.idle))
return yield* Effect.void
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID, idle: input.idle })
}),
view: (input) => sessions.forSession(input.sessionID).view(input),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
@@ -592,44 +487,16 @@ const layer = Layer.effect(
SessionHistory.decodeMessageRow,
)
}),
message: Effect.fn("Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
updateMessage: Effect.fn("Session.updateMessage")(function* (input) {
const ref = { sessionID: input.sessionID, messageID: input.messageID }
yield* result.get(ref.sessionID)
if ((yield* execution.active).has(ref.sessionID)) return yield* new BusyError({ sessionID: ref.sessionID })
const message = yield* result.message(ref)
if (!message) return yield* new MessageNotFoundError(ref)
if (message.type !== "assistant") return yield* new MessageNotAssistantError(ref)
if (!message.time.completed) return yield* new MessageIncompleteError(ref)
if (
input.content.some(
(content) =>
content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running"),
)
)
return yield* new MessageToolIncompleteError(ref)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
...ref,
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
})
const updated = yield* result.message(ref)
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
return updated
}),
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
updateMessage: (input) => sessions.forSession(input.sessionID).updateMessage(input),
context: Effect.fn("Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
inbox: Effect.fn("Session.inbox")(function* (sessionID) {
yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID)
}),
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
queueInbox: (input) => sessions.forSession(input.sessionID).queueInbox(input.inboxID),
log: (input) =>
Stream.unwrap(
result
@@ -641,47 +508,7 @@ const layer = Layer.effect(
Bus.isSynced(item) || isDurableSessionEvent(item),
),
),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const session = yield* result.get(input.sessionID)
const messageID = input.id ?? SessionMessage.ID.create()
const admitted = yield* Effect.gen(function* () {
const existing = yield* SessionInbox.reconcile(db, {
id: messageID,
sessionID: session.id,
delivery: input.delivery ?? "steer",
})
if (existing) return existing
const item = yield* restore(
preparePrompt(input, messageID).pipe(
Effect.provide(locations.get(session.location)),
Effect.provideService(FSUtil.Service, fs),
),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
return yield* SessionInbox.admit(db, bus, {
id: messageID,
sessionID: session.id,
item,
})
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
// First admission wins: same-session reuse is idempotent and ignores the
// retried payload, metadata, and delivery mode.
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -709,65 +536,7 @@ const layer = Layer.effect(
},
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(locations.get(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID, background: true },
})
.pipe(
Effect.tapError((error) =>
result.synthetic({
sessionID: input.sessionID,
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: terminal.info,
output: preview,
})
yield* result
.synthetic({
...ShellResult.userNotification(terminal),
sessionID: input.sessionID,
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
}),
shell: (input) => sessions.forSession(input.sessionID).shell(input),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -788,35 +557,9 @@ const layer = Layer.effect(
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.Renamed, {
sessionID: input.sessionID,
title: input.title,
})
}),
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
move: Effect.fn("Session.move")(function* (input) {
const current = yield* result.get(input.sessionID)
const value = input.directory.trim()
@@ -842,7 +585,6 @@ const layer = Layer.effect(
)
}),
)
yield* persistProject(project)
const item = SessionInbox.Item.make({
type: "move",
payload,
@@ -862,36 +604,19 @@ const layer = Layer.effect(
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
}
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
yield* admission
.admit({
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
.pipe(Effect.orDie)
}),
)
yield* execution.wake(input.sessionID)
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionInbox.admitCompaction(db, bus, {
id: inputID,
sessionID: input.sessionID,
delivery: input.delivery ?? "steer",
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
yield* execution.wake(input.sessionID)
return admitted
}),
wait: Effect.fn("Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.awaitIdle(sessionID)
}),
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
active: execution.active,
background: Effect.fn("Session.background")(function* (sessionID) {
yield* result.get(sessionID)
@@ -911,78 +636,13 @@ const layer = Layer.effect(
})
.pipe(Effect.catchTag("Session.SyntheticConflictError", Effect.die))
}),
resume: Effect.fn("Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
synthetic: Effect.fn("Session.synthetic")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
type: "synthetic",
payload: {
text: input.text,
description: input.description,
metadata: input.metadata,
},
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
id: inputID,
sessionID: input.sessionID,
item: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
// First admission wins: same-session reuse is idempotent and ignores the
// retried payload, metadata, and delivery mode.
if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
yield* execution.wake(input.sessionID)
return admitted
}),
),
),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(execution.interrupt(sessionID, options)),
),
resume: (sessionID) => sessions.forSession(sessionID).resume(),
synthetic: (input) => sessions.forSession(input.sessionID).synthetic(input),
interrupt: (sessionID, options) => sessions.forSession(sessionID).interrupt(options),
revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
if ((yield* execution.active).has(input.sessionID))
return yield* new BusyError({ sessionID: input.sessionID })
return yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(locations.get(session.location)))
}),
clear: Effect.fn("Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
const revert = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* SessionRevert.clear(session).pipe(Effect.provideService(Bus.Service, bus))
}).pipe(Effect.provide(locations.get(session.location)))
yield* execution.wake(sessionID)
return revert
}),
commit: Effect.fn("Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
if ((yield* execution.active).has(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
}),
stage: (input) => sessions.forSession(input.sessionID).revert.stage(input),
clear: (sessionID) => sessions.forSession(sessionID).revert.clear(),
commit: (sessionID) => sessions.forSession(sessionID).revert.commit(),
},
})
@@ -990,203 +650,6 @@ const layer = Layer.effect(
}),
)
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
request: Parameters<Interface["prompt"]>[0],
messageID: SessionMessage.ID,
) {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID,
prompt: structuredClone({
text: request.text,
files: request.files?.slice(),
agents: request.agents?.slice(),
skills: request.skills?.slice(),
}),
metadata: structuredClone(request.metadata),
delivery: request.delivery ?? "steer",
})
const input = event.prompt
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* Skill.Service
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return SessionInbox.Item.make({
type: "user",
payload: {
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
},
delivery: event.delivery,
})
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const materializeAttachment = Effect.fn("Session.materializeAttachment")(function* (
fs: FSUtil.Interface,
input: PromptInput.FileAttachment,
) {
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
name: undefined,
mime: undefined,
}
: yield* readFileAttachment(fs, input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
mention: input.mention,
})
})
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: string,
mime: string,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const service = yield* Image.Service
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* service.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
)
return { data: Base64.make(normalized.content), mime: normalized.mime }
})
const readFileAttachment = Effect.fn("Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
})
if (url.protocol !== "file:")
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
const target = yield* Effect.try({
try: () => {
url.search = ""
url.hash = ""
return fileURLToPath(url)
},
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
})
const info = yield* fs
.stat(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") {
const entries = yield* fs
.readDirectoryEntries(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
const bytes = yield* fs
.readFile(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
})
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
if (comma === -1) throw new Error("Invalid data URL")
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
return Buffer.from(decodeURIComponent(payload))
const bytes = Buffer.from(payload, "base64")
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
function positiveInt(value: string | null) {
if (value === null) return
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
export const node = makeGlobalNode({
service: Service,
layer,
@@ -1198,6 +661,7 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
SessionInbox.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
+69
View File
@@ -2,6 +2,7 @@ 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"
@@ -10,6 +11,35 @@ 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,
}) {
@@ -52,3 +82,42 @@ 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,6 +18,8 @@ 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. */
@@ -142,6 +144,7 @@ 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")
@@ -178,6 +181,7 @@ 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.active).has(recovery.childSessionID)) return
if (yield* execution.isActive(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
+143 -111
View File
@@ -1,7 +1,8 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import {
Compaction,
CompactionPayload,
@@ -15,7 +16,7 @@ import {
User,
UserPayload,
} from "@opencode-ai/schema/session-inbox"
import type { Database } from "../database/database.js"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
@@ -65,6 +66,13 @@ 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),
@@ -116,7 +124,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* Effect.die(new LifecycleConflict({ id }))
return yield* 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")
@@ -131,89 +139,140 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
type: "synthetic",
payload: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
return yield* new LifecycleConflict({ id })
})
/** Reconciles pending or delivered work without preparing a new admission payload. */
export const reconcile = Effect.fn("SessionInbox.reconcile")(function* (
db: DatabaseService,
request: {
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: {
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 })
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
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery)
})
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
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),
),
),
),
}) {
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,
}
})
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 layer = Layer.effect(Service, make())
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
db: DatabaseService,
@@ -392,39 +451,11 @@ 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)
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",
}),
),
)
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))),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
@@ -447,6 +478,7 @@ 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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,231 @@
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
@@ -0,0 +1,15 @@
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],
})
+76 -63
View File
@@ -1,28 +1,97 @@
export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Context, Effect, Layer, 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 class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export { MessageNotFoundError }
interface BoundaryInput {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
}
const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
const db = (yield* Database.Service).db
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 boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -53,59 +122,3 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
}
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,
})
})
+5 -1
View File
@@ -7,6 +7,8 @@ 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. */
@@ -112,6 +114,8 @@ 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)
@@ -166,5 +170,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())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
})
+416
View File
@@ -0,0 +1,416 @@
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
+5 -3
View File
@@ -369,9 +369,11 @@ const layer = () =>
if (!oldest) break
yield* removeCommand(oldest)
}
// 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)
// 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)
})
command.timeout = (duration) =>
+1 -1
View File
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
const remaining = limit - outputBytes
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
output.push(next)
outputBytes += encoder.encode(next).byteLength
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
last = next.at(-1) ?? last
}
const appendRaw = (value: string) => {
+53 -50
View File
@@ -2,6 +2,8 @@ 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"
@@ -107,6 +109,56 @@ 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,
@@ -151,11 +203,6 @@ 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(
@@ -168,51 +215,7 @@ export const Plugin = {
},
(invocation) =>
Effect.gen(function* () {
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}`))
finalTimeout = yield* prepare(invocation, context)
}),
)
yield* context.progress({ shellID: info.id })
+32 -10
View File
@@ -1,18 +1,40 @@
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 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>,
// 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>,
)
}),
),
)
deps: [Bus.node, FSUtil.node],
})
+21 -8
View File
@@ -1,11 +1,24 @@
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 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 }),
}),
)
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],
})
+12 -2
View File
@@ -297,7 +297,10 @@ 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) })
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -387,7 +390,10 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -440,6 +446,10 @@ 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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } 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, globalProjectLayer],
[Project.node, globalProjectNode],
[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 { promptLocationLayer } from "./fixture/prompt-location"
import { globalProjectLayer } from "./lib/project"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } 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, globalProjectLayer],
[LocationServiceMap.node, promptLocationLayer],
[Project.node, globalProjectNode],
[LocationServiceMap.node, promptLocationNode],
[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, promptLocationLayer],
[LocationServiceMap.node, promptLocationNode],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
+13 -3
View File
@@ -26,7 +26,9 @@ import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, SessionInbox.node, Job.node, KV.node, Session.node]),
),
)
describe("SessionExecution lifecycle", () => {
@@ -92,6 +94,8 @@ 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)
@@ -99,17 +103,22 @@ 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)
}),
)
@@ -146,6 +155,7 @@ describe("SessionExecution lifecycle", () => {
expect(yield* execution.interrupt(sessionID)).toBeFalse()
expect(yield* execution.active).not.toContain(sessionID)
expect(yield* execution.isActive(sessionID)).toBe(false)
}),
)
@@ -897,7 +907,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 bus = yield* Bus.Service
const admission = yield* SessionInbox.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const parent = Session.ID.make("ses_completion_conflict_parent")
@@ -920,7 +930,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* SessionInbox.admit(database.db, bus, {
yield* admission.admit({
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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } from "./lib/project"
import { executeTool, registerToolPlugin } from "./lib/tool"
const readToolNode = makeLocationNode({
@@ -74,7 +74,7 @@ const testLayer = AppNodeBuilder.build(
Image.node,
]),
[
[Project.node, globalProjectLayer],
[Project.node, globalProjectNode],
[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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } 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, globalProjectLayer],
[Project.node, globalProjectNode],
[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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } from "./lib/project"
const active = new Set<Session.ID>()
const it = testEffect(
@@ -31,13 +31,14 @@ const it = testEffect(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[Project.node, globalProjectNode],
[
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),
@@ -212,16 +213,53 @@ describe("Session.updateMessage", () => {
)
yield* complete(bus, created.id, messageID)
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 }))
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)
}),
)
+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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectLayer],
[Project.node, globalProjectNode],
[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, globalProjectLayer],
[Project.node, globalProjectNode],
[SessionExecution.node, SessionExecution.noopLayer],
[LocationServiceMap.node, unavailableLocations],
],
+976
View File
@@ -0,0 +1,976 @@
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 -3
View File
@@ -32,7 +32,7 @@ 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]), [
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
@@ -87,8 +87,9 @@ 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* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
yield* inbox.admitCompaction({ id: inputID, sessionID, delivery: "queue" })
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
@@ -290,8 +291,9 @@ describe("SessionProjector", () => {
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* SessionInbox.admit(db, bus, {
const admitted = yield* inbox.admit({
id,
sessionID,
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
+60 -30
View File
@@ -7,8 +7,11 @@ 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"
@@ -17,7 +20,9 @@ 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"
@@ -28,6 +33,7 @@ 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"
@@ -40,6 +46,7 @@ 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)
@@ -57,37 +64,60 @@ const execution = Layer.succeed(
awaitIdle: () => Effect.void,
}),
)
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>,
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>,
)
}),
),
)
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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } from "./lib/project"
import { tmpdirScoped } from "./fixture/tmpdir"
const closed: Session.ID[] = []
@@ -39,7 +39,7 @@ const it = testEffect(
LocationServiceMap.node,
]),
[
[Project.node, globalProjectLayer],
[Project.node, globalProjectNode],
[SessionExecution.node, SessionExecution.noopLayer],
[SessionModelTransport.node, transport],
],
@@ -18,6 +18,7 @@ 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"
@@ -60,6 +61,9 @@ 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,22 +73,30 @@ 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)
}),
)
@@ -105,10 +113,12 @@ 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)
}),
)
@@ -612,6 +622,7 @@ 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")
@@ -621,6 +632,7 @@ describe("SessionRunCoordinator", () => {
expect(settled).toEqual(["user"])
expect(yield* coordinator.active).toEqual(new Set())
expect(yield* coordinator.isActive("session")).toBe(false)
}),
)
@@ -636,6 +648,7 @@ 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.
@@ -645,6 +658,7 @@ 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 { promptLocationLayer } from "./fixture/prompt-location"
import { promptLocationNode } from "./fixture/prompt-location"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -125,6 +125,7 @@ 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),
@@ -155,7 +156,7 @@ const testLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
]),
[
[Bus.node, Bus.configured({ persist: true })],
[LocationServiceMap.node, promptLocationLayer],
[LocationServiceMap.node, promptLocationNode],
[LayerNodePlatform.llmClient, llmClient],
[Permission.node, permission],
[Catalog.node, promptCatalog],
+16 -12
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 { promptLocationLayer } from "./fixture/prompt-location"
import { promptLocationNode } 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,6 +447,7 @@ 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),
@@ -461,6 +462,7 @@ const layer = Layer.unwrap(
Form.node,
SessionProjector.node,
SessionStore.node,
SessionInbox.node,
Agent.node,
Catalog.node,
Tool.node,
@@ -484,7 +486,7 @@ const layer = Layer.unwrap(
[
...replacements,
[Bus.node, Bus.configured({ persist: true })],
[LocationServiceMap.node, promptLocationLayer],
[LocationServiceMap.node, promptLocationNode],
[Catalog.node, promptCatalog],
[SessionExecution.node, execution],
],
@@ -516,6 +518,7 @@ 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
@@ -547,6 +550,7 @@ const setup = Effect.gen(function* () {
return Object.assign(state, {
db,
bus,
sessionInbox,
session,
llm,
requests: llm.requests,
@@ -1387,12 +1391,12 @@ describe("SessionRunnerLLM", () => {
s.systemLoadHook = Effect.sync(() => {
reads++
})
const compaction = yield* SessionInbox.admitCompaction(s.db, s.bus, {
const compaction = yield* s.sessionInbox.admitCompaction({
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* SessionInbox.admit(s.db, s.bus, {
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -1420,7 +1424,7 @@ describe("SessionRunnerLLM", () => {
scenario("delivers a queued move atomically at the idle boundary", function* (s) {
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(s.db, s.bus, {
yield* s.sessionInbox.admit({
id: inboxID,
sessionID,
item: {
@@ -1456,7 +1460,7 @@ describe("SessionRunnerLLM", () => {
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* SessionInbox.admit(s.db, s.bus, {
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -1488,7 +1492,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* SessionInbox.admit(s.db, s.bus, {
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -1521,12 +1525,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* SessionInbox.admitCompaction(s.db, s.bus, {
const compaction = yield* s.sessionInbox.admitCompaction({
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* SessionInbox.admit(s.db, s.bus, {
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -3207,7 +3211,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
yield* SessionInbox.admit(s.db, s.bus, {
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
@@ -3319,7 +3323,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* SessionInbox.admitCompaction(s.db, s.bus, {
const compaction = yield* s.sessionInbox.admitCompaction({
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
@@ -3340,7 +3344,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* SessionInbox.admitCompaction(s.db, s.bus, {
const compaction = yield* s.sessionInbox.admitCompaction({
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
+1
View File
@@ -42,6 +42,7 @@ 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,
+34 -20
View File
@@ -4,7 +4,10 @@ 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"
@@ -15,16 +18,15 @@ 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"),
@@ -32,29 +34,41 @@ const info = Skill.Info.make({
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
})
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>,
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>,
)
}),
),
)
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, projects],
[Project.node, globalProjectNode],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
+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 { globalProjectLayer } from "./lib/project"
import { globalProjectNode } 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, globalProjectLayer],
[Project.node, globalProjectNode],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
+2 -4
View File
@@ -12,12 +12,10 @@ 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)),
})
@@ -25,7 +23,7 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, projects],
[Project.node, globalProjectNode],
[SessionExecution.node, execution],
],
),
+38
View File
@@ -437,6 +437,44 @@ 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="
+44 -10
View File
@@ -31,6 +31,7 @@ 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"
@@ -113,6 +114,7 @@ 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),
@@ -771,6 +773,8 @@ 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 *"])
@@ -928,7 +932,15 @@ describe("ShellTool", () => {
Effect.andThen(
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
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"])
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
@@ -964,23 +976,26 @@ describe("ShellTool", () => {
)
it.live(
"approves an external directory used by a directory-change command",
"deduplicates external directory approvals across workdir and directory-change commands",
() =>
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) =>
executeTool(registry, call({ command }, "call-external-cd")),
).pipe(
Effect.andThen(
Effect.sync(() => {
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" })
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" },
})
}),
),
@@ -1279,21 +1294,40 @@ describe("ShellTool", () => {
)
it.live(
"returns a useful timeout outcome",
"authorizes the hook-edited command and workdir and reports its timeout",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const timeout = isWindows ? 3_000 : 500
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
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 }))
}),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.metadata).not.toHaveProperty("exit")
expect(settled.content?.[0]).toMatchObject(Expected.text(expect.stringContaining("before timeout")))
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?.[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,6 +92,7 @@ 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),
+9
View File
@@ -128,6 +128,15 @@ describe("WebFetchTool helpers", () => {
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
})
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
expect(truncated).toBe(fitting)
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
@@ -26,6 +26,7 @@ it.live("updates completed assistant message content through the session HTTP AP
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.sync(() => state.active),
isActive: (sessionID) => Effect.sync(() => state.active.has(sessionID)),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
@@ -0,0 +1,41 @@
import { render } from "solid-js/web"
import { createStore } from "solid-js/store"
import { BasicTool } from "../src/components/basic-tool"
export { getCachedMarkdown } from "../src/components/markdown-cache"
export function mountBasicTool() {
const host = document.createElement("div")
host.dataset.testid = "basic-tool-fixture"
document.body.appendChild(host)
render(() => {
const [state, setState] = createStore({ label: "Initial title", titles: 0, details: 0 })
function Title() {
// Count construction, including JSX created by unused trigger getter reads.
setState("titles", (value) => value + 1)
return <span title={state.label}>{state.label}</span>
}
function Details() {
setState("details", (value) => value + 1)
return <p>Tool details</p>
}
return (
<>
<output data-testid="trigger-constructions">{state.titles}</output>
<output data-testid="detail-mounts">{state.details}</output>
<input
aria-label="Trigger label"
value={state.label}
onInput={(event) => setState("label", event.currentTarget.value)}
/>
<BasicTool icon="glasses" hasContent trigger={<Title />}>
<Details />
</BasicTool>
<BasicTool icon="glasses" trigger={{ title: state.label, subtitle: "Tool subtitle", args: ["path=src"] }} />
<BasicTool icon="glasses" trigger={(open) => <span>Function: {open() ? "open" : "closed"}</span>}>
<p>Function details</p>
</BasicTool>
</>
)
}, host)
}
@@ -0,0 +1,60 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./basic-tool.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story("does not render completed reasoning until it is opened", async ({ mount, page }) => {
const root = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", mode: "compact", text: "Response after reasoning" },
})
const reasoning = root.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')
const trigger = reasoning.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(reasoning.locator('[data-component="markdown"]')).toHaveCount(0)
const cached = () =>
page.evaluate(async (fixture) => {
const { getCachedMarkdown } = await import(fixture)
return !!getCachedMarkdown("msg_projection_assistant:reasoning:0:0:full")
}, fixture)
expect(await cached()).toBe(false)
await trigger.click()
await expect(
reasoning.getByText("I will inspect the timeline before changing its state.", { exact: true }),
).toBeVisible()
expect(await cached()).toBe(true)
})
story("constructs declared tool details lazily and keeps trigger contracts reactive", async ({ mount, page }) => {
await mount("components-markdown--compact-result")
await page.evaluate(async (fixture) => {
const { mountBasicTool } = await import(fixture)
mountBasicTool()
}, fixture)
const root = page.getByTestId("basic-tool-fixture")
await expect(root.getByRole("button", { name: "Initial title", exact: true })).toHaveAttribute(
"aria-expanded",
"false",
)
await expect(root.getByTestId("detail-mounts")).toHaveText("0")
await expect(root.getByTestId("trigger-constructions")).toHaveText("1")
await root.getByLabel("Trigger label").fill("Updated title")
await expect(root.getByTitle("Updated title", { exact: true })).toHaveText("Updated title")
await expect(root.getByTestId("trigger-constructions")).toHaveText("1")
await expect(root.getByRole("button", { name: /Tool subtitle/ })).toHaveAccessibleName(
/Updated title\s*Tool subtitle\s*path=src/,
)
await root.getByRole("button", { name: "Updated title", exact: true }).click()
await expect(root.getByText("Tool details", { exact: true })).toBeVisible()
await expect(root.getByTestId("detail-mounts")).toHaveText("1")
await root.getByRole("button", { name: "Function: closed", exact: true }).click()
await expect(root.getByText("Function details", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Function: open", exact: true }).click()
await expect(root.getByText("Function details", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Function: closed", exact: true })).toHaveAttribute(
"aria-expanded",
"false",
)
await expect(root.getByTestId("trigger-constructions")).toHaveText("1")
})
@@ -6,7 +6,12 @@ import { MarkdownProvider } from "../src/context/markdown"
import { OpenCode } from "@opencode-ai/client/promise"
import { readLocalImage } from "../../app/src/runtime/server/image"
export { sanitizeMarkdown } from "../src/components/markdown-cache"
export {
getCachedMarkdown,
renderCachedMarkdown,
sanitizeMarkdown,
touchCachedMarkdown,
} from "../src/components/markdown-cache"
export { renderMermaidSvg } from "../src/components/markdown-mermaid"
export async function mountMarkdown(options: {
@@ -13,6 +13,27 @@ story.beforeEach(async ({ mount }) => {
await expect(root.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
})
story("renders small completed Markdown immediately without skipping sanitization", async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
text: '**Small response**\n\n<img src="missing" onerror="alert(1)"><script>alert(2)</script>',
})
const markdown = document.querySelector('[data-testid="markdown-fixture"] [data-component="markdown"]')!
return {
ready: markdown.hasAttribute("data-markdown-ready"),
bold: markdown.querySelector("strong")?.textContent,
unsafe: markdown.querySelectorAll("script, [onerror]").length,
}
}, fixture)
expect(result).toEqual({ ready: true, bold: "Small response", unsafe: 0 })
const harness = page.getByTestId("markdown-fixture")
await harness.getByLabel("Markdown text").fill("```ts\nconst value = 42\n```")
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(harness.locator("pre code")).toHaveText("const value = 42")
await expect(harness.locator("pre.shiki")).toBeVisible()
})
story("sanitizes raw HTML while preserving supported Markdown markup", async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { sanitizeMarkdown } = await import(fixture)
@@ -132,6 +153,49 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
})
story("shares in-flight Markdown rendering without overwriting a reclaimed cache entry", async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { getCachedMarkdown, renderCachedMarkdown } = await import(fixture)
const raw = "**Shared result**"
const first = renderCachedMarkdown({ raw, src: raw }, "in-flight")
const second = renderCachedMarkdown({ raw, src: raw }, "in-flight")
const [left, right] = await Promise.all([first, second])
const abandoned = renderCachedMarkdown({ raw: "abandoned", src: "abandoned" }, "in-flight")
await renderCachedMarkdown({ raw, src: raw }, "in-flight")
await abandoned
return {
shared: left === right,
html: left.html,
cached: getCachedMarkdown("in-flight") === left,
}
}, fixture)
expect(result).toMatchObject({ shared: true, cached: true })
expect(result.html).toContain("<strong>Shared result</strong>")
})
story("keeps a reopened cached answer recent under cache pressure", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown, touchCachedMarkdown } = await import(fixture)
await mountMarkdown({ text: "**Cached answer**", cached: true })
Array.from({ length: 199 }, (_, index) =>
touchCachedMarkdown(`filler-${index}`, { raw: "filler", hash: "filler", html: "<p>filler</p>" }),
)
}, fixture)
const harness = page.getByTestId("markdown-fixture")
const markdown = harness.locator('[data-component="markdown"]')
await expect(markdown.locator("strong")).toHaveText("Cached answer")
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown).toHaveCount(0)
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown.locator("strong")).toHaveText("Cached answer")
const cached = await page.evaluate(async (fixture) => {
const { getCachedMarkdown, touchCachedMarkdown } = await import(fixture)
touchCachedMarkdown("newest", { raw: "newest", hash: "newest", html: "<p>newest</p>" })
return getCachedMarkdown("markdown-test:0:full")?.raw
}, fixture)
expect(cached).toBe("**Cached answer**")
})
story("renders cached Mermaid blocks and falls back to code for invalid diagrams", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
@@ -1,4 +1,16 @@
import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js"
import {
createEffect,
createMemo,
For,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
type Accessor,
type JSX,
} from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { createStore } from "solid-js/store"
@@ -26,6 +38,8 @@ export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element)
children?: JSX.Element
/** Declare known content without constructing lazy JSX to test its presence. */
hasContent?: boolean
status?: string
hideDetails?: boolean
defaultOpen?: boolean
@@ -93,8 +107,15 @@ export function BasicTool(props: BasicToolProps) {
const open = () => props.open ?? state.open
const ready = () => state.ready
const pending = () => props.status === "streaming" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined
const hasChildren = () => props.hasContent ?? (props.defer ? "children" in props : props.children)
const triggerContent = createMemo(() => {
const value = props.trigger
return typeof value === "function" ? value(open) : value
})
const triggerTitle = createMemo(() => {
const value = triggerContent()
return isTriggerTitle(value) ? value : undefined
})
let cancelReady: (() => void) | undefined
@@ -193,8 +214,7 @@ export function BasicTool(props: BasicToolProps) {
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
<Match when={triggerTitle()}>
{(title) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
@@ -246,7 +266,7 @@ export function BasicTool(props: BasicToolProps) {
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
<Match when={true}>{triggerContent() as JSX.Element}</Match>
</Switch>
</div>
</div>
@@ -1,4 +1,5 @@
import { checksum } from "@opencode-ai/util/encode"
import { parseSmallMarkdown } from "@opencode-ai/ui/context/marked-base"
import DOMPurify from "dompurify"
import { parseMarkdown } from "./markdown-worker"
import { localImagePath } from "./markdown-image"
@@ -11,6 +12,7 @@ export type MarkdownCacheEntry = {
const max = 200
const cache = new Map<string, MarkdownCacheEntry>()
const pending = new Map<string, { raw: string; promise: Promise<MarkdownCacheEntry> }>()
// Mermaid registers hooks on the shared instance that overwrite link attributes.
const purifier = typeof window !== "undefined" ? DOMPurify(window) : DOMPurify
const config = {
@@ -66,17 +68,55 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
}
export async function preloadMarkdown(text: string, cacheKey: string) {
const block = { raw: text, src: text }
const key = `${cacheKey}:0:full`
const cached = getCachedMarkdown(key)
if (cached?.raw === text) {
if (getReadyMarkdown(block, key)) return
await renderCachedMarkdown(block, key)
}
export function getReadyMarkdown(block: { raw: string; src: string }, key?: string) {
const cached = key ? getCachedMarkdown(key) : undefined
if (key && cached?.raw === block.raw) {
pending.delete(key)
touchCachedMarkdown(key, cached)
return cached
}
if (!purifier.isSupported) return
try {
const html = parseSmallMarkdown(block.src)
if (html === undefined) return
const hash = checksum(block.raw)
const result = { raw: block.raw, hash: hash ?? "", html: sanitizeMarkdown(html) }
if (key && hash) {
pending.delete(key)
touchCachedMarkdown(key, result)
}
return result
} catch {
// Keep parser failures on the normal worker/escaped-text fallback path.
return
}
const hash = checksum(text)
if (!hash) return
touchCachedMarkdown(key, {
raw: text,
hash,
html: sanitizeMarkdown(await parseMarkdown(text)),
})
}
export async function renderCachedMarkdown(block: { raw: string; src: string }, key?: string) {
const cached = key ? getCachedMarkdown(key) : undefined
if (key && cached?.raw === block.raw) {
pending.delete(key)
touchCachedMarkdown(key, cached)
return cached
}
const current = key ? pending.get(key) : undefined
if (current?.raw === block.raw) return current.promise
const promise = parseMarkdown(block.src)
.then((html) => {
const hash = checksum(block.raw)
const result = { raw: block.raw, hash: hash ?? "", html: sanitizeMarkdown(html) }
if (key && hash && pending.get(key)?.promise === promise) touchCachedMarkdown(key, result)
return result
})
.finally(() => {
if (key && pending.get(key)?.promise === promise) pending.delete(key)
})
if (key) pending.set(key, { raw: block.raw, promise })
return promise
}
+22 -26
View File
@@ -23,12 +23,17 @@ import {
MarkdownWorkerDisposedError,
MarkdownWorkerSupersededError,
MarkdownWorkerUnavailableError,
parseMarkdown,
projectMarkdown,
} from "./markdown-worker"
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import {
getCachedMarkdown,
getReadyMarkdown,
renderCachedMarkdown,
touchCachedMarkdown,
type MarkdownCacheEntry,
} from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
import { renderMermaidSvg } from "./markdown-mermaid"
import { createMarkdownRenderer } from "./markdown-solid"
@@ -349,8 +354,9 @@ function initialResult(
const blocks = projection.blocks.flatMap((block, index) => {
if (block.mode === "code") return []
const cacheKey = `${base}:${index}:${block.mode}`
const cached = getCachedMarkdown(cacheKey)
const cached = block.mode === "full" ? getReadyMarkdown(block, cacheKey) : getCachedMarkdown(cacheKey)
if (cached?.raw !== block.raw) return []
if (block.mode !== "full") touchCachedMarkdown(cacheKey, cached)
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
})
if (blocks.length === projection.blocks.length) return { text, blocks, ready: true }
@@ -411,6 +417,13 @@ export function Markdown(
if (value?.text) return value
return pendingProjection(local.text)
}
const initial = initialResult(
local.text,
local.cacheKey,
local.streaming ? pendingProjection(local.text) : completedProjection(local.text),
owner,
local.deferUntilReady,
)
const [html] = createResource(
() => {
if (isServer)
@@ -427,7 +440,7 @@ export function Markdown(
projection: value,
}
},
async (src): Promise<RenderResult> => {
(src): RenderResult | Promise<RenderResult> => {
if (isServer)
return {
text: src.text,
@@ -443,6 +456,7 @@ export function Markdown(
],
} satisfies RenderResult
if (!src.text) return { text: src.text, blocks: [], ready: true } satisfies RenderResult
if (!streamed && initial.ready && initial.text === src.text) return initial
const base = src.key ?? checksum(src.text)
return Promise.all(
@@ -466,18 +480,8 @@ export function Markdown(
return rendered
}
if (key) {
const cached = getCachedMarkdown(key)
if (cached?.raw === block.raw) {
touchCachedMarkdown(key, cached)
return { key: blockKey, mode: block.mode, ...cached }
}
}
const hash = checksum(block.raw)
const safe = sanitizeMarkdown(await parseMarkdown(block.src))
if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
const ready = block.mode === "full" ? getReadyMarkdown(block, key) : undefined
return { key: blockKey, mode: block.mode, ...(ready ?? (await renderCachedMarkdown(block, key))) }
}),
)
.then((blocks) => ({ text: src.text, blocks, ready: true }) satisfies RenderResult)
@@ -498,15 +502,7 @@ export function Markdown(
}) satisfies RenderResult,
)
},
{
initialValue: initialResult(
local.text,
local.cacheKey,
local.streaming ? pendingProjection(local.text) : completedProjection(local.text),
owner,
local.deferUntilReady,
),
},
{ initialValue: initial },
)
let copyCleanup: (() => void) | undefined
@@ -570,7 +566,7 @@ export function Markdown(
if (copyCleanup) copyCleanup()
const container = root()
if (container) disposeRenderedMarkdown(container)
disposeMarkdownProjection(owner)
if (streamed) disposeMarkdownProjection(owner)
activeCodeKeys.forEach(disposeCode)
completedCode.clear()
})
@@ -507,16 +507,16 @@ export function AssistantReasoningContent(props: {
const i18n = useI18n()
const [state, setState] = createStore<{ open?: boolean }>({})
const open = () => props.open ?? state.open ?? props.defaultOpen ?? false
const heading = createMemo(() => reasoningHeading(props.content.text))
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const heading = createMemo(() => (props.streaming ? reasoningHeading(props.content.text) : ""))
const duration = createMemo(() => {
const time = props.content.time
if (time?.completed === undefined) return undefined
const total = Math.max(0, Math.round((time.completed - time.created) / 1000))
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
const numfmt = new Intl.NumberFormat(i18n.locale())
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt.format(total) })
return i18n.t("ui.message.duration.minutesSeconds", {
minutes: numfmt().format(Math.floor(total / 60)),
seconds: numfmt().format(total % 60),
minutes: numfmt.format(Math.floor(total / 60)),
seconds: numfmt.format(total % 60),
})
})
return (
@@ -525,6 +525,7 @@ export function AssistantReasoningContent(props: {
icon="mcp"
status={props.streaming ? "running" : "completed"}
compact
hasContent
allowOpenWhilePending
hideDetails={!props.content.text.trim()}
open={open()}
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
import type {
ModelRef,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
import { createTimelineProjection, reuseTimelineRows, Timeline, TimelineRow, type PartGroup } from "./projection"
const context = (key: string, partIDs: string[], identity: { userMessageID?: string; messageID?: string } = {}) =>
new TimelineRow.AssistantPart({
@@ -38,6 +44,62 @@ const part = (key: string, partID: string) =>
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID })
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
describe("Timeline.resolveContent", () => {
const assistant = (content: SessionMessageAssistant["content"]): SessionMessageAssistant => ({
id: "assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 0 },
})
const tool = (id: string): SessionMessageAssistantTool => ({
id,
type: "tool",
name: "read",
state: { status: "running", input: {}, metadata: {} },
time: { created: 0 },
})
test("resolves interleaved ordinals and current store references", () => {
const [store, setStore] = createStore({
message: assistant([
{ type: "text", text: "" },
{ type: "reasoning", text: "", time: { created: 0 } },
tool("read"),
{ type: "text", text: "answer" },
{ type: "reasoning", text: "thought", time: { created: 0 } },
]),
})
expect(Timeline.resolveContent(store.message, "assistant:text:0")).toBe(store.message.content[0])
expect(Timeline.resolveContent(store.message, "assistant:reasoning:0")).toBe(store.message.content[1])
expect(Timeline.resolveContent(store.message, "read")).toBe(store.message.content[2])
expect(Timeline.resolveContent(store.message, "assistant:text:1")).toBe(store.message.content[3])
expect(Timeline.resolveContent(store.message, "assistant:reasoning:1")).toBe(store.message.content[4])
const original = store.message.content[3]
setStore("message", "content", 3, { type: "text", text: "updated" })
expect(Timeline.resolveContent(store.message, "assistant:text:1")).toBe(original)
expect(Timeline.resolveContent(store.message, "assistant:text:1")).toMatchObject({ text: "updated" })
setStore("message", "content", () => [{ type: "text" as const, text: "replacement" }, tool("replacement-tool")])
expect(Timeline.resolveContent(store.message, "assistant:text:0")).toBe(store.message.content[0])
expect(Timeline.resolveContent(store.message, "replacement-tool")).toBe(store.message.content[1])
expect(Timeline.resolveContent(store.message, "read")).toBeUndefined()
expect(Timeline.resolveContent(store.message, "assistant:text:1")).toBeUndefined()
})
test("stops reading as soon as the part is found", () => {
const message = assistant([tool("first")])
message.content.push({
get type(): "text" {
throw new Error("read past the matching part")
},
text: "later",
})
expect(Timeline.resolveContent(message, "first")).toBe(message.content[0])
})
})
describe("reuseTimelineRows", () => {
test.each([
{
@@ -327,7 +327,11 @@ export namespace Timeline {
export function resolveContent(message: SessionMessageInfo | undefined, partID: string): Content | undefined {
if (message?.type !== "assistant") return undefined
return contentEntries(message).find((entry) => entry.id === partID)?.content
const ordinals = { text: 0, reasoning: 0 }
for (const content of message.content) {
const id = content.type === "tool" ? content.id : `${message.id}:${content.type}:${ordinals[content.type]++}`
if (id === partID) return content
}
}
export function contentEntries(message: SessionMessageAssistant) {
@@ -27,6 +27,7 @@ import {
Timeline,
TimelineRow,
unwrapErrorMessage,
type PartRef,
type ReasoningMode,
} from "./projection"
@@ -80,15 +81,31 @@ export function createSessionTimelineRowRenderer(input: {
.find((entry) => entry.content.type === "text" && !!entry.content.text.trim())?.id
}
const padding = () => input.padding?.() ?? "px-4 md:px-5"
const indexGroupContents = (refs: PartRef[]) => {
const result = new Map<string, Map<string, SessionMessageAssistant["content"][number]>>()
refs.forEach((ref) => {
if (result.has(ref.messageID)) return
const contents = new Map<string, SessionMessageAssistant["content"][number]>()
const message = input.projection.messageByID().get(ref.messageID)
if (message?.type === "assistant") {
Timeline.contentEntries(message).forEach((entry) => {
// Match resolveContent's first entry when content IDs repeat.
if (!contents.has(entry.id)) contents.set(entry.id, entry.content)
})
}
result.set(ref.messageID, contents)
})
return result
}
const renderAssistant = (row: Accessor<TimelineRow.AssistantPart>, onSizeChange?: () => void) => {
if (row().group.type === "context") {
const parts = createMemo(() => {
const group = row().group
if (group.type !== "context") return []
const contents = indexGroupContents(group.refs)
return group.refs.flatMap<ContextGroupPart>((ref) => {
const message = input.projection.messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
const content = contents.get(ref.messageID)?.get(ref.partID)
if (content?.type === "tool") return [content]
if (content?.type === "reasoning") return [{ ...content, id: ref.partID }]
return []
@@ -116,10 +133,10 @@ export function createSessionTimelineRowRenderer(input: {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "file") return []
const contents = indexGroupContents(group.refs)
return group.refs.flatMap((ref) => {
const message = input.projection.messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
const content = contents.get(ref.messageID)?.get(ref.partID)
return content?.type === "tool" ? [content] : []
})
})
const firstPath = createMemo(() => {

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