Compare commits

...
Author SHA1 Message Date
Hona b530418425 fix(session-ui): share timeline tool headers 2026-08-30 01:48:49 +00:00
Aiden Cline e70d667a9f fix(ai): preserve Anthropic finish across usage deltas (#46171) 2026-08-29 15:45:27 -05:00
Kit Langton 8ba434b597 refactor(core): move projected Session reads into Store (#46075)
Move existing Session list and message queries into SessionStore. Preserve public response wrapping, Session existence checks, pagination, ordering, and typed message decoding errors.
2026-08-29 09:31:04 -04:00
Kit Langton 171947787c test(tui): wait for diff base search focus (#46083)
Wait for the diff-base search input to receive focus before typing. Preserve existing assertions and timeouts while removing the render-versus-focus test race.
2026-08-29 08:37:32 -04:00
Luke Parker 106629aa11 feat(infra): deploy beta web app with SST (#46086) 2026-08-29 14:51:22 +10:00
Luke Parker 3ee2e482ce fix(app): preserve Windows panel top outlines (#46090) 2026-08-29 04:40:55 +00:00
Kit Langton 849824efd2 refactor(core): merge defaults for selected MCP servers (#46072) 2026-08-28 23:48:29 -04:00
Kit Langton cf2c3a536d refactor(core): reuse catalog response digest (#46071) 2026-08-28 23:41:49 -04:00
opencode-agent[bot] 7852cecd72 chore: update nix node_modules hashes 2026-08-29 03:33:15 +00:00
Kit Langton 6cfffeb031 refactor(core): avoid encoding rejected image candidates (#46073) 2026-08-28 23:32:58 -04:00
Kit Langton 6e954f75ee refactor(core): isolate Session admission and controls (#46019)
Separate ID-bound Session policy from host routing. Bind Inbox and Location preparation dependencies at construction, preserve admission and execution semantics, and cover the extracted ownership contracts directly.
2026-08-28 23:22:25 -04:00
Kit Langton 0116a98371 refactor(tui): share app lifecycle test fixture
Reuse a file-local fixture for renderer, HTTP server, event stream, app startup, and teardown across lifecycle tests. Preserve scenario-specific handlers, configuration, deferred responses, and assertions while removing 187 lines of repeated setup.
2026-08-28 23:19:49 -04:00
Kit Langton a38cbd42aa refactor(core): isolate shell tool preparation
Name the tool-owned pre-spawn preparation boundary while preserving hook edits, permission ordering, directory validation, and effective timeout reporting. Strengthen the existing regression assertions.
2026-08-28 23:17:48 -04:00
Luke Parker 4ab31867c4 fix(app): reduce session-switch latency (#46044) 2026-08-29 13:16:53 +10:00
Luke Parker 51a082cea3 fix(core): release exited shell execution state (#46058) 2026-08-29 03:08:50 +00:00
Kit Langton e3bda5e2d0 feat(tui): add branch review scopes 2026-08-28 22:46:23 -04:00
Kit Langton ad9117b107 fix(session): run user shells immediately in the background
Run user shell commands concurrently with model execution, retain their output in one shell entry, and admit non-waking completion messages. Share result capture and notification formatting across shell callers.
2026-08-28 22:46:20 -04:00
Kit Langton 6b7a1d419c fix(core): preserve Responses tool identities (#45658) 2026-08-28 22:14:13 -04:00
180 changed files with 10850 additions and 3155 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ on:
branches:
- dev
- production
- beta
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -15,7 +16,7 @@ permissions:
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production' || github.ref_name == 'beta')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
@@ -28,6 +29,7 @@ jobs:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
if: github.ref_name != 'beta'
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
+2 -9
View File
@@ -1,4 +1,5 @@
import { domain } from "./stage"
import { createWebApp } from "./webapp"
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
@@ -59,12 +60,4 @@ new sst.cloudflare.x.Astro("Web", {
},
})
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
createWebApp("app." + domain)
+18
View File
@@ -0,0 +1,18 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
domain,
path: "packages/app",
environment:
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
build: {
// Preserve Sentry credentials and run source-map uploads on every deployment.
command: "bun run build",
output: "./dist",
},
})
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-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="
}
}
+18 -10
View File
@@ -1343,20 +1343,28 @@ const onMessageDelta = (
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const pendingFinish = (() => {
const stopReason = event.delta?.stop_reason
if (stopReason === null || stopReason === undefined) return state.pendingFinish
const stopSequence = event.delta?.stop_sequence
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
raw: stopReason,
},
providerMetadata: finishMetadata,
}
})()
return [
{
...state,
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
pendingFinish,
},
NO_EVENTS,
]
@@ -949,6 +949,41 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves terminal state across usage-only message deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "X" },
usage: { output_tokens: 8 },
},
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
+1
View File
@@ -1,5 +1,6 @@
src/assets/theme.css
e2e/test-results
e2e/performance/results/
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
+22 -1
View File
@@ -71,4 +71,25 @@ Environment options:
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
owns `opencode.ai`. The beta GitHub environment must allow deployments from the `beta` branch; it does not
need AWS credentials.
SST manages the beta site's custom domain. The first deployment creates its DNS record and TLS certificate.
Do not create a CNAME for `beta.opencode.ai` first, because it would conflict with the Workers custom domain.
@@ -1,5 +1,36 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
await page.addInitScript(() => {
const replace = Element.prototype.replaceChildren
Element.prototype.replaceChildren = function (this: Element, ...nodes) {
// The ref can run before data-component is assigned, so count on every target.
this.setAttribute("data-test-replacements", String(Number(this.getAttribute("data-test-replacements")) + 1))
return replace.apply(this, nodes)
}
})
const component = await mount("opencode-composer-flow--failed-submission-restoration")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
await expect(input).toHaveText("Preserve this draft on failure")
await expect(input).toHaveAttribute("data-test-replacements", "1")
await input.press("Home")
await input.press("Shift+ArrowRight")
await input.pressSequentially("XY")
await expect(input).toHaveText("XYreserve this draft on failure")
await expect(input).toHaveAttribute("data-test-replacements", "1")
// Closing the model picker restores the controller's saved caret through its editor ref.
await component.locator('[data-action="composer-model"]').click()
await page.getByRole("menu").getByRole("textbox").press("Escape")
await expect(input).toBeFocused()
await input.pressSequentially("!")
await expect(input).toHaveText("XY!reserve this draft on failure")
await component.getByRole("button", { name: "Send", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Submission failed; draft restored")
await expect(input).toHaveText("Preserve this draft on failure")
})
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
@@ -0,0 +1,135 @@
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
const host = document.createElement("main")
host.dataset.testid = "timeline-virtualizer-fixture"
host.dataset.scrolls = "0"
host.dataset.viewportResizes = "0"
host.style.cssText = "position:fixed;top:24px;right:24px;width:400px;z-index:1000"
document.body.appendChild(host)
function Fixture() {
const [state, setState] = createStore({ pinned: true, ready: false })
const rows = Array.from(
{ length: input.count },
(_, index) => new TimelineRow.UserMessage({ userMessageID: `message-${index}` }),
)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row]))
const indexes = new Map(rows.map((row, index) => [row.userMessageID, index]))
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement
const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
projection: {
rows: () => rows,
rowByKey: () => rowByKey,
activeMessageID: () => undefined,
messageRowIndex: () => indexes,
messageLastRowIndex: () => indexes,
},
showHeader: () => false,
pinned: () => state.pinned,
scroll: () => ({ overflow: false, jump: false }),
setScrollRef: (element) => {
if (!element) return
viewport = element
resize.observe(element, { box: "border-box" })
},
setContentRef: (element) => {
content = element
reveal.observe(element, { attributes: true, attributeFilter: ["style"] })
},
onPin: () => setState("pinned", true),
onUnpin: () => setState("pinned", false),
onScheduleScrollState: (element) => {
host.dataset.scrolls = String(Number(host.dataset.scrolls) + 1)
host.dataset.lastScrollTop = String(element.scrollTop)
},
onResumeScroll: () => {},
onSelectionInteraction: () => {},
onUserScroll: () => {},
onHistoryScroll: () => {},
canRenderImmediately: () => input.immediate ?? false,
})
const resize = new ResizeObserver((entries) => {
host.dataset.observedHeight = String(entries[0].borderBoxSize[0].blockSize)
host.dataset.viewportResizes = String(Number(host.dataset.viewportResizes) + 1)
})
const reveal = new MutationObserver(() => {
if (content.style.visibility === "hidden" || host.dataset.firstReveal) return
// Capture the first reveal, not a later frame after geometry has recovered.
const mounted = [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")]
host.dataset.firstReveal = JSON.stringify({
rows: mounted.map((element) => Number(element.firstElementChild!.getAttribute("data-index"))),
pendingMarkdown: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
viewportHeight: viewport.clientHeight,
scrollTop: viewport.scrollTop,
clipped: mounted
.filter((element) => element.firstElementChild!.getBoundingClientRect().height > element.offsetHeight + 1)
.map((element) => element.dataset.timelineKey),
})
})
onCleanup(() => {
resize.disconnect()
reveal.disconnect()
})
return (
<div data-testid="timeline-controls" data-pinned={state.pinned}>
<button type="button" onClick={() => setState("ready", true)}>
Complete Markdown
</button>
<button type="button" onClick={() => (container.style.display = "none")}>
Hide viewport
</button>
<button
type="button"
onClick={() => {
const parent = viewport.parentElement!
host.dataset.scrolls = "0"
// Keep the same scroller and complete Markdown while it has no layout box.
viewport.remove()
viewport.scrollTop = 0
setState("ready", true)
parent.prepend(viewport)
container.style.removeProperty("display")
}}
>
Reconnect ready rows
</button>
<div ref={container} style={{ height: "180px", width: "400px" }}>
<timeline.View
header={null}
workspaceSession={() => false}
deferred={() => false}
renderRow={(row) => (
<div
data-component="markdown"
data-markdown-ready={state.ready ? "" : undefined}
style={{ height: `${input.rowHeight}px` }}
>
{row().userMessageID}
</div>
)}
/>
</div>
</div>
)
}
render(
() => (
<LanguageProvider locale="en">
<Fixture />
</LanguageProvider>
),
host,
)
}
@@ -0,0 +1,79 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./timeline-virtualizer.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
})
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer({ count: 100, rowHeight: 60, immediate: true })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await expect(root).toHaveAttribute("data-observed-height", "180")
await expect(content).toHaveCSS("visibility", "hidden")
await expect(content.locator("[data-timeline-key]")).toHaveCount(4)
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
rows: [96, 97, 98, 99],
pendingMarkdown: 0,
clipped: [],
viewportHeight: 180,
})
})
for (const input of [
{ name: "offset-only", count: 1, rowHeight: 600 },
{ name: "zero-height", count: 4, rowHeight: 60 },
]) {
story(`reveals ready measured rows after an ${input.name} reconnect`, async ({ page }) => {
await page.evaluate(
async ({ fixture, input }) => {
const { mountTimelineVirtualizer } = await import(fixture)
mountTimelineVirtualizer(input)
},
{ fixture, input },
)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await expect(root).toHaveAttribute("data-observed-height", "180")
await expect(content).toHaveCSS("visibility", "hidden")
await expect(content.locator("[data-timeline-key]")).toHaveCount(1)
if (input.name === "offset-only") {
await expect(root).toHaveAttribute("data-last-scroll-top", "484")
await root.locator("[data-scrollable]").dispatchEvent("wheel", { deltaY: -1 })
await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
}
if (input.name === "zero-height") {
await root.getByRole("button", { name: "Hide viewport", exact: true }).click()
// Wait for ResizeObserver to clear the actual range, not just for display:none.
await expect(root).toHaveAttribute("data-observed-height", "0")
await expect(content.locator("[data-timeline-key]")).toHaveCount(0)
}
await expect(root).not.toHaveAttribute("data-first-reveal")
const resizes = await root.getAttribute("data-viewport-resizes")
await root.getByRole("button", { name: "Reconnect ready rows", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
await expect(root).toHaveAttribute("data-first-reveal", /.+/)
expect(await root.evaluate((element) => JSON.parse(element.dataset.firstReveal!))).toMatchObject({
rows: input.count === 1 ? [0] : [0, 1, 2, 3],
pendingMarkdown: 0,
clipped: [],
viewportHeight: 180,
...(input.name === "offset-only" ? { scrollTop: 0 } : {}),
})
if (input.name === "offset-only") {
// This repair must not depend on another native scroll or resize delivery.
await expect(root).toHaveAttribute("data-scrolls", "0")
await expect(root).toHaveAttribute("data-viewport-resizes", resizes!)
}
})
}
+51 -4
View File
@@ -65,7 +65,7 @@ The fixture requires every benchmark to call `report()`, automatically names and
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
```
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, optional trace path, and trace scope before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide; the default window is page lifetime. Tab-switch traces begin after scenario setup and include explicit interaction markers. Scenario metrics use their own narrower observation windows.
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
@@ -81,13 +81,60 @@ Committed smoke and regression tests continue to own correctness coverage for pa
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=20` for a baseline distribution. Warm scenarios prepare the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
The tab-switch workload uses two equally long sessions: 200 user/assistant exchanges (400 messages) per tab. Every answer includes headings, emphasis, links, a blockquote, task and nested lists, an eight-row table, and four highlighted code fences (TSX, JSON, SQL, Bash), alongside the stress fixture's reasoning and tools. The mock API deliberately returns all 400 messages in one response so every scenario measures a long loaded history, not a short paginated tail. The viewport is fixed at 1440 x 900. Results include the fixture version, Markdown and serialized-message byte counts, and message-request count. These numbers are not directly comparable to the earlier 12-exchange source / 72-exchange destination fixture.
Cold means the destination transcript has never rendered in that fresh browser context. Warm means its complex answer was rendered and ready before switching away and back. Both use the app's normal restored-tab data prefetch, which completes before measurement; neither includes app startup, the source session's Markdown engine initialization, or a cold backend fetch. The suite asserts no message fetch during either measured switch. Setup waits for mounted Markdown to finish and for the review-pane width transition to complete. Service workers are blocked to exclude the web build's background asset precache from this renderer benchmark. Screenshots are attached after measurement for the first repetition; Playwright video and trace recording are disabled for this workload, while opt-in Chrome profiling remains available. For a baseline distribution, use `--repeat-each=20 --retries=0`, keep profiling disabled, and report the median and p95 of `firstCorrectObservedMs` separately from the three-observation `stableObservedMs`.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --retries=0
```
**The tab-switch fixture is not an end-to-end cold-data benchmark.** It prefetches destination messages and returns full history. Measure cold API navigation, Home-row opening, and prefetched-but-unvisited tabs separately with normal pagination. Do not combine these entry paths or compare different transports and machine-load periods as one experiment.
Keep one-off reports, recorded results, and traces outside git, in the ignored `e2e/performance/results/` directory or an external artifact directory. Preserve raw observations locally and publish anonymized summaries and charts in the PR description, not as committed experiment files.
For a repeatable tab-switch summary, run from `packages/app`:
```sh
bun run bench:tabs
```
This runs only the tab-switch benchmark against the production build with 20 serial repetitions and no retries. It prints the median (mean of the two middle values for even sample counts) and nearest-rank p95 for `firstCorrectObservedMs` and `stableObservedMs` per scenario. Only records whose benchmark and Playwright statuses are passed and whose two metrics are finite enter the summary. Test and record statuses, missing records, and excluded samples are reported separately.
For fresh entry paths, run `bun run bench:entry` from `packages/app`. It uses the same production, serial-repetition, and reporting defaults. The cases open an empty draft from the actual Home button, create a draft with the titlebar plus from an active session, and open a cold paginated session from Home. Draft readiness requires a focused editable composer, the expected model, project control, and new tab; typing and absence of backend mutations are checked afterward. Session readiness requires the latest group, ready answer Markdown, and bottom anchoring. These cases are separate from prefetched tab remounts.
For milestone charts, rerun frozen builds with one workload and counterbalanced serial order. Do not connect historical medians from different transports, preparation, or machine-load periods. Show samples or ranges, name the checkpoints accurately, and distinguish experimental build snapshots from Git commits.
Complete original `BENCHMARK` JSON records, including samples, context, and failed records, are saved as `tab-switch-benchmark.jsonl` in Playwright's configured output directory (default: `e2e/test-results/performance`). Standard Playwright flags can override defaults when appended:
```sh
bun run bench:tabs --repeat-each=3 --output=e2e/test-results/tabs-smoke
```
Set `OPENCODE_PERFORMANCE_MEMORY=1` for an opt-in renderer-main-isolate heap and DOM sample after mounted content is ready and an explicit GC completes. Probe DOM references are released before collection. This is not total desktop memory; do not mix these diagnostic runs with unprofiled latency samples. Set `OPENCODE_PERFORMANCE_TRACE_DIR` for a separate Chrome trace of each tab interaction, starting after preparation, with `session-switch:start`, `session-switch:ready`, and `session-switch:stable` markers.
### Cache-Enabled HTTP Fixture
The default tab harness uses Playwright routing for API responses. Playwright routing disables the browser HTTP cache, including for unrelated SVG assets. To measure with HTTP caching enabled, the same API handlers and tab data can run on a real loopback HTTP endpoint:
```sh
bun run build
bun e2e/performance/tab-switch-server.ts --port 4639 --dist dist
```
With that fixture running, run the benchmark in a separate terminal from `packages/app`:
```powershell
$env:PLAYWRIGHT_BASE_URL = "http://127.0.0.1:4639"
$env:OPENCODE_PERFORMANCE_HTTP_FIXTURE = "1"
bun run bench:tabs
```
Use `--dist` to select a frozen production bundle when comparing revisions. An explicit `PLAYWRIGHT_BASE_URL` means the benchmark does not rebuild or start another preview. The fixture gives hashed assets immutable cache headers; it serves the deterministic read workload, not the live OpenCode service. Each test still gets a fresh browser context, and source-session setup still occurs before the measured switch. API responses use `no-store`, service workers remain blocked, and no destination Markdown is rendered before a cold switch. Records identify the transport as `http` or `playwright-route`; keep these series separate. Unset `OPENCODE_PERFORMANCE_HTTP_FIXTURE` when returning to the default routed harness.
## Retained renderer memory
Run the catalog workload against the production app bundle:
@@ -111,7 +158,7 @@ bunx playwright test --config e2e/performance/playwright.config.ts \
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
Trace capture follows [Puppeteer's tracing lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. V8 CPU sample stacks support attribution through the frozen build's source maps. Set `OPENCODE_PERFORMANCE_STACK_TRACE=1` only when per-event timeline stacks are needed; they add substantial overhead. Keep profiled runs separate from latency distributions, including when comparing the stack-capture modes.
```sh
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
+17 -7
View File
@@ -5,16 +5,20 @@ type BenchmarkFixtures = {
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
benchmarkResult: void
traceScope: "page" | "interaction"
}
export type PerformancePageDiagnostics = {
navigations: string[]
traceScope: "page" | "interaction"
startTrace: () => Promise<void>
stop: () => Promise<string | undefined>
}
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
export const benchmark = base.extend<BenchmarkFixtures>({
traceScope: ["page", { option: true }],
reportState: async ({}, use) => use({}),
report: async ({ reportState }, use) => {
await use((metrics, context = {}) => {
@@ -49,9 +53,9 @@ export const benchmark = base.extend<BenchmarkFixtures>({
},
{ auto: true },
],
page: async ({ page }, use, testInfo) => {
page: async ({ page, traceScope }, use, testInfo) => {
const name = benchmarkName(testInfo)
const diagnostics = await observePerformancePage(page, name)
const diagnostics = await observePerformancePage(page, name, traceScope)
try {
await use(page)
} finally {
@@ -75,25 +79,30 @@ function benchmarkName(testInfo: TestInfo) {
export { expect }
async function observePerformancePage(page: Page, name: string) {
async function observePerformancePage(page: Page, name: string, traceScope: "page" | "interaction" = "page") {
const navigations: string[] = []
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
if (frame === page.mainFrame()) navigations.push(frame.url())
}
page.on("framenavigated", onNavigation)
const stopTrace = await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
let stopTrace: Awaited<ReturnType<typeof startChromeTrace>>
let stopping: Promise<string | undefined> | undefined
const diagnostics: PerformancePageDiagnostics = {
navigations,
traceScope,
async startTrace() {
stopTrace ??= await startChromeTrace(page, name).catch((error) => {
page.off("framenavigated", onNavigation)
throw error
})
},
stop() {
page.off("framenavigated", onNavigation)
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
},
}
pages.set(page, diagnostics)
if (traceScope === "page") await diagnostics.startTrace()
return diagnostics
}
@@ -130,6 +139,7 @@ async function reportPerformancePage(name: string, diagnostics: PerformancePageD
context: {
platform: process.platform,
trace,
traceScope: diagnostics.traceScope,
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
},
navigations: diagnostics.navigations,
+3 -1
View File
@@ -14,7 +14,6 @@ const categories = [
"blink.console",
"blink.user_timing",
"latencyInfo",
"disabled-by-default-devtools.timeline.stack",
"disabled-by-default-v8.cpu_profiler",
]
@@ -34,6 +33,9 @@ export async function startChromeTrace(page: Page, name: string): Promise<undefi
.map((category) => category.slice(1)),
includedCategories: [
...categories.filter((category) => !category.startsWith("-")),
...(process.env.OPENCODE_PERFORMANCE_STACK_TRACE === "1"
? ["disabled-by-default-devtools.timeline.stack"]
: []),
...(selectors
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
: []),
@@ -0,0 +1,99 @@
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
type BenchmarkRecord = {
status?: string
metrics?: { firstCorrectObservedMs?: unknown; stableObservedMs?: unknown } | null
}
export default class TabSwitchReporter implements Reporter {
private output = ""
private tests: TestCase[] = []
private results: { test: TestCase; status: TestResult["status"]; records: string[] }[] = []
onBegin(config: FullConfig, suite: Suite) {
this.output = config.projects[0].outputDir
this.tests = suite.allTests()
}
onTestEnd(test: TestCase, result: TestResult) {
this.results.push({
test,
status: result.status,
records: Buffer.concat(result.stdout.map((chunk) => (typeof chunk === "string" ? Buffer.from(chunk) : chunk)))
.toString("utf8")
.split(/\r?\n/)
.filter((line) => line.startsWith("BENCHMARK "))
.map((line) => line.slice("BENCHMARK ".length)),
})
}
async onEnd(result: FullResult) {
const file = path.join(this.output, "tab-switch-benchmark.jsonl")
try {
await mkdir(this.output, { recursive: true })
await writeFile(file, this.results.flatMap((entry) => entry.records.map((raw) => `${raw}\n`)).join(""), "utf8")
} catch (error) {
console.error("Could not save tab-switch benchmark records:", error)
return { status: "failed" as const }
}
console.log(`\nTab-switch benchmark: ${result.status}`)
Array.from(new Set(this.tests.map((test) => test.title))).forEach((name) => {
const results = this.results.filter((entry) => entry.test.title === name)
const unrun = this.tests.filter(
(test) => test.title === name && !results.some((entry) => entry.test.id === test.id),
).length
const records = results.flatMap((entry) =>
entry.records.map((raw) => {
try {
return { status: entry.status, record: JSON.parse(raw) as BenchmarkRecord | null }
} catch {
return { status: entry.status, record: { status: "invalid JSON", metrics: null } }
}
}),
)
const passed = records.filter((entry) => entry.status === "passed" && entry.record?.status === "passed")
const valid = passed
.map((entry) => ({
firstCorrectObservedMs: entry.record?.metrics?.firstCorrectObservedMs,
stableObservedMs: entry.record?.metrics?.stableObservedMs,
}))
.filter(
(metrics): metrics is { firstCorrectObservedMs: number; stableObservedMs: number } =>
typeof metrics.firstCorrectObservedMs === "number" &&
Number.isFinite(metrics.firstCorrectObservedMs) &&
typeof metrics.stableObservedMs === "number" &&
Number.isFinite(metrics.stableObservedMs),
)
console.log(`\n${name}`)
console.log(` Tests: ${counts(results.map((entry) => entry.status))}; unrun=${unrun}`)
console.log(
` Records: ${counts(records.map((entry) => entry.record?.status ?? "missing status"))}; ` +
`missing=${results.filter((entry) => entry.records.length === 0).length + unrun}; ` +
`excluded=${records.length - valid.length}; invalid metrics=${passed.length - valid.length}`,
)
;(["firstCorrectObservedMs", "stableObservedMs"] as const).forEach((metric) => {
const values = valid.map((entry) => entry[metric]).sort((a, b) => a - b)
if (values.length === 0) {
console.log(` ${metric}: n=0, median=n/a, p95=n/a`)
return
}
const median = (values[Math.floor((values.length - 1) / 2)] + values[Math.floor(values.length / 2)]) / 2
const p95 = values[Math.ceil(values.length * 0.95) - 1]
console.log(` ${metric}: n=${values.length}, median=${median.toFixed(2)} ms, p95=${p95.toFixed(2)} ms`)
})
})
console.log(`\nRaw BENCHMARK records: ${file}`)
}
}
function counts(statuses: string[]) {
return (
Array.from(new Set(statuses))
.map((status) => `${status}=${statuses.filter((value) => value === status).length}`)
.join(", ") || "none"
)
}
@@ -0,0 +1,61 @@
import path from "node:path"
import { parseArgs } from "node:util"
import { createMockServerHandler } from "../utils/mock-server"
import { fixture } from "./timeline/session-timeline-stress.fixture"
import { messages } from "./timeline/session-tab-switch.fixture"
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
const args = parseArgs({
args: Bun.argv.slice(2),
options: { port: { type: "string", default: "4639" }, dist: { type: "string", default: "dist" } },
})
const directory = path.resolve(args.values.dist)
const api = createMockServerHandler({
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: fixture.sessions,
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
vcsDiff: createReviewDiffs(),
})
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(args.values.port),
idleTimeout: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"id":"evt_fixture_connected","type":"server.connected","data":{}}\n\n'),
)
},
}),
{ headers: { "content-type": "text/event-stream", "cache-control": "no-store" } },
)
}
if (url.pathname.startsWith("/api/")) {
const response = await api.handler(request)
response.headers.set("cache-control", "no-store")
return response
}
const file = Bun.file(path.join(directory, url.pathname))
if (!url.pathname.endsWith("/") && (await file.exists())) {
return new Response(file, {
headers: {
"cache-control": url.pathname.startsWith("/_assets/") ? "public, max-age=31536000, immutable" : "no-cache",
},
})
}
return new Response(Bun.file(path.join(directory, "index.html")), { headers: { "cache-control": "no-cache" } })
},
})
console.log(`Tab fixture: ${server.url} (${directory})`)
const close = async () => {
await server.stop(true)
await api.dispose()
}
process.once("SIGINT", close)
process.once("SIGTERM", close)
@@ -0,0 +1,33 @@
import type { NavigationMilestoneSample } from "./navigation-milestones"
import { measureNavigationMilestones } from "./navigation-milestones"
import { benchmark, expect } from "../benchmark"
benchmark(
"navigation milestones start at mousedown and wait for the expected ready controls",
async ({ page, report }) => {
await page.setContent('<button id="open">Open</button><input id="editor" disabled><span id="model">Loading</span>')
const result = await measureNavigationMilestones(page, {
triggerSelector: "#open",
milestones: { editor: { selector: "#editor:enabled:focus" }, model: { selector: "#model", text: "Ready model" } },
navigate: async () => {
await page.getByRole("button", { name: "Open", exact: true }).dispatchEvent("mousedown", { button: 0 })
await page.locator("#editor").evaluate((element: HTMLInputElement) => {
element.disabled = false
element.focus()
})
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: { samples: NavigationMilestoneSample[] } })
.__navigationMilestones?.samples
return samples?.some((sample) => sample.milestones.editor && !sample.milestones.model)
})
await page.locator("#model").evaluate((element) => {
element.textContent = "Ready model"
})
},
})
expect(result.summary.all.firstObservedMs).not.toBeNull()
expect(result.summary.all.firstObservedMs).toBeGreaterThan(result.summary.milestones.editor.firstObservedMs!)
expect(await page.evaluate(() => "__navigationMilestones" in window)).toBe(false)
report(result)
},
)
@@ -36,7 +36,7 @@ export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean }>
milestones: Record<string, { selector: string; visible?: boolean; text?: string }>
navigate: () => Promise<void>
},
) {
@@ -47,11 +47,19 @@ export async function measureNavigationMilestones(
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string) =>
const visible = (selector: string, text?: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
if (text !== undefined && element.textContent?.replace(/\s+/g, " ").trim() !== text) return false
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.top < innerHeight &&
rect.right > 0 &&
rect.left < innerWidth
)
})
const sample = () => {
if (!running || started === undefined) return
@@ -61,7 +69,9 @@ export async function measureNavigationMilestones(
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
milestone.visible === false
? !document.querySelector(milestone.selector)
: visible(milestone.selector, milestone.text),
]),
)
samples.push({
@@ -93,36 +103,46 @@ export async function measureNavigationMilestones(
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.start")
sample()
}
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
},
{ triggerSelector: input.triggerSelector, milestones: input.milestones },
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
try {
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
return (
samples &&
samples.length >= 3 &&
samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
)
})
const samples = await page.evaluate(
() => (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!.samples,
)
return { summary: summarizeNavigationMilestones(samples), samples }
} finally {
await page.evaluate(() => {
const host = window as Window & { __navigationMilestones?: NavigationMilestoneProbe }
host.__navigationMilestones?.stop()
delete host.__navigationMilestones
})
}
}
@@ -0,0 +1,91 @@
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
benchmark.use({
viewport: { width: 1440, height: 900 },
serviceWorkers: "block",
traceScope: "interaction",
trace: "off",
video: "off",
})
for (const entry of ["home", "session"] as const) {
benchmark(`entry: new session from ${entry}`, async ({ page, report }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page, { sessionIDs: entry === "home" ? [] : [fixture.sourceID] })
await page.goto(entry === "home" ? "/" : stressSessionHref(fixture.sourceID))
if (entry === "session") await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const trigger = entry === "home" ? '[data-action="home-new-session"]' : 'button[aria-label="New session"]'
await expect(page.locator(trigger)).toBeVisible()
await expect(page.locator('[data-component="new-session"]')).toHaveCount(0)
const writes: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/") && !["GET", "HEAD", "OPTIONS"].includes(request.method()))
writes.push(request.method())
})
await benchmarkDiagnostics(page).startTrace()
const result = await measureNavigationMilestones(page, {
triggerSelector: trigger,
milestones: {
editor: {
selector: '[data-component="new-session"] [data-component="composer-editor"][contenteditable="true"]:focus',
},
model: { selector: '[data-component="new-session"] [data-action="composer-model"]', text: "Claude Opus 4.6" },
project: { selector: '[data-component="new-session"] [data-action="prompt-project"]' },
tab: { selector: '[data-titlebar-tab-link][href^="/new-session?draftId="]' },
},
navigate: () => page.locator(trigger).click(),
})
await benchmarkDiagnostics(page).stop()
const editor = page.locator('[data-component="new-session"] [data-component="composer-editor"]')
await expect(editor).toHaveText("")
await page.keyboard.type("Draft input")
await expect(editor).toHaveText("Draft input")
expect(writes).toEqual([])
report(
{
firstCorrectObservedMs: result.summary.all.firstObservedMs,
stableObservedMs: result.summary.all.stableObservedMs,
...result,
},
{ entry, data: "fixture", inputEvent: "mousedown" },
)
})
}
benchmark("entry: cold session from Home", async ({ page, report }) => {
const requests: string[] = []
await mockStressTimeline(page, {
onMessages: (request) => {
if (request.phase === "start") requests.push(request.sessionID)
},
})
await installStressSessionTabs(page, { sessionIDs: [] })
await page.goto("/")
const selector = `[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"] [data-component="home-session-row"]`
await expect(page.locator(selector)).toBeVisible()
expect(requests).not.toContain(fixture.targetID)
const href = stressSessionHref(fixture.targetID)
await benchmarkDiagnostics(page).startTrace()
const result = await measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: [],
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1)!,
href,
triggerSelector: selector,
switch: async () => {
await page.locator(selector).click()
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
},
})
await benchmarkDiagnostics(page).stop()
await expect(
page.locator(`[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-link][href="${href}"]`),
).toHaveCount(1)
expect(requests).toContain(fixture.targetID)
report(result, { entry: "home", data: "cold paginated fixture", inputEvent: "mousedown" })
})
@@ -1,71 +1,129 @@
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import { fixture } from "./session-timeline-stress.fixture"
import { expected, messages, workload } from "./session-tab-switch.fixture"
import {
createReviewDiffs,
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
const scenarios = [
{ cached: false, review: false, resized: false },
{ cached: false, review: true, resized: false },
{ cached: true, review: false, resized: false },
{ cached: true, review: true, resized: false },
{ cached: true, review: true, resized: true },
]
{ cache: "cold", review: "closed" },
{ cache: "cold", review: "open" },
{ cache: "warm", review: "closed" },
{ cache: "warm", review: "open" },
{ cache: "warm", review: "resized" },
] as const
const viewport = { width: 1440, height: 900 }
const reviewDiffs = createReviewDiffs()
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
scenarios.forEach((scenario) => {
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
benchmark(`tab switch: ${scenario.cache}, review ${scenario.review}`, async ({ page, report }, testInfo) => {
const requests = await prepareSessionTabs(page)
if (scenario.review === "open") await openReviewPane(page)
if (scenario.cache === "warm") {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
await expectReadyTimeline(page, fixture.targetID)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
if (scenario.review === "resized") await openReviewPane(page)
await expectReadyTimeline(page, fixture.sourceID)
await benchmarkDiagnostics(page).startTrace()
const requestsBefore = requests.length
return measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1),
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
const result = await measureSessionSwitch(page, {
destinationIDs: messages[fixture.targetID].map((message) => message.id),
sourceIDs: messages[fixture.sourceID].map((message) => message.id),
lastID: expected[fixture.targetID].lastID,
requiredPartID: expected[fixture.targetID].answerID,
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(requests).toHaveLength(requestsBefore)
await expectReadyTimeline(page, fixture.targetID)
report(
{
...result,
messageRequestsDuringSwitch: requests.length - requestsBefore,
rendererMemory:
process.env.OPENCODE_PERFORMANCE_MEMORY === "1" ? await retainedRendererMemory(page) : undefined,
},
{
...scenario,
...workload,
viewport,
browserVersion: page.context().browser()!.version(),
serviceWorkers: "blocked",
reviewFiles: scenario.review === "closed" ? 0 : reviewDiffs.length,
data: "prefetched",
transport: process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE === "1" ? "http" : "playwright-route",
inputEvent: "mousedown",
requireReadyAnswer: true,
},
testInfo,
)
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
if (testInfo.repeatEachIndex === 0) {
await page.screenshot({ path: testInfo.outputPath("destination.png") })
await testInfo.attach("destination", { path: testInfo.outputPath("destination.png"), contentType: "image/png" })
}
})
})
async function prepareSessionTabs(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "GET") return
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
if (match) requests.push(decodeURIComponent(match[1]))
})
if (process.env.OPENCODE_PERFORMANCE_HTTP_FIXTURE !== "1")
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
// Return the full history so every scenario exercises a long loaded timeline.
pageMessages: (sessionID) => ({ items: messages[sessionID] ?? [] }),
vcsDiff: reviewDiffs,
})
await installTimelineSettings(page)
await installStressSessionTabs(page)
// Restored tabs prefetch their data even when their transcript has never rendered.
const prefetch = page.waitForResponse((response) =>
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
)
await page.goto(stressSessionHref(fixture.sourceID))
expect(await (await prefetch).finished()).toBeNull()
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expectReadyTimeline(page, fixture.sourceID)
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
expect(requests.toSorted()).toEqual([fixture.sourceID, fixture.targetID].toSorted())
return requests
}
async function expectReadyTimeline(page: Page, sessionID: string) {
const answer = page.locator(`[data-timeline-part-id="${expected[sessionID].answerID}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(answer.getByRole("table")).toHaveCount(1)
await expect(answer.locator("pre")).toHaveCount(4)
await expect
.poll(() => answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })))
.toBe(true)
await waitForStableTimeline(page, expected[sessionID].lastID)
await expect(page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(
0,
)
}
async function switchSession(page: Page, sessionID: string, title: string) {
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
await expect(tab).toHaveCount(1)
@@ -80,4 +138,20 @@ async function openReviewPane(page: Page) {
const text = document.querySelector("#review-panel")?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
await page.locator('[data-slot="session-chat-panel"]').evaluate(async (panel) => {
await Promise.all(panel.getAnimations().map((animation) => animation.finished))
})
}
async function retainedRendererMemory(page: Page) {
const cdp = await page.context().newCDPSession(page)
try {
await cdp.send("HeapProfiler.collectGarbage")
return {
heap: await cdp.send("Runtime.getHeapUsage"),
dom: await cdp.send("Memory.getDOMCounters"),
}
} finally {
await cdp.detach()
}
}
@@ -2,66 +2,77 @@ import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
for (const tag of ["a", "button"] as const) {
benchmark(
`starts at ${tag} mousedown and excludes hidden or unfinished destination content`,
async ({ page, report }) => {
await page.setContent(`
<${tag} id="destination" ${tag === "a" ? 'href="/session/destination"' : 'type="button"'}>Destination</${tag}>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
await page.evaluate(() => {
document.querySelector("#destination")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
triggerSelector: tag === "button" ? "#destination" : undefined,
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page
.getByRole(tag === "a" ? "link" : "button", { name: "Destination", exact: true })
.dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples
.slice(count)
.some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
},
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
expect(await page.evaluate(() => "__sessionSwitchProbe" in window)).toBe(false)
report(result)
},
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
)
}
@@ -14,124 +14,132 @@ async function installSessionSwitchProbe(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
},
) {
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
if (!running || started === undefined) return
setTimeout(() => {
await page.evaluate(
({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, triggerSelector, href }) => {
const destination = new Set(destinationIDs)
const source = new Set(sourceIDs)
const samples: SessionSwitchSample[] = []
let started: number | undefined
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
file: '#review-panel [data-component="file"][data-mode="diff"]',
}
const initialReviewNodes: Record<string, Element | null> = {}
const sample = () => {
if (!running || started === undefined) return
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
: undefined
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
})
setTimeout(() => {
if (!running || started === undefined) return
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
const initial = initialReviewNodes[name]
if (!initial) return []
const current = document.querySelector(selector)
return current && current !== initial ? [name] : []
})
const review = reviewPanel
? {
fileHost: !!reviewFile,
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
header:
reviewPanel
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
?.textContent?.trim() ?? "",
replacedLevels,
}
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
})
: undefined
const spacer = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
last: visible.includes(lastID),
requiredPartVisible,
bottomAnchorRequired: requireBottomAnchor !== false,
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
review,
})
} else {
samples.push({
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
last: false,
requiredPartVisible: requiredPartID ? false : undefined,
bottomAnchorRequired: requireBottomAnchor !== false,
review,
})
}
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const trigger = event.target instanceof Element ? event.target.closest(triggerSelector ?? "a") : undefined
if (!trigger || (!triggerSelector && trigger.getAttribute("href") !== href)) return
started = performance.now()
performance.mark("session-switch:start", { startTime: started })
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}, 0)
}
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
Object.keys(initialReviewNodes).forEach((key) => (initialReviewNodes[key] = null))
},
}
},
input,
)
}
async function waitForStableSessionSwitch(page: Page) {
@@ -159,9 +167,17 @@ async function collectSessionSwitchResult(page: Page) {
const samples = await page.evaluate(() => {
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
probe.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
return probe.samples
})
return classifySessionSwitch(samples)
const result = classifySessionSwitch(samples)
await page.evaluate(({ firstCorrectObservedMs, stableObservedMs }) => {
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
if (firstCorrectObservedMs !== null)
performance.mark("session-switch:ready", { startTime: start + firstCorrectObservedMs })
if (stableObservedMs !== null) performance.mark("session-switch:stable", { startTime: start + stableObservedMs })
}, result)
return result
}
export async function measureSessionSwitch(
@@ -172,6 +188,7 @@ export async function measureSessionSwitch(
lastID: string
requiredPartID?: string
requireBottomAnchor?: boolean
triggerSelector?: string
href: string
switch: () => Promise<void>
},
@@ -185,6 +202,7 @@ export async function measureSessionSwitch(
} finally {
await page.evaluate(() => {
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
delete (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe
})
}
}
@@ -0,0 +1,143 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { fixture } from "./session-timeline-stress.fixture"
export const exchanges = 200
export const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((sessionID) => [
sessionID,
Array.from({ length: exchanges }, (_, index) => {
const seed = fixture.messages[fixture.targetID]
const user = seed[(index % (seed.length / 2)) * 2]!
const assistant = seed[(index % (seed.length / 2)) * 2 + 1]!
if (user.type !== "user" || assistant.type !== "assistant") throw new Error("Expected a user/assistant pair")
const suffix = `${sessionID}_${String(index).padStart(4, "0")}`
return [
{
...user,
id: `msg_user_${suffix}`,
time: { created: 1700000000000 + index * 10_000 },
},
{
...assistant,
id: `msg_assistant_${suffix}`,
time: { created: 1700000001000 + index * 10_000, completed: 1700000008000 + index * 10_000 },
content: [
...assistant.content
.filter((part) => part.type !== "text")
.map((part) => (part.type === "tool" ? { ...part, id: `${part.id}_${suffix}` } : part)),
{ type: "text", text: complexMarkdown(sessionID, index) },
],
},
] satisfies SessionMessageInfo[]
}).flat(),
]),
)
export const expected = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((sessionID) => [
sessionID,
{
lastID: messages[sessionID].at(-2)!.id,
answerID: `${messages[sessionID].at(-1)!.id}:text:0`,
},
]),
)
export const workload = {
fixture: "long-complex-markdown-v1",
exchangesPerSession: exchanges,
messagesPerSession: exchanges * 2,
history: "full fixture history in one response",
sessions: Object.fromEntries(
Object.entries(messages).map(([sessionID, items]) => [
sessionID,
{
payloadBytes: Buffer.byteLength(JSON.stringify(items)),
markdownBytes: items.reduce(
(total, message) =>
total +
(message.type === "assistant"
? message.content.reduce(
(size, part) => size + (part.type === "text" ? Buffer.byteLength(part.text) : 0),
0,
)
: 0),
0,
),
},
]),
),
}
function complexMarkdown(sessionID: string, index: number) {
return `## Renderer review ${sessionID} / ${index}
Preserve **semantic identity**, *measured geometry*, and ~~obsolete estimates~~ when switching sessions. The \`measureElement(node)\` result must agree with the [rendering contract](https://example.com/rendering/${sessionID}/${index}).
> A completed answer contains formatted prose, highlighted source, and structured results.
> Keep the previous view until the destination is ready, rather than exposing partially formatted content.
### Readiness checklist
- [x] Resolve the destination session and its messages.
- [x] Parse Markdown and highlight fenced code.
- [ ] Verify a different panel width.
- Preserve the bottom anchor.
- Reuse the measured rows when their width matches.
| Stage | Input | Expected result | Verification |
| :--- | ---: | :--- | :--- |
${Array.from({ length: 8 }, (_, row) => `| stage-${index}-${row} | ${index * 8 + row} | **ready** with \`row[${row}]\` | stable geometry and visible content |`).join("\n")}
### Implementation
\`\`\`tsx
import { For, Show, createMemo } from "solid-js"
type Row = { id: string; title: string; ready: boolean; height: number }
export function SessionRows${index}(props: { rows: Row[]; selected: string }) {
const visible = createMemo(() => props.rows.filter((row) => row.ready))
return (
<section aria-label="${sessionID}-${index}">
<For each={visible()}>{(row) => (
<article data-selected={row.id === props.selected}>
<h3>{row.title}</h3>
<Show when={row.height > 0} fallback={<span>Measuring</span>}>
<output>{row.height.toFixed(2)} pixels</output>
</Show>
</article>
)}</For>
</section>
)
}
\`\`\`
\`\`\`json
${JSON.stringify({ session: sessionID, exchange: index, stages: ["hydrate", "parse", "highlight", "measure"], viewport: { width: 1440, height: 900 }, cache: { markdown: true, geometry: true } }, null, 2)}
\`\`\`
\`\`\`sql
SELECT session_id, COUNT(*) AS messages, MAX(created_at) AS latest
FROM session_message
WHERE session_id = '${sessionID}' AND ordinal >= ${index}
GROUP BY session_id
ORDER BY latest DESC;
\`\`\`
### Verification
1. Open the long source session and wait for its final answer.
2. Select the destination tab, without changing the viewport.
3. Confirm that **all Markdown is ready** and the bottom anchor is correct.
\`\`\`bash
bun typecheck
bunx playwright test --config e2e/performance/playwright.config.ts
git diff --check # ${sessionID}-${index}
\`\`\`
**Review complete: ${sessionID} / ${index}.**
`
}
@@ -0,0 +1,114 @@
import { expect, spyOn, test } from "bun:test"
import type { FullConfig, Suite, TestCase, TestResult } from "@playwright/test/reporter"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import TabSwitchReporter from "../tab-switch-reporter"
test("summarizes each scenario and saves complete records in the configured output directory", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
const output = path.join(root, "configured-output")
const log = spyOn(console, "log").mockImplementation(() => {})
try {
const reporter = new TabSwitchReporter()
const cases = Array.from(
{ length: 23 },
(_, index) => ({ id: String(index), title: index < 20 ? "cold" : "warm" }) as TestCase,
)
const records = cases.map((_, index) => {
const first = index < 20 ? 20 - index : [40, 0, 20][index - 20]
return JSON.stringify({
status: "passed",
metrics: {
firstCorrectObservedMs: first,
stableObservedMs: first * 2,
samples: [{ observedAtMs: first, destination: ["answer"], source: [] }],
},
extra: { preserved: "\u03b1" },
})
})
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
cases.forEach((item, index) => {
const bytes = Buffer.from(`BENCHMARK ${records[index]}\r\n`)
const split = bytes.indexOf(Buffer.from("\u03b1")) + 1
reporter.onTestEnd(item, {
status: "passed",
stdout:
index === 0
? [bytes.subarray(0, split), bytes.subarray(split)]
: ["other output\nBENCHMARK_PAGE {}\nBENCH", "MARK ", records[index], "\n"],
} as TestResult)
})
await reporter.onEnd({ status: "passed", startTime: new Date(0), duration: 0 })
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(`${records.join("\n")}\n`)
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
expect(summary).toContain("cold\n Tests: passed=20; unrun=0")
expect(summary).toContain("warm\n Tests: passed=3; unrun=0")
expect(summary).toContain("firstCorrectObservedMs: n=20, median=10.50 ms, p95=19.00 ms")
expect(summary).toContain("stableObservedMs: n=20, median=21.00 ms, p95=38.00 ms")
expect(summary).toContain("firstCorrectObservedMs: n=3, median=20.00 ms, p95=40.00 ms")
expect(summary).toContain("stableObservedMs: n=3, median=40.00 ms, p95=80.00 ms")
} finally {
log.mockRestore()
await rm(root, { recursive: true, force: true })
}
})
test("reports failures, missing records, and invalid metrics without discarding raw data", async () => {
const output = await mkdtemp(path.join(os.tmpdir(), "tab-switch-reporter-"))
const log = spyOn(console, "log").mockImplementation(() => {})
try {
const reporter = new TabSwitchReporter()
const entries = [
{
status: "passed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":12,"stableObservedMs":24}}',
},
{
status: "failed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
},
{
status: "passed",
raw: '{"status":"failed","metrics":{"firstCorrectObservedMs":900,"stableObservedMs":950}}',
},
{ status: "timedOut", raw: '{"status":"failed","metrics":null,"error":"Benchmark did not report metrics"}' },
{
status: "passed",
raw: '{"status":"passed","metrics":{"firstCorrectObservedMs":null,"stableObservedMs":40}}',
},
{ status: "failed", raw: '{"status":' },
{ status: "skipped", raw: undefined },
] as const
const cases = Array.from(
{ length: entries.length + 2 },
(_, index) => ({ id: String(index), title: index <= entries.length ? "cold" : "empty" }) as TestCase,
)
reporter.onBegin({ projects: [{ outputDir: output }] } as FullConfig, { allTests: () => cases } as Suite)
entries.forEach((entry, index) => {
reporter.onTestEnd(cases[index], {
status: entry.status,
stdout: entry.raw === undefined ? [] : [`BENCHMARK ${entry.raw}\n`],
} as TestResult)
})
await reporter.onEnd({ status: "interrupted", startTime: new Date(0), duration: 0 })
expect(await readFile(path.join(output, "tab-switch-benchmark.jsonl"), "utf8")).toBe(
entries.flatMap((entry) => (entry.raw === undefined ? [] : [`${entry.raw}\n`])).join(""),
)
const summary = log.mock.calls.map((call) => call.join(" ")).join("\n")
expect(summary).toContain("Tab-switch benchmark: interrupted")
expect(summary).toContain("Tests: passed=3, failed=2, timedOut=1, skipped=1; unrun=1")
expect(summary).toContain("Records: passed=3, failed=2, invalid JSON=1; missing=2; excluded=5; invalid metrics=1")
expect(summary).toContain("firstCorrectObservedMs: n=1, median=12.00 ms, p95=12.00 ms")
expect(summary).toContain("stableObservedMs: n=1, median=24.00 ms, p95=24.00 ms")
expect(summary).toContain("empty\n Tests: none; unrun=1")
expect(summary).toContain("Records: none; missing=1; excluded=0; invalid metrics=0")
expect(summary).toContain("firstCorrectObservedMs: n=0, median=n/a, p95=n/a")
expect(summary).toContain("stableObservedMs: n=0, median=n/a, p95=n/a")
} finally {
log.mockRestore()
await rm(output, { recursive: true, force: true })
}
})
@@ -0,0 +1,99 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
for (const window of ["assistant-only", "mixed"] as const) {
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
// Both 20-message pages begin with an assistant; only page three supplies its parent.
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
const id = `msg_hydration_${index}`
const time = { created: 1700000000000 + index * 1_000 }
if (index === 0 || (window === "mixed" && index === 39))
return { id, type: "user", time, text: `Prompt ${index}` }
return {
id,
type: "assistant",
time: { ...time, completed: time.created + 500 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
}
})
const gates = [21, 1].map((index) => ({
before: messages[index]!.id,
parent: messages[index === 21 ? 1 : 0]!.id,
requested: Promise.withResolvers<void>(),
release: Promise.withResolvers<void>(),
}))
const requests: (string | undefined)[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
beforeMessagesResponse: async ({ before }) => {
requests.push(before)
if (!before) return
const gate = gates.find((gate) => gate.before === before)
if (!gate) throw new Error(`Unexpected older-page boundary: ${before}`)
gate.requested.resolve()
await gate.release.promise
},
pageMessages: (_, limit, before) => {
expect(limit).toBe(20)
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
},
})
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
const markdown = tail.locator('[data-component="markdown"]')
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
const viewport = page.locator(".scroll-view__viewport", { has: tail })
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
})
const expectReadyTail = async () => {
await expect(content).toHaveCSS("visibility", "visible")
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(markdown.getByRole("heading", { name: "Hydrated tail", exact: true })).toBeInViewport({ ratio: 1 })
await expect
.poll(() =>
viewport.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
)
.toBeLessThanOrEqual(1)
}
try {
await page.goto(stressSessionHref(session.id))
await gates[0]!.requested.promise
// This must pass while the first older response is still held.
await expectReadyTail()
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
if (window === "mixed")
await expect(
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
).toBeInViewport()
const original = await markdown.elementHandle()
for (const gate of gates) {
await gate.requested.promise
gate.release.resolve()
// Parent ownership proves the page reached the projection, not just the network.
await expect(orphan).toHaveAttribute("data-message-id", gate.parent)
await expectReadyTail()
expect(await markdown.evaluate((element, original) => element === original, original)).toBe(true)
}
expect(requests).toEqual([undefined, ...gates.map((gate) => gate.before)])
const ids = await content
.locator("[data-timeline-part-id]")
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-part-id")))
expect(new Set(ids).size).toBe(ids.length)
} finally {
gates.forEach((gate) => gate.release.resolve())
}
})
}
@@ -0,0 +1,123 @@
import { expect, test, type Page } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { expected, messages } from "../performance/timeline/session-tab-switch.fixture"
import { installTimelineSettings, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
type Reveal = { pending: number; clipped: string[]; bottomError: number; tables: number; codeBlocks: number }
for (const width of [1440, 390]) {
test(`reveals measured Markdown after the worker completes at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(/markdown\.worker(?:-[^/?]+\.js|\.ts)(?:\?.*)?$/, async (route) => {
requested.resolve()
await release.promise
await route.continue()
})
await page.addInitScript((partID) => {
const observer = new MutationObserver(() => {
const answer = document.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const content = answer?.closest<HTMLElement>("[data-timeline-virtual-content]")
const root = content?.closest<HTMLElement>(".scroll-view__viewport")
if (!answer || !content || !root || !content.checkVisibility({ checkVisibilityCSS: true })) return
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
? [row.dataset.timelineKey!]
: [],
),
bottomError: (spacer?.getBoundingClientRect().bottom ?? Infinity) - root.getBoundingClientRect().bottom,
tables: answer.querySelectorAll("table").length,
codeBlocks: answer.querySelectorAll("pre").length,
}
observer.disconnect()
})
observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] })
}, expected[fixture.sourceID].answerID)
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
project: fixture.project,
provider: fixture.provider,
directory: fixture.directory,
pageMessages: () => ({ items: messages[fixture.sourceID] }),
})
await installTimelineSettings(page)
try {
await page.goto(stressSessionHref(fixture.sourceID), { waitUntil: "domcontentloaded" })
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
release.resolve()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
} finally {
release.resolve()
}
})
}
test("scrolls within a long answer without mounting unrelated history", async ({ page }) => {
await openTimeline(page, messages[fixture.sourceID])
const answer = page.locator(`[data-timeline-part-id="${expected[fixture.sourceID].answerID}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(answer.getByRole("table")).toHaveCount(1)
const scroller = page.locator(".scroll-view__viewport", { has: answer })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const rows = page.locator("[data-timeline-key]")
const keys = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-timeline-key")),
)
const top = await answer.evaluate((element) => element.getBoundingClientRect().top)
await scroller.hover()
await page.mouse.wheel(0, -240)
await expect.poll(() => answer.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top + 240, 0)
expect(
await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-key"))),
).toEqual(keys)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
})
test("fills a short cold transcript before revealing it", async ({ page }) => {
const history = messages[fixture.sourceID].slice(-6).map((message, index) => {
if (message.type === "user") return { ...message, text: `Prompt ${index}`, metadata: undefined }
if (message.type === "assistant")
return { ...message, content: [{ type: "text" as const, text: `**Answer ${index}**` }] }
return message
})
await openTimeline(page, history)
for (const message of history) {
if (message.type === "user") {
await expect(page.locator(`[data-timeline-row="UserMessage"][data-message-id="${message.id}"]`)).toBeInViewport()
}
if (message.type === "assistant") {
const answer = page.locator(`[data-timeline-part-id="${message.id}:text:0"]`)
await expect(answer).toBeInViewport()
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
}
}
})
async function openTimeline(page: Page, history: SessionMessageInfo[]) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
project: fixture.project,
provider: fixture.provider,
directory: fixture.directory,
pageMessages: () => ({ items: history }),
})
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
@@ -102,6 +102,14 @@ test("cramped tabs only show the close button for the active tab", async ({ page
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
for (const direction of ["ltr", "rtl"]) {
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
await page.setViewportSize({ width: 450, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
await page.setViewportSize({ width: 1280, height: 720 })
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "false")
}
})
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
@@ -288,11 +296,12 @@ async function mockServer(page: Page) {
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/inbox`)) return json(route, { data: [] })
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
@@ -355,6 +355,14 @@ test.describe("smoke: session timeline", () => {
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
// The shell is below a long diff; reveal it rather than depending on offscreen overscan.
while ((await shell.count()) === 0) {
const before = await timelineState(page)
await timelineScroller(page).press("PageDown")
await expect.poll(async () => (await timelineState(page)).signature).not.toBe(before.signature)
}
await shell.scrollIntoViewIfNeeded()
await expect(shell).toBeInViewport()
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
@@ -694,6 +702,7 @@ async function expectSessionTimelineReady(
expectedMessageIDs: string[],
errors: string[],
) {
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
await waitForTimelineStable(page)
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
const currentState = await timelineState(page)
@@ -1,10 +1,16 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project and selects its model", async ({ page }) => {
// An empty draft must remain usable when the file viewer is unavailable.
await page.route(/(?:\/_assets\/file-(?!icon-)[^/]+\.js|\/session-ui\/src\/components\/file\.tsx)(?:\?|$)/, (route) =>
route.abort(),
)
await mockOpenCodeServer(page, {
directory,
project: {
@@ -60,7 +66,7 @@ test("creates a session in a new project and selects its model", async ({ page }
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
],
recent: [],
recent: [{ providerID: "opencode-go", modelID: "go-model-1" }],
variant: {},
}),
)
@@ -81,11 +87,73 @@ test("creates a session in a new project and selects its model", async ({ page }
await expectAppVisible(page.locator('[data-component="composer"]'))
const modelControl = page.locator('[data-action="composer-model"]')
await expect(modelControl).toContainText("Go Model 1")
await modelControl.click()
await page.locator('[data-option-key="opencode:free-model"]').click()
await expect(modelControl).toContainText("Free Model")
await modelControl.click()
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
await expect(modelControl).toContainText("Go Model 1")
})
test("restores each existing session's model and variant when switching tabs", async ({ page }) => {
const sessions = ["A", "B"].map((name) => ({
...fixture.sessions[0],
id: `ses_model_${name}`,
title: `Model ${name}`,
model: { id: `model-${name}`, providerID: "opencode", variant: "balanced" },
}))
await mockOpenCodeServer(page, {
...fixture,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: Object.fromEntries(
sessions.map((session) => [
session.model.id,
{
id: session.model.id,
name: session.title,
limit: { context: 200_000 },
variants: { balanced: {}, high: {} },
},
]),
),
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: sessions[0]!.model.id },
},
pageMessages: () => ({ items: [] }),
})
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
const hrefA = stressSessionHref(sessions[0]!.id)
const hrefB = stressSessionHref(sessions[1]!.id)
await page.goto(hrefA)
const composer = page.locator('[data-component="composer"]')
const modelControl = composer.locator('[data-action="composer-model"]')
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
await expect(modelControl).toHaveText("Model A")
await expect(variant).toHaveText("balanced")
await variant.click()
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
await expect(variant).toHaveText("high")
await page.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
await expect(page).toHaveURL(hrefB)
await expect(modelControl).toHaveText("Model B")
await expect(variant).toHaveText("balanced")
await page.locator(`[data-titlebar-tab-link][href="${hrefA}"]`).click()
await expect(page).toHaveURL(hrefA)
await expect(modelControl).toHaveText("Model A")
await expect(variant).toHaveText("high")
})
+11 -8
View File
@@ -47,7 +47,6 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const state = { cursors: new Map<string, string>(), nextCursor: 0 }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
@@ -135,13 +134,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const transport = HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, state)),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
const transport = createMockServerHandler(config)
page.on("close", () => void transport.dispose())
await page.route("**/api/**", async (route) => {
@@ -173,6 +166,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
export function createMockServerHandler(config: MockServerConfig) {
return HttpRouter.toWebHandler(
HttpApiBuilder.layer(MockApi).pipe(
Layer.provide(mockHandlers(config, { cursors: new Map<string, string>(), nextCursor: 0 })),
Layer.provide(HttpServer.layerServices),
),
{ disableLogger: true },
)
}
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
+2
View File
@@ -34,6 +34,8 @@
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"bench:tabs": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-tab-switch-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
"bench:entry": "PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/playwright.config.ts timeline/session-entry-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line,./e2e/performance/tab-switch-reporter.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
},
"license": "MIT",
@@ -150,7 +150,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderComposerEditor(element, props.controller.parts())
}}
data-component="composer-editor"
role="textbox"
@@ -278,16 +278,15 @@ export function createHomeSessionsController(home: HomeController) {
const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.data.session.remember(session)
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: connKey, sessionId: session.id })
return
}
ctx.projects.touch(directory)
if (!options?.background) void ctx.data.session.message.sync(session.id).catch(() => undefined)
// Commit cache/project changes with navigation instead of rebuilding
// the outgoing Home list before leaving it.
void startTransition(() => {
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
tabs.select(tab)
if (!options?.background) tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
if (!options?.background) ctx.projects.touch(directory)
})
},
archive: async (session: SessionInfo) => {
@@ -221,7 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
const current = () => {
const current = createMemo(() => {
const item = firstModel(
() => scope()?.model,
() => agent.current()?.model,
@@ -229,7 +229,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
if (!item) return
return models.find(item)
}
})
const configured = () => {
const item = agent.current()
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
@@ -0,0 +1,117 @@
import type { SessionInfo, SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
type Task =
| { id: string; type: "subagent"; label: string; agent?: string }
| { id: string; type: "shell"; label: string }
export function createSessionBackground(input: {
sessionID: () => string | undefined
messages: (id: string) => SessionMessageInfo[]
sessions: () => SessionInfo[]
status: (id: string) => "idle" | "running"
shells: () => ShellInfo[]
}) {
const history = createMemo(() => {
const completed = new Set<string>()
const subagents: { id: string; type: "subagent"; label: string; agent: string | undefined }[] = []
const shells: { partID: string; task: { id: string; type: "shell"; label: string } }[] = []
const id = input.sessionID()
const assistant = (id ? input.messages(id) : []).reduce<SessionMessageAssistant | undefined>((latest, message) => {
if (message.type === "synthetic") {
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
completed.add(message.metadata.childID)
if (message.metadata?.source === "shell") {
if (typeof message.metadata.shellID === "string") completed.add(message.metadata.shellID)
if (typeof message.metadata.jobID === "string") completed.add(message.metadata.jobID)
}
return latest
}
if (message.type !== "assistant") return latest
message.content.forEach((part) => {
if (part.type !== "tool" || (part.name !== "subagent" && part.name !== "shell")) return
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return
if (part.name === "subagent") {
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string") return
const description = part.state.input.description
const agent = part.state.input.agent
subagents.push({
id: sessionID,
type: "subagent",
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
})
return
}
const shellID = part.state.metadata.shellID
const command = part.state.input.command
shells.push({
partID: part.id,
task: {
id: typeof shellID === "string" ? shellID : part.id,
type: "shell",
label: typeof command === "string" ? command : part.id,
},
})
})
return message.time.completed === undefined ? message : latest
}, undefined)
return {
// Completion notices can identify the shell or its original tool call.
subagents: subagents.filter((task) => !completed.has(task.id)),
shells: shells
.filter((item) => !completed.has(item.partID) && !completed.has(item.task.id))
.map((item) => item.task),
blocking:
assistant?.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
if (part.name !== "shell" && part.name !== "subagent") return []
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
]
}) ?? [],
}
})
const blocking = createMemo(() => history().blocking)
const tasks = createMemo(() => {
const id = input.sessionID()
if (!id) return []
const current = history()
const active = input.sessions().flatMap((info) => {
if (info?.parentID !== id) return []
if (input.status(info.id) === "idle") return []
if (
current.blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
)
)
return []
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
})
const running = input.shells().flatMap((shell) => {
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
if (
current.blocking.some(
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
)
)
return []
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
})
return [
...new Map<string, Task>(
[...current.subagents, ...active, ...current.shells, ...running].map((task) => [task.id, task]),
).values(),
]
})
return { blocking, tasks }
}
+9 -101
View File
@@ -8,6 +8,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
export function createSessionRequestModel() {
@@ -46,105 +47,12 @@ export function createSessionRequestModel() {
const id = params.id
return !!id && !data.session.get(id)?.parentID
}
const backgroundBlocking = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const assistant = data.session.message
.list(id)
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
if (assistant?.type !== "assistant") return []
return assistant.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
if (part.name !== "shell" && part.name !== "subagent") return []
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
]
})
})
const backgroundTasks = createMemo(() => {
if (!primary()) return []
const id = params.id
if (!id) return []
const blocking = backgroundBlocking()
const messages = data.session.message.list(id)
const completed = new Set(
messages.flatMap((message) => {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell")
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
return []
}),
)
const backgrounded = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "subagent") return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
const agent = part.state.input.agent
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
},
]
})
})
const active = data.session.list().flatMap((info) => {
if (info?.parentID !== id) return []
if (data.session.status(info.id) === "idle") return []
if (
blocking.some(
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
)
)
return []
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
})
const backgroundShells = messages.flatMap((message) => {
if (message.type !== "assistant") return []
return message.content.flatMap((part) => {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
if (typeof shellID === "string" && completed.has(shellID)) return []
const command = part.state.input.command
return [
{
id: typeof shellID === "string" ? shellID : part.id,
type: "shell" as const,
label: typeof command === "string" ? command : part.id,
},
]
})
})
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
if (
blocking.some(
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
)
)
return []
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
})
return [
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
]
const background = createSessionBackground({
sessionID: () => (primary() ? params.id : undefined),
messages: data.session.message.list,
sessions: data.session.list,
status: data.session.status,
shells: () => data.shell.list({ directory: sdk().directory }),
})
const moveToBackground = async () => {
if (!primary()) return
@@ -191,8 +99,8 @@ export function createSessionRequestModel() {
permissionRequest,
permissionResponding,
background: {
blocking: backgroundBlocking,
tasks: backgroundTasks,
blocking: background.blocking,
tasks: background.tasks,
move: moveToBackground,
},
decide,
@@ -5,18 +5,27 @@ import { createSessionResolution } from "./session-resolution"
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
let syncs = 0
const syncs = { session: 0, message: 0 }
const sessions = {
get: () => undefined,
sync: () => {
syncs++
syncs.session++
return Promise.resolve()
},
message: {
sync: () => {
syncs.message++
return Promise.resolve()
},
},
}
const session = createSessionResolution(() => undefined, () => sessions)
const session = createSessionResolution(
() => undefined,
() => sessions,
)
expect(session()).toBeUndefined()
expect(syncs).toBe(0)
expect(syncs).toEqual({ session: 0, message: 0 })
dispose()
})
})
@@ -1,9 +1,12 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createMemo, createRenderEffect, createSignal, on, onCleanup } from "solid-js"
import { sessionNotFoundError } from "@/runtime/server/errors"
type SessionStore<T> = {
get: (id: string) => T | undefined
sync: (id: string, options?: { children?: boolean }) => Promise<unknown>
message: {
sync: (id: string) => Promise<unknown>
}
}
type Resolution<T> = { id: string; store: SessionStore<T> } & (
@@ -40,13 +43,17 @@ export function createSessionResolution<T>(
})
const [status, setStatus] = createSignal<Resolution<T>>()
createEffect(
// Start independent reads before constructing the selected view, including
// when its metadata is cached but its transcript has never been loaded.
createRenderEffect(
on([sessionID, sessions] as const, ([id, store]) => {
if (!id) return
let stale = false
onCleanup(() => {
stale = true
})
// The timeline owns message errors; metadata resolution stays independent.
void store.message.sync(id).catch(() => undefined)
if (cached() && !options?.children) {
setStatus({ id, store, state: "settled" })
return
@@ -79,6 +79,17 @@ describe("visibleTimelineMessages", () => {
?.map((message) => message.id),
).toEqual(["msg_2", "msg_5"])
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
expect([...projection.messageRowIndex()]).toEqual([
["msg_1", 0],
["msg_3", 2],
])
expect([...projection.messageLastRowIndex()]).toEqual([
["msg_1", 1],
["msg_3", 3],
])
expect([...projection.lastAssistantGroupKey()]).toEqual([["msg_1", "context:msg_5:tool_read"]])
expect(projection.rowByKey().get("user-message:msg_1")).toBe(projection.rows()[0])
expect(projection.rowByKey().size).toBe(projection.rows().length)
dispose()
})
})
@@ -21,6 +21,7 @@ import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
import { getReadyMarkdown, preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -316,6 +317,16 @@ type MessageTimelineProps = {
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session })
const tail = props.pinned ? controller.data.projection.rows().at(-1) : undefined
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
const message = controller.data.projection.messageByID().get(tail.group.ref.messageID)
if (message?.type === "assistant" && message.time.completed !== undefined) {
const content = Timeline.resolveContent(message, tail.group.ref.partID)
// Start the required worker job while the rest of the selected view is constructed.
if (content?.type === "text" && content.text.trim())
void preloadMarkdown(content.text, tail.group.ref.partID).catch(() => undefined)
}
}
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
@@ -400,6 +411,38 @@ function MessageTimelineView(
onSelectionInteraction: props.onSelectionInteraction,
onUserScroll: props.onUserScroll,
onHistoryScroll: props.onHistoryScroll,
canRenderImmediately: (row, disclosure) => {
if (row._tag === "TurnGap" || row._tag === "TurnDivider") return true
if (row._tag === "Notice") {
const message = messageByID().get(row.messageID)
return (
(message?.type === "system" || message?.type === "synthetic") &&
(message.description ?? message.text).length <= 1024
)
}
if (row._tag === "UserMessage") {
const message = messageByID().get(row.userMessageID)
if (message?.type !== "user" || message.text.length > 1024 || message.files?.length || message.agents?.length)
return false
const presentation = readPromptPresentation(message.metadata)
return (
(presentation?.displayText ?? message.text).length <= 1024 &&
!presentation?.comments?.length &&
!parseCommentNote(message.text)
)
}
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
const message = messageByID().get(row.group.ref.messageID)
if (message?.type !== "assistant" || message.time.completed === undefined) return false
const content = Timeline.resolveContent(message, row.group.ref.partID)
if (content?.type === "reasoning")
return !(disclosure[row.group.ref.partID] ?? props.data.reasoningMode() === "full")
return (
content?.type === "text" &&
content.text.length <= 1024 &&
!!getReadyMarkdown({ raw: content.text, src: content.text }, `${row.group.ref.partID}:0:full`)
)
},
setRevealMessage: props.setRevealMessage,
setScrollToEnd: props.setScrollToEnd,
})
+2 -5
View File
@@ -13,7 +13,6 @@ export {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const prepared = new Set<string>()
const [resource] = createResource(
() => input.session.identity.sessionID(),
@@ -30,14 +29,12 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
if (input.session.identity.sessionKey() === key) prepared.add(key)
},
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
const messages = data.session.message.list(id)
return messages.length > 0 && !leadingTurnNeedsParent(messages)
// Enrich the partial leading group without withholding the already loaded tail.
return !id || data.session.message.list(id).length > 0 || !resource.loading
})
const more = () => {
const id = input.session.identity.sessionID()
+15 -24
View File
@@ -96,38 +96,29 @@ export function createTimelineProjection(input: {
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
const indexes = createMemo(() => {
const rowByKey = new Map<string, TimelineRow.TimelineRow>()
const messageRowIndex = new Map<string, number>()
const messageLastRowIndex = new Map<string, number>()
const lastAssistantGroupKey = new Map<string, string>()
rows().forEach((row, index) => {
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
rowByKey.set(TimelineRow.key(row), row)
if (!("userMessageID" in row)) return
if (!messageRowIndex.has(row.userMessageID)) messageRowIndex.set(row.userMessageID, index)
messageLastRowIndex.set(row.userMessageID, index)
if (row._tag === "AssistantPart") lastAssistantGroupKey.set(row.userMessageID, row.group.key)
})
return result
})
const messageLastRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
if ("userMessageID" in row) result.set(row.userMessageID, index)
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
rows().forEach((row) => {
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
})
return result
return { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey }
})
return {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey,
lastAssistantGroupKey: () => indexes().lastAssistantGroupKey,
messageByID: sessionMessageByID,
messageRowIndex,
messageLastRowIndex,
rowByKey,
messageRowIndex: () => indexes().messageRowIndex,
messageLastRowIndex: () => indexes().messageLastRowIndex,
rowByKey: () => indexes().rowByKey,
rows,
sessionMessageByID,
userContextByID,
+139 -65
View File
@@ -55,6 +55,10 @@ type Input = {
onSelectionInteraction: (event: MouseEvent) => void
onUserScroll: (target?: EventTarget | null) => void
onHistoryScroll: () => void
canRenderImmediately?: (
row: TimelineRow.TimelineRow,
disclosure: Readonly<Record<string, boolean | undefined>>,
) => boolean
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
}
@@ -75,17 +79,63 @@ export function createTimelineVirtualizer(input: Input) {
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [overscan, setOverscan] = createSignal(2)
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const knownKeys = new Set(rows().map(TimelineRow.key))
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
equals: (previous, next) => previous.length === next.length && previous.every((key, index) => key === next[index]),
})
const knownKeys = new Set(rowKeys())
const addedKeys = new Set<string>()
const getItemKey = createMemo(() => {
const keys = rowKeys()
keys
.filter((key) => !knownKeys.has(key))
.forEach((key) => {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => keys[index] ?? `removed:${index}`
})
const rangeExtractor = createMemo(() => {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const initialTail = rendering.initialTail && input.pinned()
return (range: Range) => {
// Batch a bounded cheap suffix, but stop before unknown/large content.
// A large tail still mounts alone before estimates expose earlier history.
const start = Math.max(0, range.startIndex - 2)
const boundary = initialTail
? rows()
.slice(start, range.count)
.findLastIndex(
(row) =>
!(
row._tag === "AssistantPart" &&
row.group.type === "context" &&
row.group.refs.length <= 16 &&
!toolOpen[`context:${row.group.key}`]
) && !input.canRenderImmediately?.(row, toolOpen),
)
: -1
const first = Math.min(range.count - 1, start + boundary + 1)
const indexes = initialTail
? Array.from({ length: range.count - first }, (_, index) => first + index)
: defaultRangeExtractor({ ...range, overscan: 2 })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
})
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let batchingColdSizes = false
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
@@ -93,10 +143,16 @@ export function createTimelineVirtualizer(input: Input) {
},
getScrollElement: () => listRoot() ?? null,
// Route navigation detaches and reattaches the scroll element, which drops its offset.
observeElementOffset: (instance, callback) =>
observeElementOffsetReconnectAware(instance, callback, () => {
observeElementOffset: (instance, callback) => {
reportOffset = (offset, scrolling) => {
callback(offset, scrolling)
settleColdBottom()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
if (input.pinned()) virtualizer.scrollToEnd()
}),
settleColdBottom()
})
},
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
@@ -110,28 +166,17 @@ export function createTimelineVirtualizer(input: Input) {
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined) return size
if (size !== undefined || coldPending) return size ?? fallbackItemSize
}
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (batchingColdSizes && input.pinned()) return
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
get getItemKey() {
const items = rows()
items
.map(TimelineRow.key)
.filter((key) => !knownKeys.has(key))
.forEach((key) => {
knownKeys.add(key)
addedKeys.add(key)
})
return (index: number) => {
const row = items[index]
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
return getItemKey()
},
get anchorTo() {
return input.pinned() ? "end" : "start"
@@ -145,16 +190,7 @@ export function createTimelineVirtualizer(input: Input) {
},
paddingEnd: 64,
get rangeExtractor() {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const buffer = overscan()
return (range: Range) => {
const indexes = defaultRangeExtractor({ ...range, overscan: buffer })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
return rangeExtractor()
},
})
const resizeItem = virtualizer.resizeItem
@@ -166,7 +202,7 @@ export function createTimelineVirtualizer(input: Input) {
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if (virtualizer.itemSizeCache.get(key) === size) {
if ((virtualizer.itemSizeCache.get(key) ?? fallbackItemSize) === size) {
pendingSizes.delete(index)
return
}
@@ -178,12 +214,19 @@ export function createTimelineVirtualizer(input: Input) {
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
// The hidden pinned mount needs one bottom write after the whole batch,
// not a layout-forcing scroll adjustment for every measured row.
batchingColdSizes = coldPending && input.pinned()
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
})
})
batchingColdSizes = false
if (coldPending) pinColdBottom()
settleColdBottom()
if (coldPending) return
if (!input.pinned()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
@@ -217,41 +260,73 @@ export function createTimelineVirtualizer(input: Input) {
})
})
let settleFrame: number | undefined
let overscanFrame: number | undefined
let overscanTimer: number | undefined
const expandOverscan = () => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
// Let the visible rows paint before building the normal interaction buffer.
overscanTimer = window.setTimeout(() => {
overscanTimer = undefined
setOverscan(20)
}, 0)
})
let coldPending = coldBottomMount
let settleQueued = false
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
const pinColdBottom = () => {
const root = listRoot()
if (!input.pinned() || !virtualContent || !root) return
// scrollToEnd computes its target from the DOM, not the new size cache.
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
// Report after core size adjustments finish so they cannot apply a delta
// twice. This avoids waiting a frame for the native scroll event.
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
}
const pendingMeasurements = () => {
const items = virtualizer.getVirtualItems()
return (
(rows().length > 0 && items.length === 0) ||
items.some((item) => !virtualizer.elementsCache.get(item.key)?.isConnected)
)
}
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleFrame = requestAnimationFrame(settleColdBottom)
return
}
settleFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
if (!coldPending || settleQueued) return
settleQueued = true
queueMicrotask(() => {
settleQueued = false
const root = listRoot()
if (!coldPending || !virtualContent?.isConnected || !root) return
if (virtualContent.querySelector(pendingMarkdown)) return
if (!root.clientHeight) return
// Markdown can finish before ResizeObserver delivers its new box. The
// normal measureElement path skips reads while scrolling; this gate needs
// current boxes before expanding the estimated range or revealing it.
virtualizer.elementsCache.forEach((element) => {
if (element.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(element), element.offsetHeight)
})
if (pendingSizes.size || pendingMeasurements()) return
pinColdBottom()
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
// The scroll event must update the range before newly exposed rows can reveal.
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
return
if (rendering.initialTail) {
setRendering("initialTail", false)
settleColdBottom()
return
}
settleFrame = undefined
virtualContent?.style.removeProperty("visibility")
expandOverscan()
if (pendingSizes.size || pendingMeasurements() || virtualContent.querySelector(pendingMarkdown)) return
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
virtualContent.style.removeProperty("visibility")
})
}
onMount(() => {
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
if (!coldBottomMount) expandOverscan()
if (!coldPending || !virtualContent) return
contentObserver = new MutationObserver(settleColdBottom)
contentObserver.observe(virtualContent, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-markdown-ready"],
})
viewportObserver = new ResizeObserver(settleColdBottom)
const root = listRoot()
if (root) viewportObserver.observe(root)
settleColdBottom()
})
let measuredSessionKey = input.sessionKey()
@@ -265,12 +340,14 @@ export function createTimelineVirtualizer(input: Input) {
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
// TanStack owns anchoring; browser scroll anchoring would fight its adjustments.
root.style.overflowAnchor = "none"
setListRoot(root)
scrollTop = root.scrollTop
maxScroll = root.scrollHeight - root.clientHeight
input.setScrollRef(root)
viewportObserver?.observe(root)
settleColdBottom()
}
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
@@ -278,13 +355,11 @@ export function createTimelineVirtualizer(input: Input) {
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
setOverscan(20)
}
const handleListTouchStart = (event: TouchEvent) => {
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
setOverscan(20)
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
@@ -301,7 +376,6 @@ export function createTimelineVirtualizer(input: Input) {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
setOverscan(20)
}
const releasePointer = () => {
pointerHeld = false
@@ -322,7 +396,6 @@ export function createTimelineVirtualizer(input: Input) {
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
setOverscan(20)
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
@@ -338,6 +411,7 @@ export function createTimelineVirtualizer(input: Input) {
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
input.onScheduleScrollState(root)
input.onHistoryScroll()
}
@@ -476,9 +550,9 @@ export function createTimelineVirtualizer(input: Input) {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
+1 -2
View File
@@ -11,9 +11,8 @@ import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
export const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
const loadDraftRoute = () => Promise.all([import("@/new-session/route"), File.preload()]).then(([module]) => module)
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
+2 -2
View File
@@ -43,12 +43,12 @@ export default function Layout(props: ParentProps) {
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
// The native Windows titlebar already includes the gap above the content panels.
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset":
platform.platform === "desktop" &&
platform.os === "windows" &&
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
? "0px"
? "1px"
: "8px",
}}
>
+3 -2
View File
@@ -28,7 +28,6 @@ export function TabNavItem(props: {
onClose: () => void
onNavigate: () => void
active?: boolean
forceTruncate?: boolean
suppressNavigation?: boolean
dragging?: boolean
pressed?: boolean
@@ -98,11 +97,13 @@ export function TabNavItem(props: {
createEffect(() => {
title()
props.forceTruncate
props.active
props.orientation
editing()
scheduleTitleOverflow()
})
// The overflow fade changes title padding; observe the stable tab box, not that feedback.
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
onCleanup(() => {
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
+1 -44
View File
@@ -1,6 +1,5 @@
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@@ -26,7 +25,6 @@ function SessionTabSlot(props: {
id: string
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
fallbackTitle?: string
@@ -69,7 +67,6 @@ function SessionTabSlot(props: {
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
@@ -82,7 +79,6 @@ function SessionTabEntry(props: {
id: string
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
@@ -169,7 +165,6 @@ function SessionTabEntry(props: {
id={props.id}
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
orientation={props.orientation}
session={session()}
fallbackTitle={
@@ -238,19 +233,15 @@ export function TitlebarTabStrip(props: {
orientation?: "horizontal" | "vertical"
tabs: Tab[]
currentTab: Tab | undefined
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
onReorder: (keys: string[]) => void
onOverflowChange: (overflowing: boolean) => void
}) {
const global = useGlobal()
const language = useLanguage()
const command = useCommand()
const vertical = () => props.orientation === "vertical"
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
@@ -281,38 +272,6 @@ export function TitlebarTabStrip(props: {
if (next) props.onNavigate(next)
}
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(
vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
)
}
createResizeObserver(
() => [scrollRef, listRef],
() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
})
},
)
onMount(() => {
refreshOverflow()
})
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
})
createEffect(() => {
props.tabs.length
visibleTabIds()
refreshOverflow()
})
return (
<div
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
@@ -327,7 +286,6 @@ export function TitlebarTabStrip(props: {
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
}}
ref={scrollRef}
>
<DragDropProvider
sensors={[
@@ -398,7 +356,6 @@ export function TitlebarTabStrip(props: {
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
forceTruncate={props.forceTruncate}
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
onVisibleChange={(visible) => setVisibility(id, visible)}
+1 -7
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
@@ -336,8 +336,6 @@ export function Titlebar(props: {
].filter((v) => v !== undefined)
})
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
return (
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
@@ -382,8 +380,6 @@ export function Titlebar(props: {
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
@@ -424,8 +420,6 @@ export function Titlebar(props: {
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={false}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
@@ -0,0 +1,196 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo, SessionMessageAssistantTool, ShellInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createSessionBackground } from "@/session/requests/background"
const tool = (
id: string,
name: string,
metadata: Record<string, string>,
input: Record<string, string> = {},
status: "completed" | "running" = "completed",
): SessionMessageAssistantTool => ({
id,
name,
type: "tool",
state:
status === "running"
? { status, input, metadata }
: { status, input, metadata, content: [{ type: "text", text: "backgrounded" }] },
time: { created: 0 },
})
const assistant = (id: string, content: SessionMessageAssistantTool[], completed?: number) => ({
id,
type: "assistant" as const,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 0, completed },
})
const session = (id: string): SessionInfo => ({
id,
title: id,
parentID: "root",
projectID: "project",
location: { directory: "/project" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
})
const shell = (id: string, command: string): ShellInfo => ({
id,
command,
status: "running",
cwd: "/project",
shell: "sh",
file: "output",
metadata: { sessionID: "root" },
time: { started: 0 },
})
const notification = (id: string, metadata: Record<string, string>) => ({
id,
type: "synthetic" as const,
text: "complete",
metadata,
time: { created: 0 },
})
describe("createSessionBackground", () => {
test("excludes completed children and shells using either shell or tool-call IDs", () => {
createRoot((dispose) => {
const background = createSessionBackground({
sessionID: () => "root",
messages: () => [
notification("before", { source: "subagent", childID: "before-child" }),
assistant("assistant", [
tool("before-part", "subagent", { status: "running", sessionID: "before-child" }),
tool("shell-part", "shell", { status: "running", shellID: "process" }),
tool("shell-call", "shell", { status: "running", shellID: "shell-id" }),
tool("legacy-call", "shell", { status: "running", shellID: "legacy-shell" }),
tool("child-part", "subagent", { status: "running", sessionID: "child" }, { agent: "explore" }),
]),
notification("shell-done", { source: "shell", jobID: "shell-part" }),
notification("shell-id-done", { source: "shell", shellID: "shell-id" }),
notification("legacy-done", { source: "shell", jobID: "legacy-shell" }),
],
sessions: () => [],
status: () => "idle",
shells: () => [],
})
expect(background.tasks()).toEqual([{ id: "child", type: "subagent", label: "child", agent: "explore" }])
dispose()
})
})
test("joins live tasks while idle without rescanning history, then switches sessions", () => {
createRoot((dispose) => {
const [store, setStore] = createStore({
id: "root" as string | undefined,
messages: [
assistant("assistant", [
tool("shell-part", "shell", { status: "running", shellID: "shell" }, { command: "old command" }),
tool("child-part", "subagent", { status: "running", sessionID: "child" }),
]),
],
sessions: [session("live-child"), session("child")],
status: { root: "idle", child: "idle", "live-child": "idle" } as Record<string, "idle" | "running">,
shells: [{ ...shell("shell", "command"), status: "exited" as ShellInfo["status"] }],
})
let scans = 0
const background = createSessionBackground({
sessionID: () => store.id,
messages: (id) => {
scans += 1
return id === "root" ? store.messages : []
},
sessions: () => store.sessions,
status: (id) => store.status[id],
shells: () => store.shells,
})
const blocking = background.blocking()
const initial = background.tasks()
expect(initial.map((task) => task.id)).toEqual(["child", "shell"])
setStore("status", { child: "running", "live-child": "running" })
expect(background.tasks().map((task) => task.id)).toEqual(["child", "live-child", "shell"])
setStore("shells", 0, "status", "running")
expect(background.tasks().at(-1)?.label).toBe("command")
setStore("sessions", 1, "title", "renamed")
expect(background.tasks()[0]?.label).toBe("renamed")
setStore("shells", 0, "command", "updated command")
const live = background.tasks()
expect(live).toEqual([
{ id: "child", type: "subagent", label: "renamed" },
{ id: "live-child", type: "subagent", label: "live-child" },
{ id: "shell", type: "shell", label: "updated command" },
])
expect(background.blocking()).toBe(blocking)
expect(scans).toBe(1)
setStore("id", "other")
expect(background.tasks()).toEqual([])
setStore("id", "root")
expect(background.tasks()).toEqual(live)
setStore("status", { child: "idle", "live-child": "idle" })
setStore("shells", 0, "status", "exited")
expect(background.tasks()).toEqual(initial)
expect(scans).toBe(3)
setStore("id", undefined)
expect(background.tasks()).toEqual([])
dispose()
})
})
test("tracks blocking, backgrounding, and completion through nested store updates", () => {
createRoot((dispose) => {
const [store, setStore] = createStore({
messages: [
assistant("earlier", [tool("old-part", "subagent", { sessionID: "old-child" }, {}, "running")]),
assistant("current", [
tool("child-part", "subagent", { sessionID: "child" }, {}, "running"),
tool("shell-part", "shell", {}, { command: "build" }, "running"),
]),
assistant("completed", [], 0),
],
notification: notification("notice", { source: "subagent", childID: "other-child" }),
status: { child: "running", "old-child": "running" } as Record<string, "idle" | "running">,
})
const messages = store.messages
const background = createSessionBackground({
sessionID: () => "root",
messages: () => [...store.messages, store.notification],
sessions: () => [session("child"), session("old-child")],
status: (id) => store.status[id],
shells: () => [shell("shell", "build")],
})
expect(background.blocking()).toEqual([
{ type: "subagent", partID: "child-part", id: "child", label: undefined },
{ type: "shell", partID: "shell-part", id: undefined, label: "build" },
])
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
setStore("messages", 1, "content", 0, "state", {
status: "completed",
input: { description: "background child" },
metadata: { status: "running", sessionID: "child" },
content: [{ type: "text", text: "backgrounded" }],
})
expect(store.messages).toBe(messages)
expect(background.blocking().map((task) => task.partID)).toEqual(["shell-part"])
setStore("status", "child", "idle")
expect(background.tasks().map((task) => task.id)).toEqual(["child", "old-child"])
expect(background.tasks()[0]?.label).toBe("background child")
setStore("notification", "metadata", "childID", "child")
expect(background.tasks().map((task) => task.id)).toEqual(["old-child"])
setStore("messages", [0, 1], "time", "completed", 1)
expect(background.blocking()).toEqual([])
expect(background.tasks().map((task) => task.id)).toEqual(["old-child", "shell"])
dispose()
})
})
})
@@ -15,8 +15,10 @@ function createFixture(initial: Record<string, Session> = {}) {
const [cache, setCache] = createSignal(initial)
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
const resolves: string[] = []
const messages = { syncs: [] as string[], ...Promise.withResolvers<unknown>() }
return {
resolves,
messages,
sessions: {
get: (id: string) => cache()[id],
sync: (id: string) => {
@@ -25,6 +27,12 @@ function createFixture(initial: Record<string, Session> = {}) {
deferred.set(id, entry)
return entry.promise
},
message: {
sync: (id: string) => {
messages.syncs.push(id)
return messages.promise
},
},
},
settle(id: string) {
setCache({ ...cache(), [id]: sessionOf(id) })
@@ -51,7 +59,34 @@ const flush = async () => {
await Promise.resolve()
}
test("resolves an uncached session", async () => {
test("starts metadata and messages in parallel once the route has a session ID", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const [id, setId] = createSignal<string>()
const current = createSessionResolution(id, () => fixture.sessions)
expect(current()).toBeUndefined()
await flush()
expect(fixture.resolves).toEqual([])
expect(fixture.messages.syncs).toEqual([])
setId("ses_a")
expect(fixture.resolves).toEqual(["ses_a"])
expect(fixture.messages.syncs).toEqual(["ses_a"])
fixture.messages.resolve(undefined)
await flush()
expect(current()).toBeUndefined()
fixture.settle("ses_a")
await flush()
expect(current()?.id).toBe("ses_a")
dispose()
})
})
test("message failure does not fail metadata resolution", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const current = createSessionResolution(
@@ -59,9 +94,10 @@ test("resolves an uncached session", async () => {
() => fixture.sessions,
)
expect(current()).toBeUndefined()
await flush()
expect(fixture.resolves).toEqual(["ses_a"])
fixture.messages.reject(new Error("message sync failed"))
await flush()
expect(current()).toBeUndefined()
fixture.settle("ses_a")
await flush()
@@ -82,12 +118,15 @@ test("re-resolves when navigating to an uncached session without a remount", asy
await flush()
expect(current()?.id).toBe("ses_a")
expect(fixture.resolves).toEqual([])
expect(fixture.messages.syncs).toEqual(["ses_a"])
expect(() => {
setId("ses_b")
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_b"])
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b"])
fixture.settle("ses_b")
await flush()
@@ -139,6 +178,7 @@ test("returning to a pruned session re-resolves instead of throwing not found",
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
fixture.settle("ses_a")
await flush()
@@ -167,6 +207,7 @@ test("revisiting a session whose resolution failed while unfocused retries clean
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
expect(fixture.messages.syncs).toEqual(["ses_a", "ses_b", "ses_a"])
fixture.settle("ses_a")
await flush()
@@ -197,6 +238,8 @@ test("re-resolves against a replaced session store", async () => {
}).not.toThrow()
await flush()
expect(second.resolves).toEqual(["ses_a"])
expect(first.messages.syncs).toEqual(["ses_a"])
expect(second.messages.syncs).toEqual(["ses_a"])
second.settle("ses_a")
await flush()
@@ -198,3 +198,41 @@ test("stale pinned indexes do not produce missing virtual items after count shri
dispose()
})
})
test("snapshots materialize only measured rows and restore their current geometry", () => {
const options = {
count: 100,
getItemKey: (index: number) => `row-${index}`,
estimateSize: () => 60,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
}
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options)
expect(virtualizer.getTotalSize()).toBe(6000)
virtualizer.resizeItem(4, 100)
virtualizer.resizeItem(99, 140)
const measurements = virtualizer.getMeasurements()
const reads: number[] = []
virtualizer.getMeasurements = () =>
new Proxy(measurements, {
get(target, key, receiver) {
if (typeof key === "string" && /^\d+$/.test(key)) reads.push(Number(key))
return Reflect.get(target, key, receiver)
},
})
const snapshot = virtualizer.takeSnapshot()
expect(reads).toEqual([4, 99])
expect(snapshot).toEqual([
{ index: 4, key: "row-4", start: 240, size: 100, end: 340, lane: 0 },
{ index: 99, key: "row-99", start: 5980, size: 140, end: 6120, lane: 0 },
])
const restored = new Virtualizer<HTMLDivElement, HTMLDivElement>({ ...options, initialMeasurementsCache: snapshot })
expect(restored.getTotalSize()).toBe(6120)
expect(restored.takeSnapshot()).toEqual(snapshot)
restored.setOptions({ ...options, count: 10 })
restored.resizeItem(4, 80)
expect(restored.takeSnapshot()).toEqual([{ index: 4, key: "row-4", start: 240, size: 80, end: 320, lane: 0 }])
})
+8
View File
@@ -1950,6 +1950,12 @@ export type VcsGetInput = {
export type VcsGetOutput = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
export type VcsBaseInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type VcsBaseOutput = { readonly location: Location.Info; readonly data: Vcs.Base | null }
export type VcsBaseOperation<E = never> = (input?: VcsBaseInput) => Effect.Effect<VcsBaseOutput, E>
export type VcsStatusInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1967,6 +1973,7 @@ export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effe
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly base?: string | undefined
readonly context?: number | undefined
}
export type VcsDiffOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
@@ -1974,6 +1981,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
+11 -3
View File
@@ -244,6 +244,8 @@ import type {
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
@@ -1468,6 +1470,11 @@ const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBase = (raw: RawClient["server.vcs"]) => (input?: VcsBaseInput) =>
preserveEffect<VcsBaseOutput>()(
raw["vcs.base"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusInput) =>
preserveEffect<VcsStatusOutput>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1482,13 +1489,14 @@ const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranch
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
preserveEffect<VcsDiffOutput>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
raw["vcs.diff"]({
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
+1
View File
@@ -34,6 +34,7 @@ export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Vcs } from "@opencode-ai/schema/vcs"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
@@ -240,6 +240,8 @@ import type {
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
@@ -1996,6 +1998,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
base: (input?: VcsBaseInput, requestOptions?: RequestOptions) =>
request<VcsBaseOutput>(
{
method: "GET",
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
@@ -2025,9 +2039,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], context: input["context"] },
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
+25 -3
View File
@@ -419,6 +419,8 @@ export type WorkspaceDestroyResult = { destroyed: boolean }
export type VcsBranch = { current?: string; default?: string }
export type VcsBase = { name: string; ref: string; source: "reflog" | "default" }
export type VcsFileStatus = {
file: string
additions: number
@@ -6073,6 +6075,17 @@ export type VcsGetOutput = {
data: VcsInfo
}
export type VcsBaseInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsBaseOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsBase | null
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -6110,17 +6123,26 @@ export type VcsBranchesOutput = {
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["location"]
readonly mode: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["mode"]
readonly base?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["base"]
readonly context?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["context"]
}
+2 -1
View File
@@ -750,7 +750,8 @@ export function createData(config: CreateDataInput) {
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
})
@@ -6,6 +6,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Vcs } from "@opencode-ai/schema/vcs"
const Client = await import("../src/effect")
@@ -14,6 +15,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Session).toBe(Session)
expect(Client.Vcs.Base).toBe(Vcs.Base)
})
test("generated Effect API names canonical and composed outputs", async () => {
+20
View File
@@ -27,6 +27,26 @@ test("health.get decodes the readiness response", async () => {
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("vcs.base decodes nullable review-base metadata", async () => {
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = {
name: "release",
ref: "refs/remotes/origin/release",
source: "reflog",
}
for (const data of [base, null]) {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ location, data }))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.vcs.base({ location: { directory: AbsolutePath.make("/repo") } })
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.data).toEqual(data)
expect(result.location.directory).toBe("/repo")
}
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+41 -1
View File
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
@@ -84,6 +84,46 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
const requests: Request[] = []
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = { name: "release", ref: "refs/remotes/origin/release", source: "reflog" }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return Response.json({ location, data: new URL(request.url).pathname.endsWith("/base") ? base : [] })
},
})
expect(await client.vcs.base({ location: { directory: "/repo" } })).toEqual({ location, data: base })
expect(
await client.vcs.diff({ location: { directory: "/repo" }, mode: "committed", base: base.ref, context: 1 }),
).toEqual({ location, data: [] })
expect(new URL(requests[0].url).pathname).toBe("/api/vcs/base")
const query = new URL(requests[1].url).searchParams
expect(query.get("location[directory]")).toBe("/repo")
expect(query.get("mode")).toBe("committed")
expect(query.get("base")).toBe(base.ref)
expect(query.get("context")).toBe("1")
})
test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "ServiceUnavailableError", service: "vcs", message: "No review base available" },
{ status: 503 },
),
})
await expect(client.vcs.diff({ mode: "committed" })).rejects.toMatchObject({
_tag: "ServiceUnavailableError",
service: "vcs",
message: "No review base available",
})
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
+30
View File
@@ -666,6 +666,36 @@ test("ignores activity snapshots from an older connection", async () => {
}
})
test("projects background user shell metadata from durable shell data", () => {
const setup = activityFixture(() => Response.json({ data: {} }))
try {
setup.emit({
id: "evt_user_shell",
created: 1,
type: "session.shell.started",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: {
sessionID: "ses_refresh",
shell: {
id: "sh_user",
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/project/shell.out",
metadata: { sessionID: "ses_refresh", background: true },
time: { started: 1 },
},
},
})
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
])
} finally {
setup.dispose()
}
})
function activityFixture(read: () => Response | Promise<Response>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
+2 -2
View File
@@ -46,12 +46,12 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
const servers = new Map<string, ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, 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 } })
}
})
})
@@ -4,7 +4,6 @@ import {
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3Content,
type LanguageModelV3ProviderTool,
type LanguageModelV3StreamPart,
type SharedV3ProviderMetadata,
type SharedV3Warning,
@@ -27,7 +26,7 @@ import { imageGenerationOutputSchema } from "./tool/image-generation.js"
import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input.js"
import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason.js"
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
import { prepareResponsesTools, type ResponsesHostedTool } from "./openai-responses-prepare-tools.js"
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
const webSearchCallItem = z.object({
@@ -221,15 +220,23 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
addInclude("message.output_text.logprobs")
}
// when a web search tool is present, automatically include the sources:
const webSearchToolName = (
tools?.find(
(tool) =>
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
) as LanguageModelV3ProviderTool | undefined
)?.name
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
hostedTools,
selectedHostedTool,
toolWarnings,
} = prepareResponsesTools({
tools,
toolChoice,
strictJsonSchema,
})
const getHostedToolName = (responseType: ResponsesHostedTool["responseType"]) => {
if (selectedHostedTool?.responseType === responseType) return selectedHostedTool.name
return hostedTools.find((tool) => tool.responseType === responseType)?.name ?? responseType
}
if (webSearchToolName) {
if (hostedTools.some((tool) => tool.responseType === "web_search")) {
addInclude("web_search_call.action.sources")
}
@@ -357,18 +364,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
baseArgs.service_tier = undefined
}
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
toolWarnings,
} = prepareResponsesTools({
tools,
toolChoice,
strictJsonSchema,
})
return {
webSearchToolName,
getHostedToolName,
args: {
...baseArgs,
tools: openaiTools,
@@ -379,7 +376,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
async doGenerate(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
const url = this.config.url({
path: "/responses",
modelId: this.modelId,
@@ -526,7 +523,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
input: "{}",
providerExecuted: true,
})
@@ -534,7 +531,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: part.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -605,7 +602,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
input: JSON.stringify({ action: part.action }),
providerExecuted: true,
})
@@ -613,7 +610,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
result: { status: part.status },
})
@@ -645,7 +642,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
input: "{}",
providerExecuted: true,
})
@@ -653,7 +650,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
result: {
queries: part.queries,
results:
@@ -673,7 +670,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
input: JSON.stringify({
code: part.code,
containerId: part.container_id,
@@ -684,7 +681,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
result: {
outputs: part.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
@@ -746,7 +743,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
async doStream(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
const { responseHeaders, value: response } = await postJsonToApi({
url: this.config.url({
@@ -866,7 +863,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-input-start",
id: value.item.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
})
} else if (value.item.type === "computer_call") {
ongoingToolCalls[value.output_index] = {
@@ -889,7 +886,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-input-start",
id: value.item.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
})
controller.enqueue({
@@ -901,7 +898,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
input: "{}",
providerExecuted: true,
})
@@ -909,7 +906,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
input: "{}",
providerExecuted: true,
})
@@ -980,7 +977,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "web_search",
toolName: getHostedToolName("web_search"),
input: JSON.stringify({ action: value.item.action }),
providerExecuted: true,
})
@@ -988,7 +985,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "web_search",
toolName: getHostedToolName("web_search"),
result: { status: value.item.status },
})
} else if (value.item.type === "computer_call") {
@@ -1022,7 +1019,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
result: {
queries: value.item.queries,
results:
@@ -1041,7 +1038,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
result: {
outputs: value.item.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
@@ -1050,7 +1047,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: value.item.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -1099,7 +1096,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item_id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: value.partial_image_b64,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -1135,7 +1132,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: toolCall.toolCallId,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
input: JSON.stringify({
code: value.code,
containerId: toolCall.codeInterpreter!.containerId,
@@ -1,4 +1,9 @@
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
import {
type LanguageModelV3CallOptions,
type LanguageModelV3ProviderTool,
type SharedV3Warning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { codeInterpreterArgsSchema } from "./tool/code-interpreter.js"
import { fileSearchArgsSchema } from "./tool/file-search.js"
import { webSearchArgsSchema } from "./tool/web-search.js"
@@ -6,6 +11,28 @@ import { webSearchPreviewArgsSchema } from "./tool/web-search-preview.js"
import { imageGenerationArgsSchema } from "./tool/image-generation.js"
import type { OpenAIResponsesTool } from "./openai-responses-api-types.js"
export type ResponsesHostedTool = {
name: string
type: "file_search" | "web_search_preview" | "web_search" | "code_interpreter" | "image_generation"
responseType: "file_search" | "web_search" | "code_interpreter" | "image_generation"
}
export function getResponsesHostedTool(tool: LanguageModelV3ProviderTool): ResponsesHostedTool | undefined {
switch (tool.id) {
case "openai.file_search":
return { name: tool.name, type: "file_search", responseType: "file_search" }
case "openai.web_search_preview":
return { name: tool.name, type: "web_search_preview", responseType: "web_search" }
case "openai.web_search":
return { name: tool.name, type: "web_search", responseType: "web_search" }
case "openai.code_interpreter":
return { name: tool.name, type: "code_interpreter", responseType: "code_interpreter" }
case "openai.image_generation":
return { name: tool.name, type: "image_generation", responseType: "image_generation" }
}
return undefined
}
export function prepareResponsesTools({
tools,
toolChoice,
@@ -26,6 +53,8 @@ export function prepareResponsesTools({
| { type: "function"; name: string }
| { type: "code_interpreter" }
| { type: "image_generation" }
hostedTools: ResponsesHostedTool[]
selectedHostedTool?: ResponsesHostedTool
toolWarnings: SharedV3Warning[]
} {
// when the tools array is empty, change it to undefined to prevent errors:
@@ -34,7 +63,49 @@ export function prepareResponsesTools({
const toolWarnings: SharedV3Warning[] = []
if (tools == null) {
return { tools: undefined, toolChoice: undefined, toolWarnings }
return { tools: undefined, toolChoice: undefined, hostedTools: [], toolWarnings }
}
const hostedTools = tools.flatMap((tool) => {
if (tool.type !== "provider") return []
const hostedTool = getResponsesHostedTool(tool)
return hostedTool ? [hostedTool] : []
})
const selectedToolName = toolChoice?.type === "tool" ? toolChoice.toolName : undefined
const selectedTools = selectedToolName === undefined ? [] : tools.filter((tool) => tool.name === selectedToolName)
if (selectedTools.length > 1) {
throw new UnsupportedFunctionalityError({
functionality: `ambiguous tool choice '${selectedToolName}': multiple tool definitions share this name`,
})
}
const selectedHostedTool =
selectedTools[0]?.type === "provider" ? getResponsesHostedTool(selectedTools[0]) : undefined
const ambiguousHostedResponse =
toolChoice?.type === "none" || toolChoice?.type === "tool"
? undefined
: hostedTools.find(
(tool) =>
new Set(
hostedTools.filter((candidate) => candidate.responseType === tool.responseType).map((item) => item.name),
).size > 1,
)
if (ambiguousHostedResponse) {
const names = new Set(
hostedTools.filter((tool) => tool.responseType === ambiguousHostedResponse.responseType).map((tool) => tool.name),
)
throw new UnsupportedFunctionalityError({
functionality: `ambiguous ${ambiguousHostedResponse.responseType} response for hosted tools: ${[...names].join(", ")}`,
})
}
if (selectedHostedTool) {
const names = new Set(hostedTools.filter((tool) => tool.type === selectedHostedTool.type).map((tool) => tool.name))
if (names.size > 1) {
throw new UnsupportedFunctionalityError({
functionality: `ambiguous ${selectedHostedTool.type} tool choice for hosted tools: ${[...names].join(", ")}`,
})
}
}
const openaiTools: Array<OpenAIResponsesTool> = []
@@ -134,7 +205,7 @@ export function prepareResponsesTools({
}
if (toolChoice == null) {
return { tools: openaiTools, toolChoice: undefined, toolWarnings }
return { tools: openaiTools, toolChoice: undefined, hostedTools, selectedHostedTool, toolWarnings }
}
const type = toolChoice.type
@@ -143,20 +214,18 @@ export function prepareResponsesTools({
case "auto":
case "none":
case "required":
return { tools: openaiTools, toolChoice: type, toolWarnings }
case "tool":
return { tools: openaiTools, toolChoice: type, hostedTools, selectedHostedTool, toolWarnings }
case "tool": {
return {
tools: openaiTools,
toolChoice:
toolChoice.toolName === "code_interpreter" ||
toolChoice.toolName === "file_search" ||
toolChoice.toolName === "image_generation" ||
toolChoice.toolName === "web_search_preview" ||
toolChoice.toolName === "web_search"
? { type: toolChoice.toolName }
: { type: "function", name: toolChoice.toolName },
toolChoice: selectedHostedTool
? { type: selectedHostedTool.type }
: { type: "function", name: toolChoice.toolName },
hostedTools,
selectedHostedTool,
toolWarnings,
}
}
default: {
const _exhaustiveCheck: never = type
throw new UnsupportedFunctionalityError({
+9 -3
View File
@@ -78,9 +78,15 @@ export const make = Effect.gen(function* () {
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
]
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return { ...content, content: candidate, encoding: "base64" as const, mime }
const candidate = encode()
// Base64 uses four bytes per three input bytes, including padding.
if (Math.ceil(candidate.length / 3) * 4 <= limits.maxBase64Bytes)
return {
...content,
content: Buffer.from(candidate).toString("base64"),
encoding: "base64" as const,
mime,
}
}
} finally {
resized.free()
+4
View File
@@ -45,6 +45,8 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionInstructions } from "./session/instructions.js"
import { SessionGenerateNode } from "./session/generate-node.js"
import { SessionPromptNode } from "./session/prompt-node.js"
import { SessionRevertNode } from "./session/revert-node.js"
import { McpTool } from "./tool/mcp.js"
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
@@ -92,6 +94,8 @@ const nodes = [
Form.node,
Generate.node,
SessionGenerateNode.node,
SessionPromptNode.node,
SessionRevertNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
+5 -4
View File
@@ -636,8 +636,8 @@ export const layer = (options?: Options) =>
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string, digest = bodyDigest(text)) {
yield* kv.set(key, { updatedAt: Date.now(), digest, body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
@@ -681,12 +681,13 @@ export const layer = (options?: Options) =>
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
const digest = bodyDigest(text)
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === bodyDigest(text)) return
if (!force && stored?.digest === digest) return
yield* decodeCatalog(text)
yield* writeCache(text)
yield* writeCache(text, digest)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
}),
+2 -1
View File
@@ -369,9 +369,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
vcs: {
get: () => response(vcs.info()),
base: () => response(vcs.base()),
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
transform: vcs.transform,
reload: vcs.reload,
},
+137 -38
View File
@@ -4,10 +4,11 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import {
chunksByFile,
emptyPatch,
@@ -35,9 +36,10 @@ export const Plugin = define({
id: "git",
name: "Git",
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
})
})
}),
@@ -48,7 +50,7 @@ export const Plugin = define({
* batched through one `git diff` invocation where possible and capped by
* per-file and total byte budgets, falling back to empty patches when capped.
*/
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }) {
// Listing commands scope pathspecs to the requested directory; per-file
// commands run from the worktree root because git lists root-relative paths.
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
@@ -60,6 +62,7 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
})
return { branch: { current, default: root?.name } } satisfies Info
}),
base: () => ctx.git.base(ctx.directory),
branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) {
return yield* ctx.git.branches(ctx.directory, options)
}),
@@ -93,25 +96,23 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
return yield* track(ctx, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
}
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
if (!root) return []
if (current && current === root.name) return []
const ref = yield* git.mergeBase(ctx.directory, root.ref)
if (!ref) return []
return yield* diffAgainstRef(ctx, ref, options)
if (!(yield* git.hasHead(ctx.directory))) {
return mode === "committed" ? [] : yield* track(ctx, undefined, options)
}
const base = options?.base ?? (yield* git.defaultBranch(ctx.directory))?.ref
const ref = base ? yield* git.mergeBase(ctx.directory, base) : undefined
if (!ref) {
return yield* new DiffError({
message: base ? `No merge base available for ${base}` : "No review base available",
})
}
return yield* diffAgainstRef(ctx, ref, { ...options, target: mode === "committed" ? "HEAD" : undefined })
}),
}
} satisfies Adapter
}
type Kind = FileStatus["status"]
interface Base {
readonly name: string
readonly ref: string
}
interface Item {
readonly file: string
readonly code: string
@@ -124,8 +125,11 @@ interface Stat {
readonly deletions: number
}
interface PatchOptions {
readonly context?: number
interface GitDiffOptions extends DiffOptions {
readonly target?: "HEAD"
}
interface PatchOptions extends GitDiffOptions {
readonly maxOutputBytes?: number
}
@@ -196,7 +200,7 @@ function makeGit(proc: AppProcess.Interface) {
const result = yield* run(["config", "init.defaultBranch"], { cwd })
const name = result.text().trim()
if (!name || !list.includes(name)) return
return { name, ref: name } satisfies Base
return { name, ref: name }
})
const primary = Effect.fnUntraced(function* (cwd: string) {
@@ -239,15 +243,70 @@ function makeGit(proc: AppProcess.Interface) {
.trim()
.replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
if (name) return { name, ref }
}
}
const list = yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
const next = yield* configured(cwd, list)
if (next) return next
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
if (list.includes("main")) return { name: "main", ref: "main" }
if (list.includes("master")) return { name: "master", ref: "master" }
})
const resolve = Effect.fnUntraced(function* (cwd: string, ref: string) {
const result = yield* run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd })
if (result.exitCode !== 0) return
return result.text().trim() || undefined
})
const ancestor = Effect.fnUntraced(function* (cwd: string, commit: string, ref: string) {
if (!/^[a-f0-9]{40,64}$/.test(commit)) return false
return (yield* run(["merge-base", "--is-ancestor", commit, ref], { cwd })).exitCode === 0
})
const namedRef = Effect.fnUntraced(function* (cwd: string, input: string) {
// Creation hints must identify a branch, not HEAD, an object ID, or a revision expression.
if (input === "HEAD" || input.endsWith("/HEAD") || /[~^:@{}\s]/.test(input)) return
const ref = (yield* text(["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", input], {
cwd,
})).trim()
if (!/^refs\/(heads|remotes)\/.+/.test(ref) || ref.endsWith("/HEAD") || !(yield* resolve(cwd, ref))) return
return { name: ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""), ref }
})
const base = Effect.fn("VcsGit.base")(function* (cwd: string) {
if (!(yield* hasHead(cwd))) return null
const current = yield* branch(cwd)
if (!current) return yield* new DiffError({ message: "Choose a review base" })
const history = (yield* lines(
["reflog", "show", "--max-count=256", "--format=%H%x00%gs", `refs/heads/${current}`],
{
cwd,
},
)).flatMap((line) => {
const match = /^([a-f0-9]+)\0(.+)$/.exec(line)
return match ? [{ commit: match[1], message: match[2] }] : []
})
const renamed = history.some((entry) => entry.message.startsWith("Branch: renamed "))
const creation = renamed ? undefined : history.find((entry) => entry.message.startsWith("branch: Created from "))
const origin = creation?.message.slice("branch: Created from ".length)
if (creation && origin) {
const candidate = yield* namedRef(cwd, origin)
if (
candidate &&
candidate.name !== current &&
(yield* ancestor(cwd, creation.commit, "HEAD")) &&
(yield* ancestor(cwd, creation.commit, candidate.ref))
) {
return { ...candidate, source: "reflog" } satisfies Base
}
}
const root = yield* defaultBranch(cwd)
if (!root || current !== root.name) return yield* new DiffError({ message: "Choose a review base" })
const candidate = yield* namedRef(cwd, root.ref)
if (!candidate) return yield* new DiffError({ message: "The default review base is unavailable" })
return { name: root.name, ref: candidate.ref, source: "default" } satisfies Base
})
const hasHead = Effect.fn("VcsGit.hasHead")(function* (cwd: string) {
@@ -256,7 +315,9 @@ function makeGit(proc: AppProcess.Interface) {
})
const mergeBase = Effect.fn("VcsGit.mergeBase")(function* (cwd: string, base: string) {
const result = yield* run(["merge-base", base, "HEAD"], { cwd })
const ref = yield* resolve(cwd, base)
if (!ref) return
const result = yield* run(["merge-base", ref, "HEAD"], { cwd })
if (result.exitCode !== 0) return
return result.text().trim() || undefined
})
@@ -272,10 +333,13 @@ function makeGit(proc: AppProcess.Interface) {
})
})
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string) {
const list = nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string, target?: string) {
const result = yield* run(
["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, ...(target ? [target] : []), "--", "."],
{ cwd },
)
if (result.exitCode !== 0) return yield* new DiffError({ message: "Unable to list Git changes" })
const list = nuls(result.text())
return list.flatMap((code, idx) => {
if (idx % 2 !== 0) return []
const file = list[idx + 1]
@@ -284,9 +348,12 @@ function makeGit(proc: AppProcess.Interface) {
})
})
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string) {
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string, target?: string) {
return nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
yield* text(
["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, ...(target ? [target] : []), "--", "."],
{ cwd },
),
).flatMap((item) => {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
@@ -309,7 +376,17 @@ function makeGit(proc: AppProcess.Interface) {
const patch = Effect.fn("VcsGit.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
[
"diff",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
ref,
...(options?.target ? [options.target] : []),
"--",
file,
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
@@ -317,7 +394,17 @@ function makeGit(proc: AppProcess.Interface) {
const patchAll = Effect.fn("VcsGit.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
[
"diff",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
ref,
...(options?.target ? [options.target] : []),
"--",
".",
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.text(), truncated: result.truncated } satisfies Patch
@@ -367,6 +454,7 @@ function makeGit(proc: AppProcess.Interface) {
return {
branch,
branches,
base,
defaultBranch,
hasHead,
mergeBase,
@@ -393,10 +481,11 @@ const merge = (...lists: Item[][]) => {
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) {
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: GitDiffOptions) {
if (list.length === 0) return emptyBatch()
const result = yield* ctx.git.patchAll(ctx.directory, ref, {
target: options?.target,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
@@ -407,7 +496,12 @@ const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: I
}
})
const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, item: Item, options?: DiffOptions) {
const nativePatch = Effect.fnUntraced(function* (
ctx: Ctx,
ref: string | undefined,
item: Item,
options?: GitDiffOptions,
) {
const result =
item.code === "??" || !ref
? yield* ctx.git.patchUntracked(ctx.worktree, item.file, {
@@ -415,6 +509,7 @@ const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefin
maxOutputBytes: MAX_PATCH_BYTES,
})
: yield* ctx.git.patch(ctx.worktree, ref, item.file, {
target: options?.target,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
@@ -434,7 +529,7 @@ const patchForItem = Effect.fnUntraced(function* (
item: Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
options?: DiffOptions,
options?: GitDiffOptions,
) {
if (capped) return emptyPatch(item.file)
@@ -450,7 +545,7 @@ const files = Effect.fnUntraced(function* (
list: Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
options?: DiffOptions,
options?: GitDiffOptions,
) {
const next: FileDiff.Info[] = []
let total = 0
@@ -459,7 +554,7 @@ const files = Effect.fnUntraced(function* (
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat =
map.get(item.file) ??
(item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
(!options?.target && item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
const patch = yield* patchForItem(ctx, ref, item, batch, capped, options)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
@@ -481,9 +576,13 @@ const files = Effect.fnUntraced(function* (
return next
})
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: DiffOptions) {
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: GitDiffOptions) {
const [list, stats, extra] = yield* Effect.all(
[ctx.git.diff(ctx.directory, ref), ctx.git.stats(ctx.directory, ref), ctx.git.status(ctx.directory)],
[
ctx.git.diff(ctx.directory, ref, options?.target),
ctx.git.stats(ctx.directory, ref, options?.target),
options?.target ? Effect.succeed([]) : ctx.git.status(ctx.directory),
],
{ concurrency: 3 },
)
return yield* files(
+7 -1
View File
@@ -10,6 +10,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import {
addPatch,
chunksByFile,
@@ -42,7 +43,7 @@ export const Plugin = define({
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
})
})
}),
@@ -121,6 +122,11 @@ function make(
}),
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
if (mode === "working") return yield* diffAgainst(undefined, options)
if (mode === "committed" || options?.base !== undefined) {
return yield* new DiffError({
message: "The Mercurial provider does not support committed reviews or explicit bases",
})
}
const branch = yield* hg.branch()
if (!branch || branch === "default") return []
+1
View File
@@ -62,6 +62,7 @@ export const root = Effect.fn("Project.root")(function* (
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -164,7 +164,9 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell")
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return message.metadata?.background === true
? ""
: `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return ""
}
+69
View File
@@ -2,6 +2,7 @@ export * as SessionErrors from "./error.js"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Skill } from "@opencode-ai/schema/skill"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -10,6 +11,35 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Session.
sessionID: SessionSchema.ID,
}) {}
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class MessageNotAssistantError extends Schema.TaggedError<MessageNotAssistantError>()(
"Session.MessageNotAssistantError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageIncompleteError extends Schema.TaggedError<MessageIncompleteError>()(
"Session.MessageIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class MessageToolIncompleteError extends Schema.TaggedError<MessageToolIncompleteError>()(
"Session.MessageToolIncompleteError",
{
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
},
) {}
export class ForkEmptyError extends Schema.TaggedError<ForkEmptyError>()("Session.ForkEmptyError", {
sessionID: SessionSchema.ID,
}) {
@@ -52,3 +82,42 @@ export class UserInterruptedError extends Schema.TaggedError<UserInterruptedErro
return "Session interrupted by user"
}
}
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class SyntheticConflictError extends Schema.TaggedError<SyntheticConflictError>()(
"Session.SyntheticConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class AttachmentError extends Schema.TaggedError<AttachmentError>()("Session.AttachmentError", {
uri: Schema.String,
message: Schema.String,
}) {}
export class CompactionConflictError extends Schema.TaggedError<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedError<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
+4
View File
@@ -18,6 +18,8 @@ import { SessionInbox } from "./inbox.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Checks process-local ownership, including interruption cleanup and terminal settlement. */
readonly isActive: (sessionID: SessionSchema.ID) => Effect.Effect<boolean>
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
@@ -142,6 +144,7 @@ export const layer = Layer.effect(
return Service.of({
active: coordinator.active,
isActive: coordinator.isActive,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, "user")
@@ -178,6 +181,7 @@ export const noopLayer = Layer.succeed(
Service,
Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
@@ -9,6 +9,7 @@ import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { ShellResult } from "../../shell/result.js"
import { SubagentCompletion } from "../subagent-completion.js"
const CONTINUE_AFTER_SERVER_RESTART =
@@ -116,13 +117,13 @@ export const layer = (options?: Options) =>
id: background.notificationID,
sessionID: recovery.sessionID,
description: recovery.command,
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
metadata: {
source: "shell",
...ShellResult.notification({
jobID: background.id,
shellID: recovery.shellID,
command: recovery.command,
state,
},
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
@@ -156,7 +157,7 @@ export const layer = (options?: Options) =>
yield* notify(background)
return
}
if ((yield* execution.active).has(recovery.childSessionID)) return
if (yield* execution.isActive(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
+143 -111
View File
@@ -1,7 +1,8 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import {
Compaction,
CompactionPayload,
@@ -15,7 +16,7 @@ import {
User,
UserPayload,
} from "@opencode-ai/schema/session-inbox"
import type { Database } from "../database/database.js"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
@@ -65,6 +66,13 @@ export class LifecycleConflict extends Schema.TaggedError<LifecycleConflict>()("
id: SessionMessage.ID,
}) {}
function matches<Type extends Item["type"]>(
stored: Info,
request: PendingRef & { readonly type: Type },
): stored is Extract<Info, { readonly type: Type }> {
return stored.sessionID === request.sessionID && stored.type === request.type
}
const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
@@ -116,7 +124,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
return yield* new LifecycleConflict({ id })
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
@@ -131,89 +139,140 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
type: "synthetic",
payload: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
return yield* new LifecycleConflict({ id })
})
/** Reconciles pending or delivered work without preparing a new admission payload. */
export const reconcile = Effect.fn("SessionInbox.reconcile")(function* (
db: DatabaseService,
request: {
export type Interface = Effect.Success<ReturnType<typeof make>>
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInbox") {}
export const make = Effect.fn("SessionInbox.make")(function* () {
const database = yield* Database.Service
const db = database.db
const bus = yield* Bus.Service
/** First admission wins for matching Session and type, without preparing a new payload. */
const reconcile = Effect.fn("SessionInbox.reconcile")(function* <Type extends Item["type"]>(request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly type: Type
readonly delivery: Delivery
}) {
const existing =
(yield* find(db, request.id)) ?? (yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery))
if (existing === undefined) return undefined
if (existing.type === "compaction" || !matches(existing, request))
return yield* new LifecycleConflict({ id: request.id })
return existing
})
const admit = Effect.fn("SessionInbox.admit")(function* <Type extends Item["type"]>(request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item & { readonly type: Type }
}) {
const existing = yield* reconcile({ ...request, type: request.item.type, delivery: request.item.delivery })
if (existing !== undefined) return existing
const admitted = yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.map((event) =>
Info.make({
id: request.id,
sessionID: request.sessionID,
timeCreated: DateTime.makeUnsafe(event.created),
...request.item,
}),
),
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, request.id).pipe(
Effect.flatMap((stored) => (stored === undefined ? Effect.fail(defect) : Effect.succeed(stored))),
)
: Effect.die(defect),
),
)
if (!matches(admitted, { ...request, type: request.item.type }))
return yield* new LifecycleConflict({ id: request.id })
return admitted
})
const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly delivery: Delivery
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
return yield* promotedFromMessage(db, request.sessionID, request.id, request.delivery)
})
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
},
) {
const existing = yield* reconcile(db, { ...request, delivery: request.item.delivery })
if (existing !== undefined) return existing
return yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.map((event) =>
Info.make({
id: request.id,
sessionID: request.sessionID,
timeCreated: DateTime.makeUnsafe(event.created),
...request.item,
}),
),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
}) {
return yield* serialized(
input.sessionID,
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* new LifecycleConflict({ id: input.id })
}
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
return yield* new LifecycleConflict({ id: input.id })
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
if (pending) return pending
return yield* admit({
id: input.id,
sessionID: input.sessionID,
item: { type: "compaction", payload: {}, delivery: Delivery.make(input.delivery) },
})
}),
)
})
const cancel = Effect.fn("SessionInbox.cancel")((input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
const steer = Effect.fn("SessionInbox.steer")((input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
const queue = Effect.fn("SessionInbox.queue")((input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
return {
list: (sessionID: SessionSchema.ID) => list(db, sessionID),
reconcile,
admit,
admitCompaction,
cancel,
steer,
queue,
}
})
export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
return yield* serialized(
input.sessionID,
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
if (pending) return pending
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}),
)
})
export const layer = Layer.effect(Service, make())
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
db: DatabaseService,
@@ -392,39 +451,11 @@ export const has = Effect.fn("SessionInbox.has")(function* (
})
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
serialized(input.sessionID, effect).pipe(
Effect.asVoid,
// Bus projectors abort their transaction through the defect channel.
Effect.catchDefect((defect) => (defect instanceof LifecycleConflict ? Effect.fail(defect) : Effect.die(defect))),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
@@ -447,6 +478,7 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
Effect.orDie,
)
: Effect.die(defect),
),
+2 -1
View File
@@ -169,7 +169,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
+16
View File
@@ -0,0 +1,16 @@
export * as SessionPromptNode from "./prompt-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "../image.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Skill } from "../skill.js"
import { SessionPrompt } from "./prompt.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionPrompt.Service,
layer: SessionPrompt.layer,
deps: [FSUtil.node, PluginSupervisor.node, PluginHooks.node, Image.node, Skill.node],
})
+231
View File
@@ -0,0 +1,231 @@
export * as SessionPrompt from "./prompt.js"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Image } from "../image.js"
import { Mime } from "../mime.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Skill } from "../skill.js"
import { AttachmentError, SkillNotFoundError } from "./error.js"
export type Input = {
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
}
export const make = Effect.fn("SessionPrompt.make")(function* () {
const fs = yield* FSUtil.Service
const plugins = yield* PluginSupervisor.Service
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const skillService = yield* Skill.Service
const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
sessionID: Session.ID
messageID: SessionMessage.ID
input: Input
}) {
yield* plugins.flush
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID: request.messageID,
prompt: structuredClone({
text: request.input.text,
files: request.input.files?.slice(),
agents: request.input.agents?.slice(),
skills: request.input.skills?.slice(),
}),
metadata: structuredClone(request.input.metadata),
delivery: request.input.delivery ?? "steer",
})
const input = event.prompt
const files = input.files
? yield* Effect.forEach(input.files, materializeAttachment, { concurrency: 8 })
: undefined
const requested = input.skills
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return {
type: "user",
payload: SessionInbox.UserPayload.make({
...Prompt.make({
text: input.text,
agents: input.agents,
files,
skills: selected?.length ? selected : undefined,
}),
metadata: event.metadata,
}),
delivery: SessionInbox.Delivery.make(event.delivery),
} satisfies SessionInbox.Item
})
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
input: PromptInput.FileAttachment,
) {
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
name: undefined,
mime: undefined,
}
: yield* readFileAttachment(input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
)
: resolved.bytes
const normalized = yield* normalizeImageAttachment(input, Buffer.from(content).toString("base64"), mime)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
mention: input.mention,
})
})
const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: string,
mime: string,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* image.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
)
return { data: Base64.make(normalized.content), mime: normalized.mime }
})
const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(function* (uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
})
if (url.protocol !== "file:")
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
const target = yield* Effect.try({
try: () => {
url.search = ""
url.hash = ""
return fileURLToPath(url)
},
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
})
const info = yield* fs
.stat(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") {
const entries = yield* fs
.readDirectoryEntries(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
const bytes = yield* fs
.readFile(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
})
return { prepare }
})
export type Interface = Effect.Success<ReturnType<typeof make>>
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}
export const layer = Layer.effect(Service, make())
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
if (comma === -1) throw new Error("Invalid data URL")
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
return Buffer.from(decodeURIComponent(payload))
const bytes = Buffer.from(payload, "base64")
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
function positiveInt(value: string | null) {
if (value === null) return
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
+15
View File
@@ -0,0 +1,15 @@
export * as SessionRevertNode from "./revert-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Snapshot } from "../snapshot.js"
import { SessionRevert } from "./revert.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionRevert.Service,
layer: SessionRevert.layer,
deps: [Database.node, Bus.node, PluginSupervisor.node, Snapshot.node],
})
+76 -63
View File
@@ -1,28 +1,97 @@
export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { RelativePath } from "../schema.js"
import { Snapshot } from "../snapshot.js"
import { SessionEvent } from "./event.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export { MessageNotFoundError }
interface BoundaryInput {
readonly sessionID: SessionSchema.ID
readonly messageID: SessionMessage.ID
}
const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
const db = (yield* Database.Service).db
export interface Interface {
readonly stage: (input: {
readonly session: SessionSchema.Info
readonly messageID: SessionMessage.ID
readonly files?: boolean
}) => Effect.Effect<SessionSchema.Revert, MessageNotFoundError | Snapshot.Error>
readonly clear: (session: SessionSchema.Info) => Effect.Effect<void, Snapshot.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export const make = Effect.fn("SessionRevert.make")(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const plugins = yield* PluginSupervisor.Service
const snapshot = yield* Snapshot.Service
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
yield* plugins.flush
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
: []
const revert = {
messageID: input.messageID,
snapshot: original,
files,
} satisfies SessionSchema.Info["revert"]
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
revert,
})
return revert
})
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
yield* plugins.flush
if (!session.revert) return
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
})
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
})
})
return { stage, clear }
})
export const layer = Layer.effect(Service, make())
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
if (!session.revert) return
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})
})
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -53,59 +122,3 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
}
return files
})
export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
readonly session: SessionSchema.Info
readonly messageID: SessionMessage.ID
readonly files?: boolean
}) {
const snapshot = yield* Snapshot.Service
const bus = yield* Bus.Service
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
? yield* snapshot.diff({ from: original, to: (yield* snapshot.capture()) ?? original, paths })
: []
const revert = {
messageID: input.messageID,
snapshot: original,
files,
} satisfies SessionSchema.Info["revert"]
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
revert,
})
return revert
})
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const snapshot = yield* Snapshot.Service
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
})
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
})
})
export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})
})
+5 -1
View File
@@ -7,6 +7,8 @@ import type { Promotable } from "./inbox.js"
export interface Coordinator<Key, E, Reason = never> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Checks ownership for one key, including cleanup and terminal settlement. */
readonly isActive: (key: Key) => Effect.Effect<boolean>
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
@@ -112,6 +114,8 @@ export const make = <Key, E, Reason = never>(options: {
Deferred.doneUnsafe(execution.done, exit)
}
const isActive = (key: Key) => Effect.sync(() => executions.has(key))
const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => {
const execution = executions.get(key)
@@ -166,5 +170,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
})
@@ -260,6 +260,8 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
case "system":
return [Message.system(message.text)]
case "shell":
// Background shell results enter context once, through their completion inbox item.
if (message.metadata?.background === true) return []
return [
Message.make({
id: message.id,
+416
View File
@@ -0,0 +1,416 @@
export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import { Event } from "@opencode-ai/schema/event"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
import {
BusyError,
CompactionConflictError,
InboxConflictError,
MessageIncompleteError,
MessageNotAssistantError,
MessageNotFoundError,
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SyntheticConflictError,
} from "./error.js"
import { SessionEvent } from "./event.js"
import { SessionExecution } from "./execution.js"
import { SessionInbox } from "./inbox.js"
import { SessionMessage } from "./message.js"
import { SessionPrompt } from "./prompt.js"
import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
export type Services = PluginSupervisor.Service | SessionPrompt.Service | SessionRevert.Service | Shell.Service
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
resume?: boolean
}
/**
* Build once in the host Scope: `const sessions = yield* Session.make(servicesFor)`.
* Use `sessions.forSession(id)` for handles that share host services and reload current state.
*/
export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer<Services>) {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const admission = yield* SessionInbox.Service
const scope = yield* Scope.Scope
const get = Effect.fn("Session.get")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
})
const message = Effect.fn("Session.message")(function* (sessionID: SessionSchema.ID, messageID: SessionMessage.ID) {
const stored = yield* store.message(messageID)
return stored?.sessionID === sessionID ? stored.message : undefined
})
const updateMessage = Effect.fn("Session.updateMessage")(function* (
sessionID: SessionSchema.ID,
input: { readonly messageID: SessionMessage.ID; readonly content: readonly SessionMessage.AssistantContent[] },
) {
const ref = { sessionID, messageID: input.messageID }
yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
const current = yield* message(sessionID, input.messageID)
if (!current) return yield* new MessageNotFoundError(ref)
if (current.type !== "assistant") return yield* new MessageNotAssistantError(ref)
if (!current.time.completed) return yield* new MessageIncompleteError(ref)
if (input.content.some(isUnfinishedTool)) return yield* new MessageToolIncompleteError(ref)
yield* bus.publish(SessionEvent.MessageContentUpdated, {
...ref,
content: Schema.encodeSync(Schema.Array(SessionMessage.AssistantContent))(input.content),
})
const updated = yield* message(sessionID, input.messageID)
if (updated?.type !== "assistant") return yield* new MessageNotFoundError(ref)
return updated
})
const view = Effect.fn("Session.view")(function* (sessionID: SessionSchema.ID, input: { idle: number }) {
const session = yield* get(sessionID)
if (
session.time.idle === undefined ||
input.idle > DateTime.toEpochMillis(session.time.idle) ||
(session.time.viewed !== undefined && DateTime.toEpochMillis(session.time.viewed) >= input.idle)
)
return
yield* bus.publish(SessionEvent.Viewed, { sessionID, idle: input.idle })
})
const rename = Effect.fn("Session.rename")(function* (sessionID: SessionSchema.ID, input: { title: string }) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
) {
const session = yield* get(sessionID)
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: input.agent, previous: session.agent })
})
const switchModel = Effect.fn("Session.switchModel")(function* (
sessionID: SessionSchema.ID,
input: { model: Model.Ref },
) {
const session = yield* get(sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* bus.publish(SessionEvent.ModelSelected, { sessionID, model: input.model, previous: session.model })
})
const mutatePending = (
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
mutation: (input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
}) => Effect.Effect<void, SessionInbox.LifecycleConflict>,
) =>
mutation({ sessionID, id: inboxID }).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () =>
Effect.gen(function* () {
yield* get(sessionID)
return yield* new InboxConflictError({ sessionID, inboxID })
}),
),
)
const inbox = Effect.fn("Session.inbox")(function* (sessionID: SessionSchema.ID) {
yield* get(sessionID)
return yield* admission.list(sessionID)
})
const cancelInbox = Effect.fn("Session.cancelInbox")(
(sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.cancel),
Effect.uninterruptible,
)
const steerInbox = Effect.fn("Session.steerInbox")(function* (
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
) {
yield* mutatePending(sessionID, inboxID, admission.steer)
yield* execution.wake(sessionID)
}, Effect.uninterruptible)
const queueInbox = Effect.fn("Session.queueInbox")(
(sessionID: SessionSchema.ID, inboxID: SessionMessage.ID) => mutatePending(sessionID, inboxID, admission.queue),
Effect.uninterruptible,
)
const prompt = Effect.fn("Session.prompt")((sessionID: SessionSchema.ID, input: PromptRequest) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const session = yield* get(sessionID)
const messageID = input.id ?? SessionMessage.ID.create()
const admitted = yield* Effect.gen(function* () {
const existing = yield* admission.reconcile({
id: messageID,
sessionID: session.id,
type: "user",
delivery: input.delivery ?? "steer",
})
if (existing) return existing
const item = yield* restore(
SessionPrompt.Service.use((preparation) => preparation.prepare({ sessionID, messageID, input })).pipe(
Effect.provide(servicesFor(session.location)),
),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(bus, session)
return yield* admission.admit({
id: messageID,
sessionID: session.id,
item,
})
}).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
)
if (input.resume !== false) yield* execution.wake(sessionID)
return admitted
}),
),
)
const shell = Effect.fn("Session.shell")(function* (
sessionID: SessionSchema.ID,
input: { id?: Event.ID; command: string },
) {
const session = yield* get(sessionID)
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(servicesFor(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID, background: true },
})
.pipe(
Effect.tapError((error) =>
synthetic(sessionID, {
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID,
shell: terminal.info,
output: preview,
})
yield* synthetic(sessionID, {
...ShellResult.userNotification(terminal),
resume: false,
}).pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
})
const compact = Effect.fn("Session.compact")(function* (
sessionID: SessionSchema.ID,
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
) {
yield* get(sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* admission
.admitCompaction({
id: inputID,
sessionID,
delivery: input.delivery ?? "steer",
})
.pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new CompactionConflictError({ sessionID, inputID })),
)
yield* execution.wake(sessionID)
return admitted
})
const wait = Effect.fn("Session.wait")(function* (sessionID: SessionSchema.ID) {
yield* get(sessionID)
yield* execution.awaitIdle(sessionID)
})
const resume = Effect.fn("Session.resume")(function* (sessionID: SessionSchema.ID) {
yield* get(sessionID)
yield* execution.resume(sessionID)
})
const synthetic = Effect.fn("Session.synthetic")(
(
sessionID: SessionSchema.ID,
input: {
id?: SessionMessage.ID
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
resume?: boolean
},
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* get(sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = {
type: "synthetic",
payload: SessionInbox.SyntheticPayload.make({
text: input.text,
description: input.description,
metadata: input.metadata,
}),
delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"),
} satisfies SessionInbox.Item
const admitted = yield* admission
.admit({
id: inputID,
sessionID,
item: admittedInput,
})
.pipe(
Effect.catchTag(
"SessionInbox.LifecycleConflict",
() => new SyntheticConflictError({ sessionID, inputID }),
),
)
if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID)
return admitted
}),
),
)
const interrupt = Effect.fn("Session.interrupt")(
(sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) =>
Effect.uninterruptible(execution.interrupt(sessionID, options)),
)
const stage = Effect.fn("Session.revert.stage")(function* (
sessionID: SessionSchema.ID,
input: { messageID: SessionMessage.ID; files?: boolean },
) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.Service.use((revert) =>
revert.stage({ session, messageID: input.messageID, files: input.files }),
).pipe(Effect.provide(servicesFor(session.location)))
})
const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(
Effect.provide(servicesFor(session.location)),
)
return yield* execution.wake(sessionID)
})
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.commit(bus, session)
})
const revert = { stage, clear, commit }
const operations = {
get,
message,
updateMessage,
view,
rename,
switchAgent,
switchModel,
inbox,
prompt,
synthetic,
shell,
compact,
wait,
resume,
interrupt,
cancelInbox,
steerInbox,
queueInbox,
revert,
}
const forSession = (sessionID: SessionSchema.ID) => {
const get = operations.get.bind(undefined, sessionID)
const message = operations.message.bind(undefined, sessionID)
const updateMessage = operations.updateMessage.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
const prompt = operations.prompt.bind(undefined, sessionID)
const synthetic = operations.synthetic.bind(undefined, sessionID)
const shell = operations.shell.bind(undefined, sessionID)
const compact = operations.compact.bind(undefined, sessionID)
const wait = operations.wait.bind(undefined, sessionID)
const resume = operations.resume.bind(undefined, sessionID)
const interrupt = operations.interrupt.bind(undefined, sessionID)
const cancelInbox = operations.cancelInbox.bind(undefined, sessionID)
const steerInbox = operations.steerInbox.bind(undefined, sessionID)
const queueInbox = operations.queueInbox.bind(undefined, sessionID)
const stage = operations.revert.stage.bind(undefined, sessionID)
const clear = operations.revert.clear.bind(undefined, sessionID)
const commit = operations.revert.commit.bind(undefined, sessionID)
const revert = { stage, clear, commit }
return {
id: sessionID,
get,
message,
updateMessage,
view,
rename,
switchAgent,
switchModel,
inbox,
prompt,
synthetic,
shell,
compact,
wait,
resume,
interrupt,
cancelInbox,
steerInbox,
queueInbox,
revert,
}
}
return { forSession }
})
export type Handle = ReturnType<Effect.Success<ReturnType<typeof make>>["forSession"]>
function isUnfinishedTool(content: SessionMessage.AssistantContent) {
return content.type === "tool" && (content.state.status === "streaming" || content.state.status === "running")
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
+119 -2
View File
@@ -1,7 +1,10 @@
export * as SessionStore from "./store.js"
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode-ai/schema/project"
import { Workspace } from "@opencode-ai/schema/workspace"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionHistory } from "./history.js"
@@ -11,8 +14,45 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql.js"
import { fromRow } from "./info.js"
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
anchor: Session.ListAnchor.pipe(Schema.optional),
}
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
export type MessagesInput = {
sessionID: Session.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
direction: "previous" | "next"
}
}
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
@@ -55,6 +95,83 @@ const layer = Layer.effect(
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
list: Effect.fn("SessionStore.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("SessionStore.messages")(function* (input) {
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
+35 -4
View File
@@ -18,6 +18,9 @@ import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionEnvironment } from "./session/environment.js"
import { SessionSchema } from "./session/schema.js"
import { Config } from "./config.js"
import { ToolOutput } from "./tool-output.js"
import { ShellResult } from "./shell/result.js"
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -65,6 +68,8 @@ export interface Interface {
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// A known shell's terminal state and bounded tail. Missing capture remains distinct from its exit status.
readonly result: (started: Shell.Info) => Effect.Effect<ShellResult.Result>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
@@ -120,6 +125,7 @@ const layer = () =>
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const commands = new Map<Shell.ID, Active>()
@@ -217,6 +223,28 @@ const layer = () =>
}
})
const result = Effect.fn("Shell.result")(function* (started: Shell.Info) {
const info = yield* wait(started.id).pipe(
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ ...started, status: "killed" as const, time: { ...started.time, completed: Date.now() } }),
),
)
const capture = yield* Effect.gen(function* () {
const limits = Config.latest(yield* config.entries(), "tool_output")
const maxLines = limits?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = limits?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* output(info.id, { cursor: Math.max(0, latest.size - maxBytes), limit: maxBytes })
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const text = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return { output: `${text || "(no output)"}${notice}`, truncated }
}).pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(undefined)))
return { info, capture }
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
@@ -341,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) =>
@@ -382,7 +412,7 @@ const layer = () =>
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
return Service.of({ create, list, get, wait, result, timeout, output, remove })
}),
)
@@ -397,6 +427,7 @@ export const node = makeLocationNode({
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
Config.node,
cleanupNode,
],
})
+74
View File
@@ -0,0 +1,74 @@
export * as ShellResult from "./result.js"
import type { Shell } from "@opencode-ai/schema/shell"
export type Result = {
info: Shell.Info
capture: { output: string; truncated: boolean } | undefined
}
type Output = { output: string; truncated: boolean; exit?: number; timeout?: boolean }
const missing = "Shell command output is no longer available."
export const unavailable: Shell.Output = {
output: missing,
cursor: Buffer.byteLength(missing),
size: Buffer.byteLength(missing),
truncated: false,
}
export function output(result: Result): Output {
return {
output: result.capture?.output ?? unavailable.output,
truncated: result.capture?.truncated ?? false,
...(result.info.exit !== undefined ? { exit: result.info.exit } : {}),
...(result.info.status === "timeout" ? { timeout: true } : {}),
}
}
export function notice(output: Pick<Output, "exit" | "timeout">) {
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
}
export function metadata(output: Output) {
return {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
}
export function notification(input: {
shellID: string
jobID?: string
command: string
state: "completed" | "cancelled" | "error"
text: string
output?: Output
}) {
return {
text: `<shell id="${input.jobID ?? input.shellID}" state="${input.state}" command="${input.command}">\n${input.text}\n</shell>`,
metadata: {
source: "shell",
shellID: input.shellID,
...(input.jobID !== undefined ? { jobID: input.jobID } : {}),
state: input.state,
...(input.output ? metadata(input.output) : {}),
},
}
}
export function userNotification(result: Result) {
const captured = output(result)
const status =
result.info.status === "killed" ? "Command cancelled." : (notice(captured) ?? "Command exited with code unknown.")
const message = notification({
shellID: result.info.id,
command: result.info.command,
state: result.info.status === "killed" ? "cancelled" : "completed",
text: `${captured.output}\n\n${status}`,
output: captured,
})
return { ...message, text: `The following shell command was executed by the user:\n${message.text}` }
}
+71 -113
View File
@@ -2,6 +2,8 @@ export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import type { Tool } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
@@ -13,7 +15,7 @@ import { SessionSchema } from "../../session/schema.js"
import { Shell } from "../../shell.js"
import { ShellParse } from "../../shell/parse.js"
import { ShellSelect } from "../../shell/select.js"
import { ToolOutput } from "../../tool-output.js"
import { ShellResult } from "../../shell/result.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@@ -71,11 +73,7 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const resultMessages = (output: Output) => {
const notice = (() => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
})()
const notice = output.status === "running" ? BACKGROUND_INSTRUCTION : ShellResult.notice(output)
return [output.output, ...(notice ? [notice] : [])]
}
@@ -85,10 +83,8 @@ const toolResult = (output: Output) => {
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
metadata: {
status: output.status,
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...ShellResult.metadata(output),
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}
@@ -113,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,
@@ -132,21 +178,15 @@ export const Plugin = {
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID,
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
...ShellResult.notification({
jobID: id,
shellID,
command,
state: info.status,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
text,
output,
}),
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
},
@@ -163,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(
@@ -180,101 +215,24 @@ 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 })
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - maxBytes),
limit: maxBytes,
})
const lines = page.output.split("\n")
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
const settled = yield* Deferred.make<Output>()
const run = Effect.gen(function* () {
const result = yield* shell.result(info)
if (!result.capture) return yield* new Shell.NotFoundError({ id: info.id })
const output = ShellResult.output(result)
return {
output: `${output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
truncated: capture.truncated,
...output,
output: output.timeout
? `${output.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`
: output.output,
status: "completed" as const,
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
}).pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => resultMessages(output).join("\n\n")),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
+41 -6
View File
@@ -5,7 +5,7 @@ import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -14,10 +14,15 @@ import { Bus } from "./bus.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
export { BranchList, FileStatus, Info, Mode }
export { Base, BranchList, FileStatus, Info, Mode }
export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError", {
message: Schema.String,
}) {}
export interface DiffOptions {
readonly context?: number
readonly base?: string
}
export interface BranchOptions {
@@ -27,12 +32,15 @@ export interface BranchOptions {
export interface Adapter {
readonly info: () => Effect.Effect<Info>
readonly base?: () => Effect.Effect<Base | null, DiffError>
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[], DiffError>
}
export interface Interface extends Adapter, State.Transformable<VcsDraft> {}
export interface Interface extends Adapter, State.Transformable<VcsDraft> {
readonly base: () => Effect.Effect<Base | null, DiffError>
}
interface Data {
readonly providers: Map<string, VcsDefinition>
@@ -56,6 +64,7 @@ const layer = Layer.effect(
...(vcs ? { store: vcs.store } : {}),
}
const decodeInfo = Schema.decodeUnknownEffect(Schema.toType(Info))
const decodeBase = Schema.decodeUnknownEffect(Schema.NullOr(Base))
const decodeBranches = Schema.decodeUnknownEffect(BranchList)
const decodeStatus = Schema.decodeUnknownEffect(Schema.Array(FileStatus))
const decodeDiff = Schema.decodeUnknownEffect(Schema.Array(FileDiff.Info))
@@ -86,6 +95,27 @@ const layer = Layer.effect(
),
),
)
const review = <A>(provider: VcsDefinition, operation: "base" | "diff", effect: Effect.Effect<A, unknown>) =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterrupts(cause)) return Effect.failCause(cause).pipe(Effect.orDie)
const error = Cause.squash(cause)
return Effect.logWarning("vcs provider failed", { provider: provider.id, operation, cause }).pipe(
Effect.andThen(
Effect.fail(
error instanceof DiffError
? error
: new DiffError({
message:
operation === "base"
? "VCS provider could not resolve a review base"
: "VCS provider could not produce a diff",
}),
),
),
)
}),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
@@ -117,6 +147,11 @@ const layer = Layer.effect(
info: Effect.fn("Vcs.info")(function* () {
return current.info
}),
base: Effect.fn("Vcs.base")(function* () {
const provider = selected()
if (!provider?.base) return null
return yield* review(provider, "base", provider.base(scope).pipe(Effect.flatMap(decodeBase)))
}),
branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) {
const provider = selected()
if (provider)
@@ -145,18 +180,18 @@ const layer = Layer.effect(
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
const provider = selected()
if (!provider) return []
const rows = yield* protect(
const rows = yield* review(
provider,
"diff",
provider
.diff({
...scope,
mode,
...(options?.base !== undefined ? { base: options.base } : {}),
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
.pipe(Effect.flatMap(decodeDiff)),
[],
)
let total = 0
return rows.map((row) => {
+32 -10
View File
@@ -1,18 +1,40 @@
import { Bus } from "@opencode-ai/core/bus"
import { Image } from "@opencode-ai/core/image"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
import { Skill } from "@opencode-ai/core/skill"
import type { Location } from "@opencode-ai/schema/location"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Layer, LayerMap } from "effect"
// Plain-prompt unit fixtures use virtual directories and need only the admission hook services.
export const promptLocationLayer = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
) as Layer.Layer<LocationServices>,
// Plain-prompt unit fixtures use virtual directories and need only prompt preparation services.
export const promptLocationNode = makeGlobalNode({
service: LocationServiceMap.Service,
layer: Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
return yield* LayerMap.make(
(_ref: Location.Ref) =>
SessionPrompt.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), [
[Bus.node, Layer.succeed(Bus.Service, bus)],
]),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
),
),
) as Layer.Layer<LocationServices>,
)
}),
),
)
deps: [Bus.node, FSUtil.node],
})
@@ -1,10 +1,197 @@
import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model"
import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input"
import { describe, test, expect, mock } from "bun:test"
import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import type { LanguageModelV3Prompt, LanguageModelV3ProviderTool, LanguageModelV3StreamPart } from "@ai-sdk/provider"
const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
const HOSTED_TOOL_CASES = [
{
id: "openai.web_search",
name: "current_web",
args: {},
wireType: "web_search",
output: {
type: "web_search_call",
id: "web_1",
status: "completed",
action: { type: "search", query: "news" },
},
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "web_search_call",
id: "web_1",
status: "in_progress",
action: { type: "search", query: "news" },
},
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "web_search_call",
id: "web_1",
status: "completed",
action: { type: "search", query: "news" },
},
},
],
streamEventTypes: ["tool-input-start", "tool-input-end", "tool-call", "tool-result"],
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
},
{
id: "openai.web_search_preview",
name: "preview_web",
args: {},
wireType: "web_search_preview",
output: {
type: "web_search_call",
id: "preview_1",
status: "completed",
action: { type: "search", query: "news" },
},
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "web_search_call",
id: "preview_1",
status: "in_progress",
action: { type: "search", query: "news" },
},
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "web_search_call",
id: "preview_1",
status: "completed",
action: { type: "search", query: "news" },
},
},
],
streamEventTypes: ["tool-input-start", "tool-input-end", "tool-call", "tool-result"],
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
},
{
id: "openai.file_search",
name: "documents",
args: { vectorStoreIds: ["store_1"] },
wireType: "file_search",
output: { type: "file_search_call", id: "file_1", queries: ["news"], results: null },
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "file_search_call", id: "file_1" },
},
{
type: "response.output_item.done",
output_index: 0,
item: { type: "file_search_call", id: "file_1", queries: ["news"], results: null },
},
],
streamEventTypes: ["tool-call", "tool-result"],
eventTypes: ["tool-call", "tool-result"],
},
{
id: "openai.code_interpreter",
name: "python",
args: {},
wireType: "code_interpreter",
output: {
type: "code_interpreter_call",
id: "code_1",
code: "print(1)",
container_id: "container_1",
outputs: null,
},
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: {
type: "code_interpreter_call",
id: "code_1",
code: null,
container_id: "container_1",
outputs: null,
status: "in_progress",
},
},
{
type: "response.code_interpreter_call_code.delta",
item_id: "code_1",
output_index: 0,
delta: "print(",
},
{
type: "response.code_interpreter_call_code.done",
item_id: "code_1",
output_index: 0,
code: "print(1)",
},
{
type: "response.output_item.done",
output_index: 0,
item: {
type: "code_interpreter_call",
id: "code_1",
code: "print(1)",
container_id: "container_1",
outputs: null,
},
},
],
streamEventTypes: [
"tool-input-start",
"tool-input-delta",
"tool-input-delta",
"tool-input-delta",
"tool-input-end",
"tool-call",
"tool-result",
],
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
},
{
id: "openai.image_generation",
name: "illustrate",
args: {},
wireType: "image_generation",
output: { type: "image_generation_call", id: "image_1", result: "final-image" },
stream: [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "image_generation_call", id: "image_1" },
},
{
type: "response.image_generation_call.partial_image",
item_id: "image_1",
output_index: 0,
partial_image_b64: "partial-image",
},
{
type: "response.output_item.done",
output_index: 0,
item: { type: "image_generation_call", id: "image_1", result: "final-image" },
},
],
streamEventTypes: ["tool-call", "tool-result", "tool-result"],
eventTypes: ["tool-call", "tool-result", "tool-result"],
},
] as const
function hostedTool(testCase: (typeof HOSTED_TOOL_CASES)[number]): LanguageModelV3ProviderTool {
return { type: "provider", id: testCase.id, name: testCase.name, args: testCase.args }
}
function createMockFetch(body: unknown) {
return mock(
async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
@@ -30,12 +217,144 @@ function createModel(fetchFn: ReturnType<typeof mock>) {
})
}
async function readStream(stream: ReadableStream<LanguageModelV3StreamPart>) {
const reader = stream.getReader()
const events: LanguageModelV3StreamPart[] = []
while (true) {
const item = await reader.read()
if (item.done) return events
events.push(item.value)
}
}
// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent,
// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the
// namespace request options already use. It used to echo this metadata under "openai" (a leftover
// from forking the OpenAI Responses model), which left it unreachable by anything reading the
// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace.
describe("doGenerate", () => {
test.each([...HOSTED_TOOL_CASES])("forces $id by its declared logical name", async (testCase) => {
const requests: unknown[] = []
const model = createModel(
mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requests.push(await new Response(init?.body).json())
return new Response(
JSON.stringify({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}),
)
await model.doGenerate({
prompt: TEST_PROMPT,
tools: [hostedTool(testCase)],
toolChoice: { type: "tool", toolName: testCase.name },
})
expect(requests[0]).toMatchObject({ tool_choice: { type: testCase.wireType } })
})
test("does not mistake a colliding function name for a hosted tool", async () => {
const requests: unknown[] = []
const model = createModel(
mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requests.push(await new Response(init?.body).json())
return new Response(
JSON.stringify({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}),
)
await model.doGenerate({
prompt: TEST_PROMPT,
tools: [
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
{ type: "function", name: "web_search", inputSchema: { type: "object" } },
],
toolChoice: { type: "tool", toolName: "web_search" },
})
expect(requests[0]).toMatchObject({ tool_choice: { type: "function", name: "web_search" } })
})
test.each([...HOSTED_TOOL_CASES])("uses $name for generated $id calls and results", async (testCase) => {
const model = createModel(
createMockFetch({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [testCase.output],
usage: { input_tokens: 1, output_tokens: 1 },
}),
)
const result = await model.doGenerate({ prompt: TEST_PROMPT, tools: [hostedTool(testCase)] })
expect(result.content.filter((part) => part.type === "tool-call" || part.type === "tool-result")).toMatchObject([
{ type: "tool-call", toolName: testCase.name },
{ type: "tool-result", toolName: testCase.name },
])
})
test("uses canonical names only when no hosted declaration matches", async () => {
const model = createModel(
createMockFetch({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [
HOSTED_TOOL_CASES[0].output,
HOSTED_TOOL_CASES[2].output,
HOSTED_TOOL_CASES[3].output,
HOSTED_TOOL_CASES[4].output,
{ type: "computer_call", id: "computer_1", status: "completed" },
],
usage: { input_tokens: 1, output_tokens: 1 },
}),
)
const result = await model.doGenerate({ prompt: TEST_PROMPT })
expect(result.content.filter((part) => part.type === "tool-call").map((part) => part.toolName)).toEqual([
"web_search",
"file_search",
"code_interpreter",
"image_generation",
"computer_use",
])
})
test("rejects an automatic web response when both variants have different logical names", async () => {
const model = createModel(
createMockFetch({
id: "resp_1",
created_at: 0,
model: "test-model",
output: [HOSTED_TOOL_CASES[0].output],
usage: { input_tokens: 1, output_tokens: 1 },
}),
)
await expect(
model.doGenerate({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
}),
).rejects.toThrow("ambiguous web_search response for hosted tools: current_web, preview_web")
})
test("attaches item metadata under the copilot namespace, not openai", async () => {
const mockFetch = createMockFetch({
id: "resp_1",
@@ -129,6 +448,105 @@ describe("doGenerate", () => {
})
describe("doStream", () => {
test.each([...HOSTED_TOOL_CASES])("uses $name for every streamed $id identity event", async (testCase) => {
const model = createModel(createStreamFetch(testCase.stream))
const result = await model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(testCase)],
})
const streamEvents = (await readStream(result.stream)).filter(
(event) => event.type !== "stream-start" && event.type !== "finish",
)
const events = streamEvents.filter((event) => "toolName" in event)
expect(streamEvents.map((event) => event.type)).toEqual([...testCase.streamEventTypes])
expect(events.map((event) => event.type)).toEqual([...testCase.eventTypes])
expect(events.map((event) => event.toolName)).toEqual(testCase.eventTypes.map(() => testCase.name))
})
test("uses the forced web variant's logical name when both variants are declared", async () => {
const model = createModel(createStreamFetch(HOSTED_TOOL_CASES[1].stream))
const result = await model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
toolChoice: { type: "tool", toolName: "preview_web" },
})
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
expect(events.map((event) => event.toolName)).toEqual(["preview_web", "preview_web", "preview_web"])
})
test("rejects ambiguous web variants before fetching or exposing a stream", async () => {
const fetchFn = createStreamFetch(HOSTED_TOOL_CASES[0].stream)
const model = createModel(fetchFn)
await expect(
model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
}),
).rejects.toThrow("ambiguous web_search response for hosted tools")
expect(fetchFn).not.toHaveBeenCalled()
})
test("rejects an ambiguous forced wire choice before fetching or exposing a stream", async () => {
const fetchFn = createStreamFetch(HOSTED_TOOL_CASES[0].stream)
const model = createModel(fetchFn)
await expect(
model.doStream({
prompt: TEST_PROMPT,
tools: [hostedTool(HOSTED_TOOL_CASES[0]), { ...hostedTool(HOSTED_TOOL_CASES[0]), name: "backup_web" }],
toolChoice: { type: "tool", toolName: HOSTED_TOOL_CASES[0].name },
}),
).rejects.toThrow("ambiguous web_search tool choice for hosted tools")
expect(fetchFn).not.toHaveBeenCalled()
})
test("streams a shared logical name for both web variants", async () => {
const model = createModel(createStreamFetch(HOSTED_TOOL_CASES[0].stream))
const tools = [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])].map((tool) => ({
...tool,
name: "web",
}))
const result = await model.doStream({ prompt: TEST_PROMPT, tools })
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
expect(events.map((event) => event.toolName)).toEqual(["web", "web", "web"])
})
test("uses canonical names for undeclared streamed web and computer calls", async () => {
const model = createModel(
createStreamFetch([
...HOSTED_TOOL_CASES[0].stream,
{
type: "response.output_item.added",
output_index: 1,
item: { type: "computer_call", id: "computer_1", status: "in_progress" },
},
{
type: "response.output_item.done",
output_index: 1,
item: { type: "computer_call", id: "computer_1", status: "completed" },
},
]),
)
const result = await model.doStream({ prompt: TEST_PROMPT })
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
expect(events.map((event) => event.toolName)).toEqual([
"web_search",
"web_search",
"web_search",
"computer_use",
"computer_use",
"computer_use",
])
})
test("streams sequential Copilot reasoning summary blocks", async () => {
const model = createModel(
createStreamFetch([
@@ -1,5 +1,9 @@
import { expect, test } from "bun:test"
import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider"
import type {
LanguageModelV3CallOptions,
LanguageModelV3FunctionTool,
LanguageModelV3ProviderTool,
} from "@ai-sdk/provider"
import { prepareResponsesTools } from "@opencode-ai/core/github-copilot/responses/openai-responses-prepare-tools"
function prepare(strict: boolean | undefined, strictJsonSchema: boolean) {
@@ -18,3 +22,198 @@ test("function tools prefer explicit strictness over the global fallback", () =>
expect(prepare(undefined, true)).toMatchObject({ type: "function", strict: true })
expect(prepare(undefined, false)).toMatchObject({ type: "function", strict: false })
})
const webTools: LanguageModelV3ProviderTool[] = [
{ type: "provider", id: "openai.web_search", name: "current_web", args: {} },
{ type: "provider", id: "openai.web_search_preview", name: "preview_web", args: {} },
]
test.each([
{ order: webTools, toolChoice: undefined },
{ order: webTools.toReversed(), toolChoice: undefined },
{ order: webTools, toolChoice: { type: "auto" as const } },
{ order: webTools.toReversed(), toolChoice: { type: "auto" as const } },
{ order: webTools, toolChoice: { type: "required" as const } },
{ order: webTools.toReversed(), toolChoice: { type: "required" as const } },
])("rejects differently named web variants before automatic or required selection", ({ order, toolChoice }) => {
expect(() => prepareResponsesTools({ tools: order, toolChoice, strictJsonSchema: false })).toThrow(
"ambiguous web_search response for hosted tools",
)
})
test.each([
{ order: webTools, name: "current_web", wireType: "web_search" },
{ order: webTools.toReversed(), name: "current_web", wireType: "web_search" },
{ order: webTools, name: "preview_web", wireType: "web_search_preview" },
{ order: webTools.toReversed(), name: "preview_web", wireType: "web_search_preview" },
])("uses the uniquely forced web variant independent of declaration order", ({ order, name, wireType }) => {
const result = prepareResponsesTools({
tools: order,
toolChoice: { type: "tool", toolName: name },
strictJsonSchema: false,
})
expect(result.toolChoice).toEqual({ type: wireType })
expect(result.selectedHostedTool).toMatchObject({ name, type: wireType, responseType: "web_search" })
})
test.each([{ order: webTools }, { order: webTools.toReversed() }])(
"allows indistinguishable web variants when they share one logical name",
({ order }) => {
const tools = order.map((tool) => ({ ...tool, name: "web" }))
const result = prepareResponsesTools({ tools, toolChoice: { type: "required" }, strictJsonSchema: false })
expect(result.hostedTools.map((tool) => tool.name)).toEqual(["web", "web"])
},
)
const duplicateToolCases = [
[
{ type: "function", name: "lookup", inputSchema: { type: "object" } },
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
],
[
{ type: "provider", id: "other.unsupported", name: "lookup", args: {} },
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
],
[
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
{ type: "provider", id: "openai.web_search_preview", name: "lookup", args: {} },
],
] satisfies Array<NonNullable<LanguageModelV3CallOptions["tools"]>>
test.each(duplicateToolCases.flatMap((tools) => [{ tools }, { tools: tools.toReversed() }]))(
"rejects duplicate forced definitions independent of type and order",
({ tools }) => {
expect(() =>
prepareResponsesTools({
tools,
toolChoice: { type: "tool", toolName: "lookup" },
strictJsonSchema: false,
}),
).toThrow("multiple tool definitions share this name")
},
)
const duplicateHostedToolCases = [
{ id: "openai.web_search", responseType: "web_search", args: {} },
{ id: "openai.web_search_preview", responseType: "web_search", args: {} },
{ id: "openai.file_search", responseType: "file_search", args: { vectorStoreIds: ["store_1"] } },
{ id: "openai.code_interpreter", responseType: "code_interpreter", args: {} },
{ id: "openai.image_generation", responseType: "image_generation", args: {} },
] as const
function duplicateHostedTools(testCase: (typeof duplicateHostedToolCases)[number], sameName = false) {
return [
{ type: "provider" as const, id: testCase.id, name: `${testCase.responseType}_one`, args: testCase.args },
{
type: "provider" as const,
id: testCase.id,
name: sameName ? `${testCase.responseType}_one` : `${testCase.responseType}_two`,
args: testCase.args,
},
]
}
test.each(
duplicateHostedToolCases.flatMap((testCase) =>
[undefined, { type: "auto" as const }, { type: "required" as const }].flatMap((toolChoice) => {
const tools = duplicateHostedTools(testCase)
return [
{ testCase, tools, toolChoice, selection: toolChoice?.type ?? "default" },
{ testCase, tools: tools.toReversed(), toolChoice, selection: toolChoice?.type ?? "default" },
]
}),
),
)(
"rejects differently named duplicate $testCase.id responses for $selection selection",
({ testCase, tools, toolChoice }) => {
expect(() => prepareResponsesTools({ tools, toolChoice, strictJsonSchema: false })).toThrow(
`ambiguous ${testCase.responseType} response for hosted tools`,
)
},
)
test.each(
duplicateHostedToolCases.flatMap((testCase) => {
const tools = duplicateHostedTools(testCase, true)
return [
{ testCase, tools },
{ testCase, tools: tools.toReversed() },
]
}),
)("allows duplicate $testCase.id responses with the same logical name", ({ tools }) => {
expect(
prepareResponsesTools({ tools, toolChoice: { type: "required" }, strictJsonSchema: false }).hostedTools.map(
(tool) => tool.name,
),
).toEqual([tools[0].name, tools[0].name])
})
test.each(
duplicateHostedToolCases.flatMap((testCase) => {
const tools = duplicateHostedTools(testCase)
return [
{ testCase, tools },
{ testCase, tools: tools.toReversed() },
]
}),
)("rejects a forced $testCase.id wire choice with multiple logical identities", ({ testCase, tools }) => {
expect(() =>
prepareResponsesTools({
tools,
toolChoice: { type: "tool", toolName: `${testCase.responseType}_one` },
strictJsonSchema: false,
}),
).toThrow(`ambiguous ${tools[0].id.replace("openai.", "")} tool choice for hosted tools`)
})
test.each(
duplicateHostedToolCases.flatMap((testCase) => {
const tools = duplicateHostedTools(testCase, true)
return [{ tools }, { tools: tools.toReversed() }]
}),
)("rejects a forced logical name shared by duplicate same-wire definitions", ({ tools }) => {
expect(() =>
prepareResponsesTools({
tools,
toolChoice: { type: "tool", toolName: tools[0].name },
strictJsonSchema: false,
}),
).toThrow("multiple tool definitions share this name")
})
test.each([...duplicateHostedToolCases])("skips ambiguous $id responses when tool choice is none", (testCase) => {
expect(
prepareResponsesTools({
tools: duplicateHostedTools(testCase),
toolChoice: { type: "none" },
strictJsonSchema: false,
}).toolChoice,
).toBe("none")
})
test.each([...duplicateHostedToolCases])("skips ambiguous $id responses for a uniquely forced function", (testCase) => {
expect(
prepareResponsesTools({
tools: [...duplicateHostedTools(testCase), { type: "function", name: "local", inputSchema: { type: "object" } }],
toolChoice: { type: "tool", toolName: "local" },
strictJsonSchema: false,
}).toolChoice,
).toEqual({ type: "function", name: "local" })
})
test.each([{ ambiguousWebTools: duplicateHostedTools(duplicateHostedToolCases[0]) }, { ambiguousWebTools: webTools }])(
"validates only the selected wire choice for a forced unrelated hosted tool",
({ ambiguousWebTools }) => {
const selected = duplicateHostedTools(duplicateHostedToolCases[2], true)[0]
const result = prepareResponsesTools({
tools: [...ambiguousWebTools, selected],
toolChoice: { type: "tool", toolName: selected.name },
strictJsonSchema: false,
})
expect(result.toolChoice).toEqual({ type: "file_search" })
expect(result.selectedHostedTool).toMatchObject({ name: selected.name, type: "file_search" })
},
)
+21 -8
View File
@@ -1,11 +1,24 @@
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
import { upsertProject } from "@opencode-ai/core/project/sql"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Effect, Layer } from "effect"
export const globalProjectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
}),
)
export const globalProjectNode = makeGlobalNode({
service: Project.Service,
layer: Layer.effect(
Project.Service,
Effect.gen(function* () {
const database = yield* Database.Service
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => {
const project = { id: Project.ID.global, directory, canonical: directory }
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
},
})
}),
),
deps: [Database.node],
})
+62
View File
@@ -1207,6 +1207,68 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
testEffect(Layer.empty).live(
"merges MCP defaults into the winning configured server without changing runtime overrides",
() =>
Effect.gen(function* () {
const entries = [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { startup: 10, catalog: 20, execution: 30 },
servers: {
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
},
}),
}),
}),
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
timeout: { catalog: 40 },
servers: {
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
},
}),
}),
}),
]
const original = JSON.stringify(entries)
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
const check = yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual({
type: "local",
command: ["later"],
disabled: true,
timeout: { startup: 50, catalog: 40, execution: 30 },
})
})
yield* check.dispose
const runtime = {
type: "local",
command: ["runtime"],
disabled: true,
timeout: { catalog: 60 },
} satisfies ConfigMCP.Local
yield* service.add("resources", runtime)
yield* service.reload()
yield* service.transform((draft) => {
expect(draft.get("resources")).toEqual(runtime)
})
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed(entries),
}),
),
)
expect(JSON.stringify(entries)).toBe(original)
}),
)
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
+12 -2
View File
@@ -297,7 +297,10 @@ describe("ModelsDev Service", () => {
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
@@ -387,7 +390,10 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -440,6 +446,10 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
}),
)
+1
View File
@@ -129,6 +129,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
base: () => Effect.die("unused vcs.base"),
get: () => Effect.die("unused vcs.get"),
branches: () => Effect.die("unused vcs.branches"),
status: () => Effect.die("unused vcs.status"),
+8 -2
View File
@@ -567,7 +567,7 @@ describe("fromPromise", () => {
}),
)
it.effect("registers a Promise VCS provider and forwards client reads", () =>
it.effect("registers a Promise VCS provider and preserves its receiver when forwarding client reads", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const plugin = yield* Plugin.Service
@@ -584,6 +584,11 @@ describe("fromPromise", () => {
signals.push(request.signal)
return { branch: { current: "feature", default: "main" } }
},
async base(_input, request) {
expect(this.id).toBe("custom")
signals.push(request.signal)
return { name: "main", ref: "refs/heads/main", source: "default" }
},
branches: async (input, request) => {
signals.push(request.signal)
expect(input.search).toBe("feat")
@@ -604,6 +609,7 @@ describe("fromPromise", () => {
})
expect((await ctx.vcs.get()).data.branch.current).toBe("feature")
expect((await ctx.vcs.base()).data).toEqual({ name: "main", ref: "refs/heads/main", source: "default" })
expect((await ctx.vcs.branches({ search: "feat" })).data).toEqual(["feature"])
expect((await ctx.vcs.status()).data).toHaveLength(1)
expect((await ctx.vcs.diff({ mode: "working", context: 2 })).data[0].patch).toBe("+hello")
@@ -612,7 +618,7 @@ describe("fromPromise", () => {
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect((yield* vcs.info()).branch.current).toBe("feature")
expect(signals).toHaveLength(4)
expect(signals).toHaveLength(5)
expect(signals.every((signal) => signal instanceof AbortSignal)).toBeTrue()
}),
)
+2 -2
View File
@@ -21,7 +21,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Effect, Layer, LayerMap, Stream } from "effect"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
import { globalProjectNode } from "./lib/project"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = LanguageModel.make({
@@ -66,7 +66,7 @@ const it = testEffect(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[LocationServiceMap.node, locations],
[Project.node, globalProjectLayer],
[Project.node, globalProjectNode],
[SessionExecution.node, SessionExecution.noopLayer],
],
),

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