mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
927ca66df7 | ||
|
|
15fbbad20a | ||
|
|
346d121ec3 | ||
|
|
849824efd2 | ||
|
|
cf2c3a536d | ||
|
|
7852cecd72 | ||
|
|
6cfffeb031 | ||
|
|
6e954f75ee | ||
|
|
0116a98371 | ||
|
|
a38cbd42aa | ||
|
|
4ab31867c4 | ||
|
|
51a082cea3 |
+4
-4
@@ -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,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!)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.(() => {})
|
||||
|
||||
@@ -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 })),
|
||||
)
|
||||
|
||||
@@ -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,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,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 }])
|
||||
})
|
||||
|
||||
@@ -98,12 +98,13 @@ Effect.gen(function* () {
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
|
||||
[
|
||||
Global.node,
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
|
||||
replacements: [
|
||||
Global.node.replace(
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
Observability.layer({
|
||||
|
||||
@@ -30,12 +30,13 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
|
||||
[
|
||||
Global.node,
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
replacements: [
|
||||
Global.node.replace(
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Effect.provide(NodeServices.layer),
|
||||
)
|
||||
|
||||
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
const servers = new Map<string, ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
servers.set(name, server)
|
||||
}
|
||||
}
|
||||
for (const [name, server] of servers) {
|
||||
if (draft.get(name)) continue
|
||||
draft.set(name, server)
|
||||
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { buildLocationServiceMap } from "../location-services.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||
// Only build the location service map if it's actually needed
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
|
||||
return LayerNode.compile(root, replacements)
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacements)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
|
||||
}
|
||||
|
||||
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
|
||||
return replacements.some(([source]) => source.name === node.name)
|
||||
export function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
|
||||
return LayerNode.compile(root, {
|
||||
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
|
||||
})
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder.js"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
@@ -104,9 +108,9 @@ const nodes = [
|
||||
Vcs.node,
|
||||
// Start repository watches only after boot-critical filesystem and Git work.
|
||||
LocationWatcher.node,
|
||||
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
|
||||
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
|
||||
|
||||
export const graph = LayerNode.group<typeof nodes>(nodes)
|
||||
export const graph = LayerNode.group(nodes)
|
||||
|
||||
export type Services = LayerNode.Output<typeof graph>
|
||||
export type Error = LayerNode.Error<typeof graph>
|
||||
@@ -135,29 +139,23 @@ export interface Options {
|
||||
// source still honors explicit plugin operations from wellknown and
|
||||
// host-injected config.
|
||||
const vanillaReplacements: LayerNode.Replacements = [
|
||||
[Config.node, Config.configured({ project: false, global: false })],
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
|
||||
Config.node.replace(Config.configured({ project: false, global: false })),
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })),
|
||||
]
|
||||
|
||||
// One instance is one compiled, fresh copy of the graph standing on a directory.
|
||||
export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
const startedAt = performance.now()
|
||||
// Ordered: vanilla defaults, then caller replacements (which win over the
|
||||
// defaults), then bound pairs (which win over everything).
|
||||
const allReplacements: LayerNode.Replacements = [
|
||||
// defaults), then instance bindings (which win over everything).
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...(options.discovery === false ? vanillaReplacements : []),
|
||||
...(options.replacements ?? []),
|
||||
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
|
||||
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
|
||||
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
|
||||
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
|
||||
]
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
@@ -165,6 +163,5 @@ export function layer(ref: Location.Ref, options: Options = {}) {
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, {})
|
||||
}),
|
||||
|
||||
@@ -112,7 +112,7 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
|
||||
|
||||
export const configured = (options: Options = {}) =>
|
||||
const makeLayer = (options: Options = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -361,8 +361,14 @@ export const configured = (options: Options = {}) =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = configured()
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
export const layer = makeLayer()
|
||||
export const configured = (options?: Options) =>
|
||||
makeGlobalNode({
|
||||
service: Service,
|
||||
layer: options === undefined ? layer : makeLayer(options),
|
||||
deps: [Bus.node, Global.node],
|
||||
})
|
||||
export const node = configured()
|
||||
|
||||
const request = (daemon: DaemonTransport, value: object, start = false) =>
|
||||
daemon.request(value, start).pipe(Effect.mapError(unavailable))
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}) {}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -22,8 +22,8 @@ const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
|
||||
[Global.node, globalLayer],
|
||||
[Location.node, locationLayer],
|
||||
Global.node.replace(globalLayer),
|
||||
Location.node.replace(locationLayer),
|
||||
]) as unknown as Layer.Layer<unknown, never>,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
|
||||
|
||||
@@ -100,12 +100,14 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number }
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Location.node.replace(locationLayer),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const itWithoutLocation = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
|
||||
|
||||
@@ -631,8 +633,7 @@ describe("Bus", () => {
|
||||
const continueRead = yield* Deferred.make<void>()
|
||||
let pause = true
|
||||
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
[
|
||||
Bus.node,
|
||||
Bus.node.replace(
|
||||
Bus.configured({
|
||||
persist: true,
|
||||
beforeAggregateRead: () =>
|
||||
@@ -640,7 +641,7 @@ describe("Bus", () => {
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -1318,7 +1319,7 @@ describe("Bus", () => {
|
||||
it.effect("log replays across configured read pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
|
||||
Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -1351,8 +1352,7 @@ describe("Bus", () => {
|
||||
const releaseRead = yield* Deferred.make<void>()
|
||||
const firstRead = yield* Ref.make(true)
|
||||
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
|
||||
[
|
||||
Bus.node,
|
||||
Bus.node.replace(
|
||||
Bus.configured({
|
||||
persist: true,
|
||||
beforeAggregateRead: () =>
|
||||
@@ -1363,7 +1363,7 @@ describe("Bus", () => {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
|
||||
@@ -25,7 +25,7 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const catalogLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]),
|
||||
[[Location.node, locationLayer]],
|
||||
[Location.node.replace(locationLayer)],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("Catalog", () => {
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(locationLayer)]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
@@ -78,7 +78,7 @@ describe("Catalog", () => {
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
Location.node.replace(locationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("Catalog", () => {
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
Location.node.replace(locationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("CodeMode", () => {
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Tool.node, [
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("CodeModeInstructions", () => {
|
||||
execute: () => Effect.succeed({ output: "zeta" }),
|
||||
}
|
||||
const layer = AppNodeBuilder.build(Tool.node, [
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -43,10 +43,10 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
[
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[ShellSelect.node, shellLayer],
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Config.node.replace(emptyConfigLayer),
|
||||
Location.node.replace(testLocationLayer),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -340,17 +340,16 @@ describeNative("ConfigCommandPlugin native watcher", () => {
|
||||
ShellSelect.node,
|
||||
]),
|
||||
[
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[ShellSelect.node, shellLayer],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -40,13 +40,12 @@ const it = testEffect(
|
||||
Layer.merge(
|
||||
config,
|
||||
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
|
||||
[
|
||||
llmClient,
|
||||
llmClient.replace(
|
||||
Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
|
||||
}),
|
||||
],
|
||||
[Config.node, config],
|
||||
),
|
||||
Config.node.replace(config),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,12 +55,12 @@ function testLayer(
|
||||
),
|
||||
)
|
||||
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[Config.node, Config.configured(options)],
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
|
||||
[Credential.node, credentialNode],
|
||||
[WellKnown.node, wellknownNode],
|
||||
[Watcher.node, watcher],
|
||||
Config.node.replace(Config.configured(options)),
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
|
||||
Credential.node.replace(credentialNode),
|
||||
WellKnown.node.replace(wellknownNode),
|
||||
Watcher.node.replace(watcher),
|
||||
])
|
||||
// Merge the watcher layer by reference so Watcher.Test resolves to the same
|
||||
// memoized instance the built graph uses.
|
||||
@@ -311,16 +311,15 @@ describe("Config", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -28,13 +28,13 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
[Global.node, tempGlobalLayer],
|
||||
ConfigPluginSource.node.replace(ConfigPluginSource.empty),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const refreshNpm = makeGlobalNode({
|
||||
@@ -65,10 +65,7 @@ const refreshNpm = makeGlobalNode({
|
||||
const refreshIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Npm.node, refreshNpm],
|
||||
],
|
||||
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,14 +86,13 @@ const discover = (directory: string, global: string) =>
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
[Watcher.node, Watcher.testLayer],
|
||||
),
|
||||
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
Watcher.node.replace(Watcher.testLayer),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Snapshot.node, [
|
||||
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
|
||||
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,7 +44,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
}
|
||||
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
|
||||
}).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]),
|
||||
),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
|
||||
@@ -13,131 +13,218 @@ class OtherError {
|
||||
readonly _tag = "OtherError"
|
||||
}
|
||||
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({})
|
||||
}),
|
||||
)
|
||||
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
|
||||
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||
// Keep intentionally invalid expressions out of runtime execution.
|
||||
const contracts = (tag: LayerNode.Tag<"app"> | LayerNode.Tag<"other">, flag: boolean) => {
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
C,
|
||||
Effect.gen(function* () {
|
||||
yield* A
|
||||
yield* B
|
||||
return C.of({})
|
||||
}),
|
||||
)
|
||||
const a = make({ service: A, layer: aLayer, deps: [] })
|
||||
const b = make({ service: B, layer: bLayer, deps: [a] })
|
||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const ab = make({ name: "a-and-b", layer: Layer.mergeAll(aLayer, Layer.succeed(B, {})), deps: [] })
|
||||
const failing = make({ service: A, layer: Layer.effect(A, Effect.fail(new LayerError())), deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const group = LayerNode.group([a, b])
|
||||
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
// @ts-expect-error A node must have a service or name
|
||||
make({ layer: aLayer, deps: [] })
|
||||
// @ts-expect-error Service and name are mutually exclusive
|
||||
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||
// @ts-expect-error An explicit tagged contract requires a corresponding runtime tag
|
||||
LayerNode.make<typeof aLayer, readonly [], typeof tags.values.app>({ service: A, layer: aLayer, deps: [] })
|
||||
// @ts-expect-error B requires A
|
||||
make({ service: B, layer: bLayer, deps: [] })
|
||||
// @ts-expect-error C requires A and B
|
||||
make({ service: C, layer: cLayer, deps: [a] })
|
||||
const erasedLayer: Layer.Any = bLayer
|
||||
// @ts-expect-error Erasing a Layer's contract cannot hide its inputs and errors
|
||||
make({ service: B, layer: erasedLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error A node must have a service or name
|
||||
make({ layer: aLayer, deps: [] })
|
||||
LayerNode.compile(c) satisfies Layer.Layer<C, never, never>
|
||||
LayerNode.compile(dependent) satisfies Layer.Layer<B, LayerError, never>
|
||||
LayerNode.compile(group) satisfies Layer.Layer<A | B, never, never>
|
||||
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<never>
|
||||
// @ts-expect-error An empty graph cannot supply arbitrary services
|
||||
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<A>
|
||||
LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer<A, never, never>
|
||||
// @ts-expect-error A is a private dependency, not a root output
|
||||
LayerNode.compile(c) satisfies Layer.Layer<A | C>
|
||||
// @ts-expect-error Dependency failures are not erased
|
||||
LayerNode.compile(dependent) satisfies Layer.Layer<B>
|
||||
|
||||
// @ts-expect-error Service and name are mutually exclusive
|
||||
make({ service: A, name: "a", layer: aLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error B requires A
|
||||
make({ service: B, layer: bLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error C requires A and B
|
||||
make({ service: C, layer: cLayer, deps: [a] })
|
||||
|
||||
const closed = build(LayerNode.group([c]))
|
||||
const closedWithError = build(LayerNode.group([dependent]))
|
||||
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
|
||||
|
||||
// @ts-expect-error Replacement must provide A
|
||||
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
|
||||
|
||||
// @ts-expect-error Node replacement must provide A
|
||||
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
|
||||
void invalidNodeReplacement
|
||||
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
|
||||
|
||||
const invalidNodeErrorReplacement = () =>
|
||||
const replacements: LayerNode.Replacements = [a.replace(aLayer), a.replace(ab), failing.replace(a)]
|
||||
const replacement: LayerNode.Replacement = a.replace(Layer.mergeAll(aLayer, Layer.succeed(B, {})))
|
||||
LayerNode.compile(a, { replacements: [...replacements, replacement] })
|
||||
inputA.replace(a)
|
||||
a.replace(a)
|
||||
// @ts-expect-error Closed layer replacements must provide every source output
|
||||
ab.replace(aLayer)
|
||||
// @ts-expect-error Node replacements must provide every source output
|
||||
ab.replace(a)
|
||||
// @ts-expect-error Replacement must provide A
|
||||
a.replace(Layer.succeed(B, {}))
|
||||
// @ts-expect-error Node replacement must provide A
|
||||
a.replace(b)
|
||||
// @ts-expect-error Raw layers with inputs are not closed
|
||||
a.replace(Layer.effect(A, Effect.as(B, A.of({}))))
|
||||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
a.replace(Layer.effect(A, Effect.fail(new OtherError())))
|
||||
// @ts-expect-error Node replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
|
||||
void invalidNodeErrorReplacement
|
||||
a.replace(failing)
|
||||
// @ts-expect-error Existing errors do not authorize unrelated replacement errors
|
||||
failing.replace(Layer.effect(A, Effect.fail(new OtherError())))
|
||||
// @ts-expect-error Every alternative of a node replacement must supply A
|
||||
a.replace(flag ? a : b)
|
||||
// @ts-expect-error Every alternative of a raw-layer replacement must supply A
|
||||
a.replace(flag ? aLayer : Layer.succeed(B, {}))
|
||||
// @ts-expect-error A valid alternative cannot hide a new error in another alternative
|
||||
a.replace(flag ? a : failing)
|
||||
a.replace(flag ? a : ab)
|
||||
failing.replace(flag ? a : failing)
|
||||
// @ts-expect-error Storing replacements must not erase their validation
|
||||
const invalidStored: LayerNode.Replacements = [a.replace(b)]
|
||||
// @ts-expect-error Raw tuples cannot be stored as opaque replacements
|
||||
const rawStored: LayerNode.Replacements = [[a, aLayer]]
|
||||
// @ts-expect-error Raw tuples cannot be supplied to compile
|
||||
LayerNode.compile(a, { replacements: [[a, aLayer]] })
|
||||
// @ts-expect-error Replacements are not structurally forgeable
|
||||
const forged: LayerNode.Replacement = { source: a, target: a }
|
||||
// @ts-expect-error Groups are not replaceable nodes
|
||||
group.replace(a)
|
||||
// @ts-expect-error Groups cannot be replacement targets
|
||||
a.replace(group)
|
||||
// @ts-expect-error Groups cannot be widened to nodes
|
||||
const groupNode: LayerNode.Node<A | B, never, typeof tags.values.app> = group
|
||||
// @ts-expect-error Graphs are opaque
|
||||
const forgedGraph: LayerNode.Graph<A> = { name: "a" }
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
|
||||
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
|
||||
const aContract: LayerNode.Node<A, never, typeof tags.values.app> = a
|
||||
aContract.replace(aLayer)
|
||||
// @ts-expect-error A method cannot be rebound to a declaration with a stronger contract
|
||||
a.replace.call(ab, aLayer)
|
||||
const detached = a.replace
|
||||
// @ts-expect-error Replacement authority requires its checked receiver
|
||||
detached(aLayer)
|
||||
// @ts-expect-error Output narrowing cannot forget B before replacement
|
||||
const narrowedOutput: LayerNode.Node<A, never, typeof tags.values.app> = ab
|
||||
// @ts-expect-error Output widening cannot add B before replacement
|
||||
const widenedOutput: LayerNode.Node<A | B, never, typeof tags.values.app> = a
|
||||
// @ts-expect-error Error widening cannot authorize a new replacement error
|
||||
const widenedError: LayerNode.Node<A, LayerError, typeof tags.values.app> = a
|
||||
// @ts-expect-error Error narrowing cannot forget an existing failure
|
||||
const narrowedError: LayerNode.Node<A, never, typeof tags.values.app> = failing
|
||||
// @ts-expect-error Tag widening cannot authorize replacement across tags
|
||||
const widenedTag: LayerNode.Node<A, never, LayerNode.Tag | undefined> = a
|
||||
const unionTag = LayerNode.unbound(A, tag)
|
||||
// @ts-expect-error Tag narrowing cannot forget a possible tag
|
||||
const narrowedTag: LayerNode.Node<A, never, typeof tags.values.app> = unionTag
|
||||
|
||||
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
|
||||
const request = scopedTags.make("request")
|
||||
const global = scopedTags.make("global")
|
||||
const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||
const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
|
||||
const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] })
|
||||
const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({})))
|
||||
const tagCLayer = Layer.effect(
|
||||
TagC,
|
||||
Effect.gen(function* () {
|
||||
yield* TagA
|
||||
yield* TagB
|
||||
return TagC.of({})
|
||||
}),
|
||||
)
|
||||
const outputProjection: LayerNode.Graph<A, never, typeof tags.values.app> = group
|
||||
// @ts-expect-error Graph output projection cannot invent a service
|
||||
const widenedGraph: LayerNode.Graph<A | B, never, typeof tags.values.app> = a
|
||||
// @ts-expect-error A projected Graph has no replacement authority
|
||||
outputProjection.replace(aLayer)
|
||||
|
||||
request({ service: TagB, layer: tagBLayer, deps: [globalA] })
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] })
|
||||
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] })
|
||||
const choice = flag ? a : b
|
||||
// @ts-expect-error Choosing one dependency does not provide both services
|
||||
make({ service: C, layer: cLayer, deps: [choice] })
|
||||
// @ts-expect-error A conditional root promises only outputs present in every alternative
|
||||
LayerNode.compile(LayerNode.group([choice])) satisfies Layer.Layer<A | B>
|
||||
const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] })
|
||||
LayerNode.compile(conditional) satisfies Layer.Layer<never>
|
||||
// @ts-expect-error A conditional implementation does not acquire both branches
|
||||
LayerNode.compile(conditional) satisfies Layer.Layer<A | B>
|
||||
LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer<A>
|
||||
const dynamic: Array<typeof a> = []
|
||||
// @ts-expect-error An unbounded array may contain no roots
|
||||
LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer<A>
|
||||
|
||||
// @ts-expect-error Tag configuration can only reference declared tags
|
||||
LayerNode.tags({ request: ["missing"], global: [] })
|
||||
const decorated = b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.void)))
|
||||
LayerNode.compile(decorated) satisfies Layer.Layer<B>
|
||||
b.replace(decorated)
|
||||
// @ts-expect-error A layer mapper cannot be rebound to a weaker declaration
|
||||
ab.mapLayer.call(a, (layer) => layer)
|
||||
// @ts-expect-error mapLayer cannot add an input requirement
|
||||
b.mapLayer((layer) => layer.pipe(Layer.tap(() => C)))
|
||||
// @ts-expect-error mapLayer cannot grow the error channel
|
||||
b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.fail(new OtherError()))))
|
||||
// @ts-expect-error mapLayer cannot drop an output
|
||||
ab.mapLayer(() => aLayer)
|
||||
// @ts-expect-error Unbound declarations have no implementation to map
|
||||
inputA.mapLayer((layer: Layer.Layer<A>) => layer)
|
||||
|
||||
// @ts-expect-error An unrelated dependency cannot satisfy TagA
|
||||
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
|
||||
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
|
||||
const request = scopedTags.make("request")
|
||||
const global = scopedTags.make("global")
|
||||
const globalA = global({ service: A, layer: aLayer, deps: [] })
|
||||
const requestA = request({ service: A, layer: aLayer, deps: [] })
|
||||
const requestB = request({ service: B, layer: Layer.succeed(B, {}), deps: [] })
|
||||
request({ service: B, layer: bLayer, deps: [globalA] })
|
||||
request({ service: C, layer: cLayer, deps: [globalA, requestB] })
|
||||
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] })
|
||||
LayerNode.compile(LayerNode.group([globalA, requestB]), { shared: scopedTags.values.global }) satisfies Layer.Layer<
|
||||
A | B
|
||||
>
|
||||
// @ts-expect-error Tag configuration can only reference declared tags
|
||||
LayerNode.tags({ request: ["missing"], global: [] })
|
||||
// @ts-expect-error Shared tags must be branded
|
||||
LayerNode.compile(globalA, { shared: "global" })
|
||||
// @ts-expect-error Replacement targets must keep the source tag
|
||||
globalA.replace(requestA)
|
||||
// @ts-expect-error Replacement targets must keep the source tag in either direction
|
||||
requestA.replace(globalA)
|
||||
// @ts-expect-error Every alternative must keep the source tag
|
||||
globalA.replace(flag ? globalA : requestA)
|
||||
// @ts-expect-error Providing only A leaves B missing
|
||||
request({ service: C, layer: cLayer, deps: [globalA] })
|
||||
// @ts-expect-error Providing only B leaves A missing
|
||||
request({ service: C, layer: cLayer, deps: [requestB] })
|
||||
// @ts-expect-error Duplicate A providers still leave B missing
|
||||
request({ service: C, layer: cLayer, deps: [globalA, requestA] })
|
||||
// @ts-expect-error A group with only A still leaves B missing
|
||||
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA])] })
|
||||
// @ts-expect-error Global cannot depend on request
|
||||
global({ service: B, layer: bLayer, deps: [requestA] })
|
||||
// @ts-expect-error Groups preserve their child tags
|
||||
global({ service: B, layer: bLayer, deps: [LayerNode.group([requestA])] })
|
||||
|
||||
// @ts-expect-error Providing only TagA leaves TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
|
||||
const globalScopedA = makeGlobalNode({ service: A, layer: aLayer, deps: [] })
|
||||
const locationScopedA = makeLocationNode({ service: A, layer: aLayer, deps: [] })
|
||||
makeGlobalNode({ service: B, layer: bLayer, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: B, layer: bLayer, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: B, layer: bLayer, deps: [locationScopedA] })
|
||||
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||
makeGlobalNode({ service: B, layer: bLayer, deps: [locationScopedA] })
|
||||
// @ts-expect-error B requires A
|
||||
makeLocationNode({ service: B, layer: bLayer, deps: [] })
|
||||
|
||||
// @ts-expect-error Providing only TagB leaves TagA missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
|
||||
void [
|
||||
invalidStored,
|
||||
rawStored,
|
||||
forged,
|
||||
groupNode,
|
||||
forgedGraph,
|
||||
narrowedOutput,
|
||||
widenedOutput,
|
||||
widenedError,
|
||||
narrowedError,
|
||||
widenedTag,
|
||||
narrowedTag,
|
||||
widenedGraph,
|
||||
]
|
||||
}
|
||||
|
||||
// @ts-expect-error Duplicate TagA providers still leave TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] })
|
||||
|
||||
// @ts-expect-error A group with only TagA still leaves TagB missing
|
||||
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] })
|
||||
|
||||
// @ts-expect-error Global cannot depend on request
|
||||
global({ service: TagB, layer: tagBLayer, deps: [requestA] })
|
||||
|
||||
// @ts-expect-error Groups preserve their child tags
|
||||
global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] })
|
||||
|
||||
class ScopedA extends Context.Service<ScopedA, {}>()("test/ScopedA") {}
|
||||
class ScopedB extends Context.Service<ScopedB, {}>()("test/ScopedB") {}
|
||||
|
||||
const scopedA = Layer.succeed(ScopedA, ScopedA.of({}))
|
||||
const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({})))
|
||||
const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||
const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] })
|
||||
|
||||
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||
|
||||
// @ts-expect-error Global nodes cannot depend on location nodes
|
||||
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
|
||||
|
||||
// @ts-expect-error ScopedB requires ScopedA
|
||||
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] })
|
||||
|
||||
test("type exploration compiles", () => {})
|
||||
test("layer node type contracts compile", () => {
|
||||
void contracts
|
||||
})
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
|
||||
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
|
||||
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
|
||||
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
|
||||
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
|
||||
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
|
||||
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
|
||||
class Memo extends Context.Service<Memo, Layer.MemoMap>()("test/LayerNodeMemo") {}
|
||||
class Support extends Context.Service<Support, {}>()("test/LayerNodeSupport") {}
|
||||
class Locations extends Context.Service<Locations, LayerMap.LayerMap<string, Value | Right, "failed location">>()(
|
||||
"test/LayerNodeLocations",
|
||||
) {}
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
|
||||
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
|
||||
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
|
||||
const greetingLayer = Layer.effect(
|
||||
Greeting,
|
||||
@@ -23,240 +25,443 @@ const value = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
|
||||
describe("layer node", () => {
|
||||
test("builds an untagged graph", async () => {
|
||||
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([greeting]))),
|
||||
it.effect("builds an untagged graph", () =>
|
||||
Effect.gen(function* () {
|
||||
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const result = yield* Greeting.pipe(Effect.provide(LayerNode.compile(LayerNode.group([greeting]))))
|
||||
expect(result.value).toBe("hello production")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes roots but hides transitive dependencies", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting])))
|
||||
expect(Context.get(context, Greeting).value).toBe("hello production")
|
||||
expect(Option.isNone(Context.getOption(context, Value))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces exact declarations, not sibling names or native layer identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const sibling = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const target = make({ name: "different-name", layer: Layer.succeed(Value, { value: "replaced" }), deps: [] })
|
||||
const left = make({
|
||||
service: Left,
|
||||
layer: Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
),
|
||||
deps: [value],
|
||||
})
|
||||
const right = make({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
),
|
||||
deps: [sibling],
|
||||
})
|
||||
const context = yield* Layer.build(
|
||||
LayerNode.compile(LayerNode.group([left, right]), { replacements: [value.replace(target)] }),
|
||||
)
|
||||
expect(Context.get(context, Left).value).toBe("replaced")
|
||||
expect(Context.get(context, Right).value).toBe("production")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires reachable unbound nodes to be replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const root = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
expect(() => LayerNode.compile(root)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(LayerNode.compile(root, { replacements: [unbound.replace(value)] })),
|
||||
)
|
||||
expect(result.value).toBe("hello production")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces every use of a declaration with a stored closed-layer replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const replacements: LayerNode.Replacements = [value.replace(Layer.succeed(Value, { value: "replacement" }))]
|
||||
const right = make({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
),
|
||||
deps: [value],
|
||||
})
|
||||
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting, right]), { replacements }))
|
||||
expect(Context.get(context, Greeting).value).toBe("hello replacement")
|
||||
expect(Context.get(context, Right).value).toBe("replacement")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last replacement and ignores unreachable unbound defaults and cycles", () =>
|
||||
Effect.gen(function* () {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const unused = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, {
|
||||
replacements: [
|
||||
value.replace(unbound),
|
||||
unbound.replace(unused),
|
||||
unused.replace(unbound),
|
||||
value.replace(Layer.succeed(Value, { value: "last" })),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("hello last")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves target chains independently of replacement order and treats self-replacement as identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const middle = make({ service: Value, layer: Layer.succeed(Value, { value: "middle" }), deps: [] })
|
||||
const target = make({ service: Value, layer: Layer.succeed(Value, { value: "target" }), deps: [] })
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, {
|
||||
replacements: [target.replace(target), middle.replace(target), value.replace(middle)],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("hello target")
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects reachable replacement and dependency cycles", () => {
|
||||
const other = make({ service: Value, layer: valueLayer, deps: [] })
|
||||
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(other), other.replace(value)] })).toThrow(
|
||||
"Cycle detected in layer graph",
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("builds a dependency graph", async () => {
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("exposes roots but hides transitive dependencies", () => {
|
||||
const layer = build(LayerNode.group([greeting]))
|
||||
const check: Layer.Layer<Greeting> = layer
|
||||
void check
|
||||
})
|
||||
|
||||
test("preserves branch-specific implementations across roots", async () => {
|
||||
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
|
||||
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
|
||||
const layer = build(LayerNode.group([left, right]))
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("requires unbound nodes to be replaced before compilation", async () => {
|
||||
const unbound = LayerNode.unbound(Value, tags.values.app)
|
||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
|
||||
const tree = LayerNode.group([greeting])
|
||||
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
|
||||
const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||
})
|
||||
|
||||
test("replaces a node with a closed layer", async () => {
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
|
||||
)
|
||||
expect(await Effect.runPromise(program)).toBe("hello simulation")
|
||||
})
|
||||
|
||||
test("replaces every use of the same layer", async () => {
|
||||
const leftLayer = Layer.effect(
|
||||
Left,
|
||||
Effect.map(Value, (item) => Left.of({ value: item.value })),
|
||||
)
|
||||
const rightLayer = Layer.effect(
|
||||
Right,
|
||||
Effect.map(Value, (item) => Right.of({ value: item.value })),
|
||||
)
|
||||
const left = make({ service: Left, layer: leftLayer, deps: [value] })
|
||||
const right = make({ service: Right, layer: rightLayer, deps: [value] })
|
||||
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
|
||||
const layer = build(LayerNode.group([left, right]), [[value, replacement]])
|
||||
const program = Effect.gen(function* () {
|
||||
return [(yield* Left).value, (yield* Right).value]
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
|
||||
})
|
||||
|
||||
test("does not acquire an unused replacement", async () => {
|
||||
let acquisitions = 0
|
||||
const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
|
||||
const replacement = Layer.effect(
|
||||
Left,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Left.of({ value: "replacement" })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
|
||||
),
|
||||
)
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("replaces a node without acquiring its dependencies", async () => {
|
||||
let acquisitions = 0
|
||||
const dependencyLayer = Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Value.of({ value: "dependency" })
|
||||
}),
|
||||
)
|
||||
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
|
||||
const replacement = make({
|
||||
service: Greeting,
|
||||
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("replacement")
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("applies later replacements inside earlier replacement nodes", async () => {
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(
|
||||
build(LayerNode.group([original]), [
|
||||
[original, replacement],
|
||||
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
|
||||
})
|
||||
|
||||
test("hoists and compiles tagged graphs", async () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const database = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||
deps: [],
|
||||
})
|
||||
const users = location({
|
||||
service: Users,
|
||||
const dependent = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Users,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* Database
|
||||
return Users.of({ list: Effect.succeed([db.name]) })
|
||||
}),
|
||||
Value,
|
||||
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
|
||||
),
|
||||
deps: [database],
|
||||
deps: [greeting],
|
||||
})
|
||||
const app = location({
|
||||
service: App,
|
||||
layer: Layer.effect(
|
||||
App,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Users
|
||||
return App.of({ run: service.list })
|
||||
}),
|
||||
),
|
||||
deps: [users],
|
||||
})
|
||||
|
||||
const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global)
|
||||
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||
kind: "group",
|
||||
dependencies: [],
|
||||
})
|
||||
expect(result.hoisted.dependencies).toEqual([database])
|
||||
|
||||
const layer = LayerNode.compile(result.node).pipe(
|
||||
Layer.provide(LayerNode.compile(result.hoisted)),
|
||||
) as unknown as Layer.Layer<App>
|
||||
const program = Effect.gen(function* () {
|
||||
const app = yield* App
|
||||
return yield* app.run
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
expect(await Effect.runPromise(program)).toEqual(["Alice"])
|
||||
})
|
||||
|
||||
test("rejects conflicting hoisted implementations", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const first = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "first" })),
|
||||
deps: [],
|
||||
})
|
||||
const second = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "second" })),
|
||||
deps: [],
|
||||
})
|
||||
const left = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||
deps: [first],
|
||||
})
|
||||
const right = location({
|
||||
service: App,
|
||||
layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
|
||||
deps: [second],
|
||||
})
|
||||
|
||||
expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
|
||||
"Tag global has conflicting implementations for test/GraphDatabase",
|
||||
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow(
|
||||
"Cycle detected in layer graph",
|
||||
)
|
||||
})
|
||||
|
||||
test("treats dependency groups as transparent while hoisting", () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const database = global({
|
||||
service: Database,
|
||||
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
|
||||
deps: [],
|
||||
})
|
||||
const users = location({
|
||||
service: Users,
|
||||
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
|
||||
deps: [LayerNode.group([database])],
|
||||
})
|
||||
const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global)
|
||||
it.effect("does not acquire replaced dependencies or unused replacement targets", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquired: string[] = []
|
||||
const dependency = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquired.push("old dependency")
|
||||
return Value.of({ value: "dependency" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(original, {
|
||||
replacements: [
|
||||
original.replace(Layer.succeed(Greeting, { value: "replacement" })),
|
||||
value.replace(
|
||||
Layer.effect(
|
||||
Value,
|
||||
Effect.sync(() => {
|
||||
acquired.push("unused target")
|
||||
return Value.of({ value: "unused" })
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("replacement")
|
||||
expect(acquired).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
|
||||
kind: "group",
|
||||
dependencies: [],
|
||||
})
|
||||
it.effect("mapLayer preserves dependency wiring and replacement traversal", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquired: string[] = []
|
||||
const decorated = greeting.mapLayer((layer) =>
|
||||
layer.pipe(
|
||||
Layer.tap((context) =>
|
||||
Effect.sync(() => {
|
||||
acquired.push(Context.get(context, Greeting).value)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(greeting, {
|
||||
replacements: [
|
||||
greeting.replace(decorated),
|
||||
value.replace(Layer.succeed(Value, { value: "mapped dependency" })),
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result.value).toBe("hello mapped dependency")
|
||||
expect(acquired).toEqual(["hello mapped dependency"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("memoizes shared wiring instead of expanding a diamond into a tree", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquisitions: string[] = []
|
||||
const shared = value.mapLayer((layer) =>
|
||||
layer.pipe(Layer.tap(() => Effect.sync(() => acquisitions.push("shared")))),
|
||||
)
|
||||
const left = make({ name: "left", layer: Layer.empty, deps: [shared] })
|
||||
const right = make({ name: "right", layer: Layer.empty, deps: [shared] })
|
||||
yield* Layer.build(LayerNode.compile(LayerNode.group([left, right])))
|
||||
expect(acquisitions).toEqual(["shared"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves declared memo-service outputs rather than filtering them as build metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const supplied = yield* Layer.makeMemoMap
|
||||
const memo = make({
|
||||
service: Layer.CurrentMemoMap,
|
||||
layer: Layer.succeed(Layer.CurrentMemoMap, supplied),
|
||||
deps: [],
|
||||
})
|
||||
const observer = make({ service: Memo, layer: Layer.effect(Memo, Layer.CurrentMemoMap), deps: [memo] })
|
||||
expect(yield* Memo.pipe(Effect.provide(LayerNode.compile(observer)))).toBe(supplied)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects one implementation wired to different effective dependencies in either memo domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const other = make({ service: Value, layer: Layer.succeed(Value, { value: "other" }), deps: [] })
|
||||
const sibling = make({ service: Greeting, layer: greetingLayer, deps: [other] })
|
||||
const root = LayerNode.group([greeting, sibling])
|
||||
expect(() => LayerNode.compile(root)).toThrow("wired to different dependencies")
|
||||
expect(() => LayerNode.compile(root, { shared: tags.values.app })).toThrow("wired to different dependencies")
|
||||
const result = yield* Greeting.pipe(
|
||||
Effect.provide(LayerNode.compile(root, { replacements: [value.replace(other)] })),
|
||||
)
|
||||
expect(result.value).toBe("hello other")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts dependencies in parallel and nested group roots in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const valueStarted = yield* Deferred.make<void>()
|
||||
const greetingStarted = yield* Deferred.make<void>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const events: string[] = []
|
||||
const value = make({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(valueStarted, undefined)
|
||||
yield* Deferred.await(greetingStarted)
|
||||
return Value.of({ value: "value" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const greeting = make({
|
||||
service: Greeting,
|
||||
layer: Layer.effect(
|
||||
Greeting,
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(greetingStarted, undefined)
|
||||
yield* Deferred.await(valueStarted)
|
||||
return Greeting.of({ value: "greeting" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const first = make({
|
||||
service: Left,
|
||||
layer: Layer.effect(
|
||||
Left,
|
||||
Effect.gen(function* () {
|
||||
yield* Value
|
||||
yield* Greeting
|
||||
events.push("first started")
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
events.push("first finished")
|
||||
return Left.of({ value: "first" })
|
||||
}),
|
||||
),
|
||||
deps: [value, greeting],
|
||||
})
|
||||
const second = make({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.sync(() => {
|
||||
expect(events).toEqual(["first started", "first finished"])
|
||||
events.push("second started")
|
||||
return Right.of({ value: "second" })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const fiber = yield* Layer.build(LayerNode.compile(LayerNode.group([LayerNode.group([first]), second]))).pipe(
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(firstStarted)
|
||||
expect(events).toEqual(["first started"])
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
const context = yield* Fiber.join(fiber)
|
||||
expect(events).toEqual(["first started", "first finished", "second started"])
|
||||
expect(Context.get(context, Left).value).toBe("first")
|
||||
expect(Context.get(context, Right).value).toBe("second")
|
||||
}),
|
||||
)
|
||||
;[false, true].forEach((topLevel) => {
|
||||
it.effect(
|
||||
`LayerMap isolates builds and retains resources ${topLevel ? "with" : "without"} a top-level global owner`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const acquired = { global: 0, local: 0, support: 0 }
|
||||
const released: string[] = []
|
||||
const startup: string[] = []
|
||||
yield* Effect.gen(function* () {
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
const location = tags.make("location")
|
||||
const support = LayerNode.make({
|
||||
service: Support,
|
||||
layer: Layer.effect(
|
||||
Support,
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
acquired.support++
|
||||
return Support.of({})
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
released.push("support")
|
||||
}),
|
||||
),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const value = global({
|
||||
service: Value,
|
||||
layer: Layer.effect(
|
||||
Value,
|
||||
Effect.andThen(
|
||||
Support,
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
startup.push("global")
|
||||
return Value.of({ value: `global-${++acquired.global}` })
|
||||
}),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
released.push(value.value)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
deps: [support],
|
||||
})
|
||||
const local = location({
|
||||
service: Greeting,
|
||||
layer: Layer.effect(
|
||||
Greeting,
|
||||
Effect.gen(function* () {
|
||||
yield* Value
|
||||
return yield* Effect.acquireRelease(
|
||||
Effect.sync(() => Greeting.of({ value: `local-${++acquired.local}` })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
released.push(value.value)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
deps: [LayerNode.group([value])],
|
||||
})
|
||||
const root = location({
|
||||
service: Right,
|
||||
layer: Layer.effect(
|
||||
Right,
|
||||
Effect.gen(function* () {
|
||||
const local = yield* Greeting
|
||||
if (local.value === "local-2") return yield* Effect.fail("failed location" as const)
|
||||
return Right.of(local)
|
||||
}),
|
||||
),
|
||||
deps: [local],
|
||||
})
|
||||
// Every key builds the same compiled Layer, not a new graph per lookup.
|
||||
const compiled = LayerNode.compile(LayerNode.group([value, root]), { shared: tags.values.global })
|
||||
const locations = location({
|
||||
service: Locations,
|
||||
layer: Layer.effect(
|
||||
Locations,
|
||||
Effect.gen(function* () {
|
||||
startup.push("map")
|
||||
expect(Option.getOrUndefined(yield* Effect.serviceOption(Layer.CurrentMemoMap))).toBe(memoMap)
|
||||
return yield* LayerMap.make((_: string) => compiled, { idleTimeToLive: Duration.infinity })
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
const scope = yield* Effect.scope
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
LayerNode.compile(LayerNode.group([locations, ...(topLevel ? [value] : [])]), {
|
||||
shared: tags.values.global,
|
||||
}),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
expect(startup).toEqual(topLevel ? ["map", "global"] : ["map"])
|
||||
const map = Context.get(context, Locations)
|
||||
const first = yield* map.contextEffect("first").pipe(Effect.scoped)
|
||||
expect(Option.getOrUndefined(Context.getOption(context, Value))).toBe(
|
||||
topLevel ? Context.get(first, Value) : undefined,
|
||||
)
|
||||
expect(Option.isNone(Context.getOption(first, Greeting))).toBe(true)
|
||||
expect(Context.get(first, Right).value).toBe("local-1")
|
||||
|
||||
expect(yield* map.contextEffect("failed").pipe(Effect.scoped, Effect.flip)).toBe("failed location")
|
||||
expect(released).toEqual(["local-2"])
|
||||
expect(Context.get(yield* map.contextEffect("first").pipe(Effect.scoped), Right)).toBe(
|
||||
Context.get(first, Right),
|
||||
)
|
||||
|
||||
const second = yield* map.contextEffect("second").pipe(Effect.scoped)
|
||||
expect(Context.get(second, Value)).toBe(Context.get(first, Value))
|
||||
expect(Context.get(second, Right)).not.toBe(Context.get(first, Right))
|
||||
expect(acquired).toEqual({ global: 1, local: 3, support: 1 })
|
||||
|
||||
yield* map.invalidate("first")
|
||||
expect(released).toEqual(["local-2", "local-1"])
|
||||
expect(Context.get(yield* map.contextEffect("second").pipe(Effect.scoped), Right)).toBe(
|
||||
Context.get(second, Right),
|
||||
)
|
||||
const rebuilt = yield* map.contextEffect("first").pipe(Effect.scoped)
|
||||
expect(Context.get(rebuilt, Right).value).toBe("local-4")
|
||||
expect(Context.get(rebuilt, Value)).toBe(Context.get(first, Value))
|
||||
expect(acquired).toEqual({ global: 1, local: 4, support: 1 })
|
||||
expect(released).not.toContain("global-1")
|
||||
}).pipe(Effect.scoped)
|
||||
expect(released.toSorted()).toEqual(["global-1", "local-1", "local-2", "local-3", "local-4", "support"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, LayerMap, Option } from "effect"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { buildLocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../../fixture/tmpdir"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
|
||||
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
|
||||
class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
|
||||
class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("test/NodeBuildB") {}
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("node build", () => {
|
||||
test("does not build a location service map when the graph does not require it", async () => {
|
||||
const result = Node.makeGlobalNode({
|
||||
@@ -31,7 +34,7 @@ describe("node build", () => {
|
||||
expect(await Effect.runPromise(program)).toBe("plain")
|
||||
})
|
||||
|
||||
test("detects cycles through a replaced location service map", async () => {
|
||||
test("detects cycles through a replaced location service map", () => {
|
||||
const a = Node.makeGlobalNode({
|
||||
service: CycleA,
|
||||
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
|
||||
@@ -45,31 +48,49 @@ describe("node build", () => {
|
||||
),
|
||||
deps: [a],
|
||||
})
|
||||
const mapLayer = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* CycleB
|
||||
return yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
|
||||
}),
|
||||
),
|
||||
{ idleTimeToLive: "1 minute" },
|
||||
)
|
||||
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
|
||||
)
|
||||
const mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap()))
|
||||
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
|
||||
"Cycle detected in layer tree",
|
||||
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow(
|
||||
"Cycle detected in layer graph",
|
||||
)
|
||||
})
|
||||
|
||||
test("shares top-level project with location services", async () => {
|
||||
it.effect("supplies the lazy map when only a replacement introduces the dependency", () =>
|
||||
Effect.gen(function* () {
|
||||
const original = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.succeed(Result, { value: "original" }),
|
||||
deps: [],
|
||||
})
|
||||
const replacement = Node.makeGlobalNode({
|
||||
service: Result,
|
||||
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "has map" }))),
|
||||
deps: [LocationServiceMap.node],
|
||||
})
|
||||
const result = yield* Result.pipe(Effect.provide(AppNodeBuilder.build(original, [original.replace(replacement)])))
|
||||
expect(result.value).toBe("has map")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("caller replacements override the lazy default without building any locations", () =>
|
||||
Effect.gen(function* () {
|
||||
const acquisitions: string[] = []
|
||||
const override = buildLocationServiceMap().pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.sync(() => {
|
||||
acquisitions.push("caller map")
|
||||
}),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LocationServiceMap.node, [LocationServiceMap.node.replace(override)]),
|
||||
)
|
||||
expect(Context.get(context, LocationServiceMap.Service)).toBeDefined()
|
||||
expect(acquisitions).toEqual(["caller map"])
|
||||
}),
|
||||
)
|
||||
|
||||
test("shares top-level project even when the location service map is built first", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let acquisitions = 0
|
||||
const projectLayer = Layer.effect(
|
||||
@@ -84,8 +105,8 @@ describe("node build", () => {
|
||||
}),
|
||||
)
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
|
||||
[Project.node, projectLayer],
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [
|
||||
Project.node.replace(projectLayer),
|
||||
])
|
||||
const program = Effect.gen(function* () {
|
||||
yield* Project.Service
|
||||
|
||||
@@ -21,8 +21,8 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[Environment.node, transformEnvironmentFiles(transformFiles)],
|
||||
Location.node.replace(activeLocation),
|
||||
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,16 +77,15 @@ describe("FileSystemSearch", () => {
|
||||
workspaceID: Workspace.ID.make("wrk_test"),
|
||||
})
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
),
|
||||
Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -103,8 +102,7 @@ describe("FileSystemSearch", () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -114,8 +112,8 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
),
|
||||
Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))),
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
@@ -137,17 +135,15 @@ describe("FileSystemSearch", () => {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
),
|
||||
Ripgrep.node.replace(
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
@@ -169,7 +165,7 @@ describe("FileSystemSearch", () => {
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -208,17 +204,15 @@ describe("FileSystemSearch", () => {
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
),
|
||||
Ripgrep.node.replace(
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
@@ -234,7 +228,7 @@ describe("FileSystemSearch", () => {
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -129,7 +129,7 @@ function provide(
|
||||
vcs?: Location.Interface["vcs"],
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
config: Layer.Layer<Config.Service> = configLayer,
|
||||
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
|
||||
plugins: typeof pluginNode = pluginNode,
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -138,10 +138,10 @@ function provide(
|
||||
const built = AppNodeBuilder.build(
|
||||
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
|
||||
[
|
||||
[Config.node, config],
|
||||
[Location.node, locationLayer],
|
||||
[PluginSupervisor.node, plugins],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
Config.node.replace(config),
|
||||
Location.node.replace(locationLayer),
|
||||
PluginSupervisor.node.replace(plugins),
|
||||
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
|
||||
],
|
||||
)
|
||||
return Effect.provide(built)
|
||||
@@ -154,7 +154,7 @@ function withTmp<A, E, R>(
|
||||
init?: (directory: string) => Promise<void>
|
||||
watcher?: Layer.Layer<Watcher.Service>
|
||||
config?: Layer.Layer<Config.Service>
|
||||
plugins?: LocationNode<PluginSupervisor.Service>
|
||||
plugins?: typeof pluginNode
|
||||
},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
|
||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
||||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
|
||||
const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] })
|
||||
|
||||
async function job() {
|
||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||
|
||||
@@ -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]), {
|
||||
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
|
||||
}),
|
||||
Layer.succeed(FSUtil.Service, fs),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
),
|
||||
),
|
||||
) as Layer.Layer<LocationServices>,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
deps: [Bus.node, FSUtil.node],
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
@@ -34,7 +34,7 @@ const instances = Layer.effect(
|
||||
(ref: Location.Ref) =>
|
||||
Instance.layer(ref, {
|
||||
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
|
||||
replacements: [[Global.node, tempGlobalLayer]],
|
||||
replacements: [Global.node.replace(tempGlobalLayer)],
|
||||
}),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
),
|
||||
@@ -42,8 +42,8 @@ const instances = Layer.effect(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[LocationServiceMap.node, instances],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,14 +23,13 @@ import { Bus } from "../src/bus"
|
||||
// Config the host hands the vanilla instance explicitly: a value and an
|
||||
// explicit plugin removal, both of which must survive discovery: false.
|
||||
const hostConfig: LayerNode.Replacements = [
|
||||
[
|
||||
Config.node,
|
||||
Config.node.replace(
|
||||
Config.configured({
|
||||
project: false,
|
||||
global: false,
|
||||
content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }),
|
||||
}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
// Same directory contents, two instances: one vanilla, one with discovery.
|
||||
@@ -43,7 +42,7 @@ const instances = Layer.effect(
|
||||
// "bare" exercises the vanilla defaults themselves: no caller Config.
|
||||
discovery: name !== "vanilla" && name !== "bare",
|
||||
// Caller replacements win over the vanilla defaults.
|
||||
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
|
||||
replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])],
|
||||
})
|
||||
},
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
@@ -52,8 +51,8 @@ const instances = Layer.effect(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[LocationServiceMap.node, instances],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,19 +33,18 @@ const instructionLayer = (input: {
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
[
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[
|
||||
Global.node,
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
|
||||
Global.node.replace(
|
||||
input.config || input.home
|
||||
? Global.layerWith({
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...(input.home ? { home: input.home } : {}),
|
||||
})
|
||||
: tempGlobalLayer,
|
||||
],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
[Watcher.node, watcher],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
),
|
||||
Location.node.replace(input.locationServiceLayer),
|
||||
Watcher.node.replace(watcher),
|
||||
...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []),
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ config: temporary, tmp: temporary })),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const failingCredentialNode = makeGlobalNode({
|
||||
deps: [],
|
||||
})
|
||||
const failingIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
|
||||
)
|
||||
|
||||
function eventually<A, E, R>(
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -13,15 +13,16 @@ import { it } from "./lib/effect"
|
||||
|
||||
const provide = (directory: string, workspaceID?: Workspace.ID) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(FileSystem.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
|
||||
LayerNode.compile(FileSystem.node, {
|
||||
replacements: [
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
)
|
||||
|
||||
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
|
||||
|
||||
@@ -51,12 +51,12 @@ import { Tool } from "../src/tool"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
@@ -77,7 +77,7 @@ const activityLocations = Layer.effect(
|
||||
)
|
||||
const itWithActivity = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
|
||||
[LocationServiceMap.node, activityLocations],
|
||||
LocationServiceMap.node.replace(activityLocations),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,20 +13,21 @@ import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, projectDirectory = directory) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
LayerNode.compile(LocationMutation.node, {
|
||||
replacements: [
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const projectLayer = Layer.succeed(
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)]))
|
||||
|
||||
describe("Location", () => {
|
||||
it.effect("resolves the current project and vcs information", () =>
|
||||
|
||||
@@ -23,13 +23,12 @@ const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.Ser
|
||||
|
||||
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
|
||||
AppNodeBuilder.build(McpInstructions.node, [
|
||||
[
|
||||
Mcp.node,
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
instructions: () => Effect.succeed(catalog()),
|
||||
tools: () => Effect.succeed(tools()),
|
||||
}),
|
||||
],
|
||||
),
|
||||
])
|
||||
|
||||
describe("McpInstructions", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user