Compare commits

..
19 Commits
Author SHA1 Message Date
Brendan Allan ccb3258c8b chore(app): merge v2 into server onboarding 2026-09-08 00:53:32 +08:00
Brendan Allan b6c474d155 fix(app): mount camera preview before starting scanner 2026-09-08 00:50:35 +08:00
Brendan Allan 73e5009a25 feat(app): add first-server connection and QR pairing 2026-09-08 00:46:18 +08:00
opencode-agent[bot]andrekram1-node 582a2108ce fix(tui): finish reasoning rows on end event (#47813)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-07 11:12:33 -05:00
OpeOginniandAiden Cline 9c65a69937 fix(core): support granular webfetch permissions (#46611)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-07 11:03:21 -05:00
Aiden Cline 1d391908f4 feat(core): identify to MCP authorization servers with a client metadata document (#47743) 2026-09-07 10:54:29 -05:00
Filip 596dca4dee feat(cli): add session list and delete commands (#47812) 2026-09-07 17:43:56 +02:00
Shoubhit Dash 1827832775 fix(core): retry transient provider compaction failures (#47806) 2026-09-07 21:03:43 +05:30
Brendan Allan 04d5cf406a fix(app): allow home to start without a server 2026-09-07 22:04:43 +08:00
Brendan Allan f5efd33f4e feat(app): configure initial servers by build target 2026-09-07 21:59:29 +08:00
Shoubhit Dash 898692af26 feat(core): schedule provider compaction automatically (#47324) 2026-09-07 19:27:11 +05:30
Kit Langton 5c3f2ddf8c refactor(core): unify filesystem access policy (#47630) 2026-09-07 09:26:55 -04:00
Shoubhit Dash 1382cebe10 feat(core): support explicit provider compaction (#47323) 2026-09-07 18:20:35 +05:30
Shoubhit Dash 0732cdd8e1 feat(core): persist provider compaction context (#47322) 2026-09-07 18:16:16 +05:30
opencode-agent[bot]andnexxeln 2bf6a9813a fix(tui): honor falsy dialog current values (#47782)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-07 17:21:06 +05:30
opencode-agent[bot]andnexxeln f094a10645 fix(plugin): expose dialog option footer (#47780)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-07 17:20:51 +05:30
Luke Parker 89f1943d2d fix(app): restore the review panel landmark label (#47781) 2026-09-07 21:27:13 +10:00
opencode-agent[bot] fc4f319676 chore: update nix node_modules hashes 2026-09-07 11:16:33 +00:00
Shoubhit Dash b51fdb331c fix: delete sessions with missing directories and report api errors (#47774) 2026-09-07 16:34:17 +05:30
109 changed files with 3767 additions and 723 deletions
+6
View File
@@ -86,6 +86,7 @@
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
@@ -103,6 +104,7 @@
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"uqr": "0.1.3",
"vite": "8.2.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14",
@@ -3206,6 +3208,8 @@
"@types/npmlog": ["@types/npmlog@7.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/pacote": ["@types/pacote@11.1.8", "", { "dependencies": { "@types/node": "*", "@types/npm-registry-fetch": "*", "@types/npmlog": "*", "@types/ssri": "*" } }, "sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
@@ -5138,6 +5142,8 @@
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
"qr-scanner": ["qr-scanner@1.4.2", "", { "dependencies": { "@types/offscreencanvas": "^2019.6.4" } }, "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
+1
View File
@@ -6,6 +6,7 @@ export function createWebApp(domain: string) {
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_OPENCODE_SERVER_MODE: "none",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
+22 -2
View File
@@ -83,8 +83,28 @@ Changes merged into `v2` reach the beta site when they are promoted to `beta`. T
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.
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
| Mode | Initial server |
| ------------------ | ------------------------------------------------------------------------------------ |
| `none` | No initial server. The beta deployment uses this mode. |
| `origin` (default) | The current page's origin. CLI builds explicitly use this mode for `opencode serve`. |
In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPENCODE_SERVER_PORT`
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
Scanning fills the form; **Connect** checks the credentials before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
Run `bun run test:server-connect` to build in `none` mode and test onboarding, password authentication,
persistence, and QR scanning through Chromium's virtual camera. To use an existing `none`-mode app build:
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4446 bun run test:server-connect
```
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
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test"
test.use({ launchOptions: { args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] } })
test("stops the camera on cancel", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Scan QR code" }).click()
const video = page.getByLabel("Pairing camera")
await expect(video).toHaveJSProperty("readyState", 4)
await expect(video).toBeVisible()
await expect(video).toHaveCSS("opacity", "1")
const stream = await video.evaluateHandle((element: HTMLVideoElement) => element.srcObject as MediaStream)
await page.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(page.getByLabel("Password", { exact: true })).toBeVisible()
await expect
.poll(() => stream.evaluate((value) => value.getTracks().every((track) => track.readyState === "ended")))
.toBe(true)
})
@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const password = "pairing-test-password"
test("checks the password before saving and restores the connection after reload", async ({ page }) => {
await mockOpenCodeServer(page, {
provider: { all: [], default: {}, connected: [] },
directory: "/fixture",
project: { id: "fixture", worktree: "/fixture", time: { created: 1 } },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route(`${server}/api/health`, (route) =>
route.fulfill({
status:
route.request().headers().authorization === `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
? 200
: 401,
json: { healthy: true, version: "2.0.0" },
}),
)
await page.goto("/")
await expect(page.getByRole("main", { name: "Connect to a server" })).toBeVisible()
await page.getByLabel("Server address").fill(server)
await page.getByLabel("Password", { exact: true }).fill("wrong-password")
await page.getByRole("button", { name: "Connect", exact: true }).click()
await expect(page.getByRole("alert")).toHaveText(
"Could not connect. Check the server address and password, then try again.",
)
expect(await page.evaluate(() => localStorage.getItem("opencode.global.dat:server"))).toBeNull()
await page.getByLabel("Password", { exact: true }).fill(password)
await page.getByRole("button", { name: "Connect", exact: true }).click()
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
await expect.poll(() => page.evaluate(() => localStorage.getItem("opencode.global.dat:server"))).toContain(password)
await page.reload()
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
})
test("a saved offline server does not trigger first-server onboarding", async ({ page }) => {
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: server } }] }),
)
}, server)
await page.route(`${server}/**`, (route) => route.abort())
await page.goto("/")
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible()
await expect(page.getByRole("main", { name: "Connect to a server" })).toHaveCount(0)
})
test("camera failure allows returning to the manual form", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto("/")
await page.getByLabel("Server address").fill(server)
await page.getByRole("button", { name: "Scan QR code" }).click()
await expect(page.getByRole("alert")).toHaveText(
"Could not open the camera. Allow camera access or enter your connection details manually.",
)
await page.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(page.getByLabel("Server address")).toHaveValue(server)
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled()
})
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_PORT ?? 4456)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/server-connect",
timeout: 60_000,
expect: { timeout: 15_000 },
workers: 1,
use: {
baseURL,
browserName: "chromium",
serviceWorkers: "block",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command: `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`,
cwd: "../..",
url: baseURL,
timeout: 120_000,
env: { VITE_OPENCODE_SERVER_MODE: "none" },
},
})
@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test"
import { writeFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { encode } from "uqr"
const video = path.join(tmpdir(), `opencode-pairing-${process.pid}.y4m`)
const pairing = {
urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"],
username: "opencode",
password: "qr-test-password",
}
test.use({
viewport: { width: 390, height: 844 },
launchOptions: {
args: [
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream",
`--use-file-for-fake-video-capture=${video}`,
],
},
})
test.beforeAll(async () => {
// Feed the real decoder an inverted terminal QR through Chromium's virtual camera.
const qr = encode(JSON.stringify(pairing), { border: 4, invert: true })
const size = 640
const scale = Math.floor(400 / qr.size)
const offset = Math.floor((size - qr.size * scale) / 2)
const luma = Buffer.from(
Array.from({ length: size * size }, (_, index) => {
const row = Math.floor((Math.floor(index / size) - offset) / scale)
const column = Math.floor(((index % size) - offset) / scale)
return qr.data[row]?.[column] === false ? 235 : 16
}),
)
await writeFile(
video,
Buffer.concat([
Buffer.from(`YUV4MPEG2 W${size} H${size} F10:1 Ip A1:1 C420jpeg\nFRAME\n`),
luma,
Buffer.alloc((size * size) / 2, 128),
]),
)
})
test.afterAll(() => rm(video, { force: true }))
test("scans opencode pair into the address and password fields", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Scan QR code" }).click()
await expect(page.getByLabel("Server address")).toHaveValue(pairing.urls[0])
await expect(page.getByLabel("Password", { exact: true })).toHaveValue(pairing.password)
await expect(page.getByLabel("Password", { exact: true })).toHaveAttribute("type", "password")
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled()
await expect(page.getByLabel("Pairing camera")).toHaveCount(0)
})
+3
View File
@@ -32,6 +32,7 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:server-connect": "playwright test --config e2e/server-connect/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",
@@ -51,6 +52,7 @@
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"uqr": "0.1.3",
"vite": "8.2.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
@@ -87,6 +89,7 @@
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
testDir: "./e2e",
testIgnore: [
"service-worker/**",
"server-connect/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
outputDir: "./e2e/test-results",
+1 -1
View File
@@ -95,7 +95,7 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
+13 -11
View File
@@ -76,22 +76,24 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
servers={server ? [server] : []}
>
<KeyboardInsets />
{standalone && <PwaRoutePersistence />}
+1
View File
@@ -1,4 +1,5 @@
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_MODE?: "none" | "origin"
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
+23 -19
View File
@@ -198,26 +198,30 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
<Show
when={props.servers.length > 1}
fallback={
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(props.servers[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers[0])}
<Show when={props.servers[0]}>
{(server) => (
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={<HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
<HomeProjectList {...props} {...contextMenuProps} server={server()} items={props.projects} />
<Show when={props.dropdown}>
<HomeProjectNavButton
type="button"
data-action="home-add-project-row"
class="mt-1 disabled:opacity-60"
disabled={props.serverHealth(server())?.healthy === false}
onClick={() => props.onChooseProject(server())}
>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
)}
</Show>
}
>
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
@@ -311,8 +311,10 @@ export function createHomeSessionsController(home: HomeController) {
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
isOpen: (record: HomeSessionRecord) => {
const server = home.selection.value().server
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
},
}
}
+43 -38
View File
@@ -2,6 +2,7 @@ import type { HomeScrollController } from "../scroll"
import type { HomeSessionSearchController } from "./search"
import type { HomeSessionsController } from "./controller"
import { HomeSessionsView } from "./view"
import { Show } from "solid-js"
export function HomeSessions(props: {
sessions: HomeSessionsController
@@ -9,43 +10,47 @@ export function HomeSessions(props: {
scroll: HomeScrollController
}) {
return (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
<Show when={props.sessions.session.server()}>
{(server) => (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)}
</Show>
)
}
+15 -1
View File
@@ -366,6 +366,20 @@ export const dict = {
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.password": "Password",
"dialog.server.add.passwordPlaceholder": "password",
"server.connect.title": "Connect to a server",
"server.connect.description": "Enter your server address and password to get started.",
"server.connect.button": "Connect",
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -671,7 +685,7 @@ export const dict = {
"session.tab.add": "Add tab",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review, files, and browser",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+1 -1
View File
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
name: "Server",
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, serverAddress } from "./pairing"
describe("pairing code", () => {
test("reads the JSON payload emitted by opencode pair", () => {
expect(
decodePairingCode(
JSON.stringify({
urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"],
username: "opencode",
password: "test:password",
}),
),
).toEqual({ urls: ["http://192.168.1.20:4096", "http://[fd00::1]:4096"], password: "test:password" })
})
test("normalizes and deduplicates addresses while dropping invalid addresses", () => {
expect(
decodePairingCode(
JSON.stringify({
urls: ["https://server.example/", "https://server.example", "file:///etc/passwd"],
username: "opencode",
password: "test-password",
}),
),
).toEqual({ urls: ["https://server.example"], password: "test-password" })
})
test.each([
"not json",
"null",
"[]",
JSON.stringify({ urls: [], username: "opencode", password: "test" }),
JSON.stringify({ urls: ["file:///etc/passwd"], username: "opencode", password: "test" }),
JSON.stringify({ urls: [42], username: "opencode", password: "test" }),
JSON.stringify({ urls: ["https://server.example"], username: "someone", password: "test" }),
JSON.stringify({ urls: ["https://server.example"], username: "opencode", password: 42 }),
JSON.stringify({ urls: ["https://server.example"], username: "opencode" }),
])("rejects an unrelated or malformed QR payload: %s", (value) => {
expect(decodePairingCode(value)).toBeUndefined()
})
})
describe("server address", () => {
test("accepts a bare address and preserves an explicit server path", () => {
expect(serverAddress(" 192.168.1.20:4096/ ")).toBe("http://192.168.1.20:4096")
expect(serverAddress("https://server.example/opencode/")).toBe("https://server.example/opencode")
})
test.each([
"",
"http://",
"file:///tmp/server",
"https://user:password@server.example",
"https://server.example?token=secret",
"https://server.example#fragment",
])("rejects invalid or embedded connection data: %s", (value) => {
expect(serverAddress(value)).toBeUndefined()
})
})
@@ -0,0 +1,28 @@
import { Option, Schema } from "effect"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
const normalized = normalizeServerUrl(value)
if (!normalized || !URL.canParse(normalized)) return
const url = new URL(normalized)
if (url.protocol !== "http:" && url.protocol !== "https:") return
if (url.username || url.password || url.search || url.hash) return
return normalized
}
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
}
@@ -0,0 +1,62 @@
import QrScanner from "qr-scanner"
import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingCode } from "./pairing"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onCancel: () => void
}) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
video.setAttribute("aria-label", language.t("server.connect.camera"))
video.setAttribute("playsinline", "")
video.muted = true
onMount(() => {
// QrScanner hides detached videos, so initialize only after this preview is mounted.
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingCode(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
props.onScan(pairing)
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
// Terminal QR codes can be light-on-dark depending on the terminal theme.
scanner.setInversionMode("both")
onCleanup(() => scanner.destroy())
void scanner.start().then(
() => setState("ready", true),
() => setState("error", language.t("server.connect.camera.error")),
)
})
return (
<section class="server-connect-scanner" aria-label={language.t("server.connect.scan")}>
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
</Show>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button variant="neutral" size="large" onClick={props.onCancel}>
{language.t("common.cancel")}
</Button>
</section>
)
}
+119
View File
@@ -0,0 +1,119 @@
[data-component="connect-server"] {
display: flex;
flex: 1;
min-height: 0;
width: 100%;
overflow: auto;
padding: max(48px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
max(32px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
background: var(--v2-background-bg-base);
color: var(--v2-text-text-base);
.server-connect-content {
display: flex;
flex-direction: column;
width: min(100%, 360px);
margin: auto;
gap: 16px;
}
.server-connect-brand {
width: 160px;
margin-inline: auto;
margin-block-end: 16px;
}
header {
text-align: center;
margin-block-end: 16px;
}
h1 {
font-size: 20px;
line-height: 28px;
font-weight: 530;
margin-block-end: 8px;
}
p {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
form,
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
}
.server-connect-field {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 13px;
line-height: var(--line-height-compact);
font-weight: 530;
}
[data-component="text-input-v2"] {
width: 100%;
height: 44px;
}
[data-slot="text-input-v2-input"] {
font-size: 16px;
line-height: 24px;
}
button {
min-height: 44px;
width: 100%;
}
.server-connect-error {
color: var(--v2-state-fg-danger);
}
footer {
text-align: center;
margin-block-start: 16px;
}
code {
display: inline-block;
margin-block-start: 12px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
line-height: var(--line-height-base);
background: var(--v2-background-bg-layer-01);
user-select: all;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
+142
View File
@@ -0,0 +1,142 @@
import { lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const request = useMutation(() => ({
mutationFn: async () => {
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
return
}
const http = { url, password: state.password || undefined }
const result = await check(http)
if (!result.healthy) {
setState("error", language.t("server.connect.failed"))
return
}
servers.add({ type: "http", http })
},
onError: () => setState("error", language.t("server.connect.failed")),
}))
return (
<main data-component="connect-server" aria-labelledby="server-connect-title">
<div class="server-connect-content">
<div class="server-connect-brand" role="img" aria-label="OpenCode">
<Wordmark />
</div>
<header>
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
<p>{language.t("server.connect.description")}</p>
</header>
<Show
when={!state.scanning}
fallback={
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
<PairingScanner
onCancel={() => setState("scanning", false)}
onScan={(pairing) =>
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
}
/>
</Suspense>
}
>
<form
onSubmit={(event) => {
event.preventDefault()
if (request.isPending) return
setState("error", "")
request.mutate()
}}
>
<div class="server-connect-field">
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
<TextInput
id="server-connect-url"
name="server"
dir="ltr"
type="text"
inputMode="url"
autocomplete="url"
autocapitalize="off"
spellcheck={false}
required
appearance="large"
list="server-connect-addresses"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
<datalist id="server-connect-addresses">
{state.urls.map((url) => (
<option value={url} />
))}
</datalist>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
<TextInput
id="server-connect-password"
name="password"
type="password"
autocomplete="current-password"
appearance="large"
value={state.password}
disabled={request.isPending}
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
/>
</div>
<Show when={state.error}>
<p class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button type="submit" variant="contrast" size="large" disabled={request.isPending || !state.url.trim()}>
{language.t(request.isPending ? "dialog.server.add.checking" : "server.connect.button")}
</Button>
</form>
<Show when={platform.platform === "web"}>
<Button
variant="neutral"
size="large"
disabled={request.isPending}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
</Show>
<footer>
<p>{language.t("server.connect.pair.description")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
</div>
</main>
)
}
+14 -8
View File
@@ -3,7 +3,7 @@ import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
@@ -15,6 +15,9 @@ export const File = lazy(() => import("@opencode-ai/session-ui/file").then((modu
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
const DraftRoute = lazy(() => import("@/new-session/route").then((module) => ({ default: module.DraftRoute })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
const ConnectServerScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
)
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -71,13 +74,16 @@ function TargetServerRoute(props: ParentProps) {
}
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
)
}
@@ -14,6 +14,19 @@ describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("restores layout without an initial server and retains a saved selection", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout())
const decode = Schema.decodeUnknownSync(schema)
const empty = decode({})
expect(empty.home.selection).toEqual({})
expect(decode(Schema.encodeSync(schema)(empty)).home.selection).toEqual({})
const selection = { server: ServerConnection.Key.make("https://server.example.test"), directory: "/project" }
const saved = decode({ home: { selection } })
expect(saved.home.selection).toEqual(selection)
expect(decode(Schema.encodeSync(schema)(saved)).home.selection).toEqual(selection)
})
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
+4 -4
View File
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: TabStorage.ServerKey,
server: Schema.optional(TabStorage.ServerKey),
directory: Schema.optional(Schema.String),
}),
}),
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
),
)
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
@@ -233,7 +233,7 @@ export function initialLayout(server: ServerConnection.Key): typeof layoutSchema
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: { server } },
home: { selection: server ? { server } : {} },
}
}
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(ServerConnection.key(servers.list[0])),
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
+3 -1
View File
@@ -6,7 +6,9 @@ import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
+1 -1
View File
@@ -111,7 +111,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
setup(build) {
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
build.onLoad({ filter: /filesystem[/\\]watcher-binding\.ts$/ }, () => ({
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
loader: "js",
}))
+28
View File
@@ -361,6 +361,34 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
...PermissionParams,
},
}),
Spec.make("session", {
description: "Manage sessions",
commands: [
Spec.make("list", {
description: "List top-level sessions in the current project, newest first",
params: {
...ServerParams,
maxCount: Flag.integer("max-count").pipe(
Flag.withAlias("n"),
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
Flag.withDescription("Limit to N most recent sessions (default: 100)"),
Flag.optional,
),
format: Flag.choice("format", ["table", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("table"),
),
},
}),
Spec.make("delete", {
description: "Delete a session and its child sessions",
params: {
...ServerParams,
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
},
}),
],
}),
Spec.make("service", {
description: "Manage the background server",
commands: [
@@ -44,6 +44,10 @@ export default Runtime.handler(
)
const output = yield* Effect.promise(() => response.text())
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
if (!response.ok) {
process.stderr.write(`HTTP ${response.status} ${response.statusText}${EOL}`)
process.exitCode = 1
}
}),
)
@@ -0,0 +1,34 @@
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
const handler = Effect.fn("cli.session.delete")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.delete>,
) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
yield* Effect.tryPromise({
try: (signal) => client.session.remove({ sessionID: input.sessionID }, { signal }),
catch: (cause) => cause,
})
process.stdout.write(`Session ${input.sessionID} deleted${EOL}`)
})
export default Runtime.handler(Commands.commands.session.commands.delete, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -0,0 +1,113 @@
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect, Option, Stream } from "effect"
import { EOL } from "node:os"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"
const handler = Effect.fn("cli.session.list")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.list>,
) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
const location = yield* Effect.tryPromise({
try: (signal) => client.location.get({ location: { directory: process.cwd() } }, { signal }),
catch: (cause) => cause,
})
const page = yield* Effect.tryPromise({
try: (signal) =>
client.session.list(
{
project: location.project.id,
parentID: null,
order: "desc",
limit: Option.getOrElse(input.maxCount, () => 100),
},
{ signal },
),
catch: (cause) => cause,
})
if (input.format === "table" && page.data.length === 0) return
const output =
(input.format === "json"
? JSON.stringify(
page.data.map((session) => ({
id: session.id,
title: session.title,
updated: session.time.updated,
created: session.time.created,
projectId: session.projectID,
directory: session.location.directory,
})),
null,
2,
)
: formatTable(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
process.stdout.write(output, (error) => (error ? reject(error) : resolve()))
}),
)
if (!process.stdout.isTTY || Option.isSome(input.maxCount) || input.format === "json") {
yield* write
return
}
const { AppProcess } = yield* Effect.promise(() => import("@opencode-ai/util/process"))
const { LayerNode } = yield* Effect.promise(() => import("@opencode-ai/util/effect/layer-node"))
const { ChildProcess } = yield* Effect.promise(() => import("effect/unstable/process"))
yield* Effect.gen(function* () {
const processService = yield* AppProcess.Service
const pager = yield* processService
.spawn(
ChildProcess.make(
process.platform === "win32" ? "cmd" : "less",
process.platform === "win32" ? ["/c", "more"] : ["-R", "-S"],
{
stdin: Stream.make(new TextEncoder().encode(output)),
stdout: "inherit",
stderr: "inherit",
},
),
)
.pipe(Effect.option)
if (Option.isNone(pager)) {
yield* write
return
}
yield* pager.value.exitCode
}).pipe(Effect.provide(LayerNode.compile(AppProcess.node)))
})
export default Runtime.handler(Commands.commands.session.commands.list, (input) =>
handler(input).pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
}
+4
View File
@@ -53,6 +53,10 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
session: {
list: () => import("./commands/handlers/session/list"),
delete: () => import("./commands/handlers/session/delete"),
},
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
+14
View File
@@ -970,6 +970,20 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly model?: Model.Ref | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
readonly providerContext?:
| {
readonly version: 1
readonly provenance: {
readonly providerID: Provider.ID
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: Schema.Json
}
| undefined
readonly text: string
readonly recent: string
}
+101 -46
View File
@@ -138,6 +138,15 @@ export type SessionMessageCompactionRunning = {
recent: string
}
export type SessionProviderContextProvenance = {
providerID: string
provider: string
modelID: string
route: string
protocol: string
endpoint: string
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -183,6 +192,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ModelCapabilities = {
tools: boolean
input: Array<string>
@@ -201,18 +212,6 @@ export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type FormWhen = {
key: string
op: "eq" | "neq"
@@ -512,19 +511,6 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
}
export type ToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -539,6 +525,8 @@ export type SessionMessageCompactionFailed = {
error: SessionStructuredError
}
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
export type SessionInboxSynthetic = {
id: string
sessionID: string
@@ -1345,23 +1333,6 @@ export type SessionToolCalled = {
}
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
text: string
recent: string
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
@@ -1381,6 +1352,19 @@ export type ModelCompatibility = {
requireAssistantAfterTool?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1742,10 +1726,37 @@ export type SessionMessageToolStateError = {
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageCompactionCompleted = {
type: "compaction"
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
}
export type SessionCompactionEnded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState1
providerContext?: SessionProviderContext
text: string
recent: string
}
}
export type SessionForked = {
id: string
@@ -1824,6 +1835,7 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
@@ -1999,6 +2011,7 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
canonical?: string
name?: string
env?: Array<string>
@@ -2008,6 +2021,7 @@ export type ConfigEntry =
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
modelID?: string
family?: string
name?: string
@@ -2085,6 +2099,11 @@ export type SessionMessageAssistantTool = {
time: { created: number; ran?: number; completed?: number }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionMessageAssistantTool1 = {
type: "tool"
id: string
@@ -3080,6 +3099,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3359,6 +3390,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
@@ -3638,6 +3681,18 @@ export type SessionImportInput = {
readonly providerState?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
readonly version: 1
readonly provenance: {
readonly providerID: string
readonly provider: string
readonly modelID: string
readonly route: string
readonly protocol: string
readonly endpoint: string
}
readonly messages: JsonValue
}
}
| {
readonly type: "compaction"
+1
View File
@@ -76,6 +76,7 @@ const layer = Layer.effect(
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
compaction: model.compaction ?? provider.compaction,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
+2 -5
View File
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission.js"
import type { LocationMutation } from "../../location-mutation.js"
import type { FileAccess } from "../../file-access.js"
import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js"
import { AbsolutePath } from "../../schema.js"
@@ -27,10 +27,7 @@ const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Info)
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
type PathAction = FileAccess.ExternalDirectoryAuthorization["action"] | typeof ReadTool.name | typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
@@ -57,6 +57,7 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -76,6 +77,7 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+173
View File
@@ -0,0 +1,173 @@
export * as FileAccess from "./file-access.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Array, Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
import type { SessionErrors } from "./session/error.js"
import type { Tool } from "./tool.js"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
readonly directory: AbsolutePath
readonly resource: string
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
readonly absolute: AbsolutePath
/** Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
export type Invocation = Pick<Tool.Context, "sessionID" | "agent" | "messageID" | "id">
export interface ReadOptions {
/** A target already authorized by this invocation, used for filename recovery. */
readonly siblingOf: Target
}
export interface Interface {
/** Resolve a lexical path and its permission resources, without requesting approval. */
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
/** Approve external directories in one batch, preserving first-seen resource order. */
readonly authorizeExternal: (
targets: readonly Target[],
context: Invocation,
metadata?: Permission.AssertInput["metadata"],
) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
/** Resolve a read target and obtain external-directory approval before read approval. */
readonly authorizeRead: (
file: string,
context: Invocation,
options?: ReadOptions,
) => Effect.Effect<Target, FSUtil.Error | Error | SessionErrors.NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileAccess") {}
/** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
normalized === "~"
? home
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
const slash = (value: string) => value.replaceAll("\\", "/")
const invocation = (context: Invocation) => ({
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool" as const, messageID: context.messageID, id: context.id },
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory,
resource: slash(path.join(directory, "*")),
save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")),
},
} satisfies Target
})
const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* (
targets: readonly Target[],
context: Invocation,
metadata?: Permission.AssertInput["metadata"],
) {
const external = Array.dedupeWith(
targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])),
(left, right) => left.resource === right.resource,
)
if (external.length === 0) return
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
...(metadata === undefined ? {} : { metadata }),
...invocation(context),
})
})
const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* (
file: string,
context: Invocation,
options?: ReadOptions,
) {
const target = yield* resolve({ path: file, kind: options ? "file" : undefined })
const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute)
// Filename recovery shares the directory approval, but checks the recovered file's own read rules.
if (!sibling) yield* authorizeExternal([target], context)
yield* permission.assert({
action: "read",
resources: [target.resource],
save: ["*"],
...invocation(context),
})
return target
})
return Service.of({ resolve, authorizeExternal, authorizeRead })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
+2 -4
View File
@@ -7,11 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment/index.js"
import type { Files } from "./environment/index.js"
import type { FileAccess } from "./file-access.js"
export interface Target {
readonly absolute: string
readonly resource: string
}
export type Target = Pick<FileAccess.Target, "absolute" | "resource">
export interface WriteInput {
readonly target: Target
+1
View File
@@ -42,6 +42,7 @@ export const layer = Layer.effect(
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
(error) => {
const mapped: Error = input.model
+2 -2
View File
@@ -17,7 +17,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { FileAccess } from "./file-access.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
import { Permission } from "./permission.js"
@@ -82,7 +82,7 @@ const nodes = [
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
FileAccess.node,
FileMutation.node,
Formatter.node,
Mcp.node,
+3 -130
View File
@@ -1,130 +1,3 @@
export * as LocationMutation from "./location-mutation.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. A leading `~` expands to
* the home directory; other relative paths resolve from the active Location.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
/** Selects the external approval boundary; it does not validate the target type. */
kind: Kind.pipe(Schema.optional),
})
export type ResolveInput = typeof ResolveInput.Type
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
readonly directory: string
/** `external_directory` permission resource. */
readonly resource: string
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
/** Absolute lexical path. */
readonly absolute: string
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
export interface Interface {
/**
* Resolve a path and derive its permission resources. A leading `~` expands
* to the home directory; other relative paths resolve from the Location.
* Paths outside it and its non-root project worktree require separate
* `external_directory` approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
const normalized = FSUtil.windowsPath(input)
return path.resolve(
directory,
normalized === "~"
? home
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
? path.join(home, normalized.slice(2))
: normalized,
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/")
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: input.kind === "file"
? "File"
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
absolute,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node],
})
/** @deprecated Use FileAccess for path resolution and authorization. */
export { FileAccess as LocationMutation } from "./file-access.js"
export * from "./file-access.js"
+39 -1
View File
@@ -1,6 +1,12 @@
export * as McpOAuth from "./oauth.js"
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
auth,
discoverOAuthServerInfo,
parseErrorResponse,
type OAuthClientProvider,
type OAuthServerInfo,
} from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import { Cause, Deferred, Effect } from "effect"
@@ -10,6 +16,12 @@ import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
/**
* opencode's OAuth Client ID Metadata Document. Authorization servers that support CIMD accept this URL as the
* client_id and fetch it to learn our name and redirect URIs, so no per-server dynamic registration is needed.
*/
export const CLIENT_METADATA_URL = "https://opencode.ai/oauth/opencode/client.json"
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
Effect.gen(function* () {
@@ -79,6 +91,10 @@ export interface Options {
readonly state?: string
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
readonly client?: { readonly id: string; readonly secret?: string }
/** Use opencode's Client ID Metadata Document as the client_id instead of registering dynamically. */
readonly clientMetadataUrl?: string
/** Pre-fetched authorization server discovery so the SDK does not repeat it. */
readonly discovery?: OAuthServerInfo
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
@@ -95,6 +111,8 @@ export const provider = (options: Options): OAuthClientProvider => {
const client = options.client
return {
redirectUrl: options.redirectUrl,
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(options.discovery ? { discoveryState: () => options.discovery } : {}),
clientMetadata: {
redirect_uris: [options.redirectUrl],
client_name: "opencode",
@@ -252,12 +270,32 @@ export const authorize = (input: {
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
// Discover the authorization server up front so we can decide how to identify ourselves. CIMD only works
// when the server advertises it, accepts public clients (our document declares no client secret), and the
// redirect is our own loopback URL (a user-configured redirect_uri is not in the published document).
// A configured client_id is pre-registered and always wins.
const discovery = yield* Effect.tryPromise({
try: () => discoverOAuthServerInfo(input.config.url, { fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
!oauth?.client_id &&
!oauth?.redirect_uri &&
discovery.authorizationServerMetadata?.client_id_metadata_document_supported === true &&
(discovery.authorizationServerMetadata.token_endpoint_auth_methods_supported?.includes("none") ?? false)
yield* Effect.logInfo("mcp oauth client registration selected", {
...fields,
registration: oauth?.client_id ? "static" : cimd ? "cimd" : "dcr",
})
let authorizationUrl: URL | undefined
const oauthProvider = provider({
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
scope: oauth?.scope,
state,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
clientMetadataUrl: cimd ? CLIENT_METADATA_URL : undefined,
discovery,
onRedirect: (url) => {
authorizationUrl = url
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
+29 -1
View File
@@ -52,10 +52,24 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError<Unresol
}
}
export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCompactionError>()(
"SessionRunnerModel.UnsupportedCompactionError",
{
providerID: Provider.ID,
modelID: ID,
route: Schema.String,
},
) {
override get message() {
return `Provider compaction is not supported by ${this.providerID}/${this.modelID} (${this.route})`
}
}
export type Error =
| VariantUnavailableError
| UnsupportedPackageError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
| Integration.AuthorizationError
export interface Resolved {
@@ -69,6 +83,8 @@ export interface Resolved {
readonly cost: Info["cost"]
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Info["compaction"]
}
export interface Interface {
@@ -115,9 +131,20 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
): Effect.Effect<
LanguageModel,
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
)
}),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
@@ -296,6 +323,7 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: selected.compaction,
}
})
return Service.of({
+3 -3
View File
@@ -31,6 +31,7 @@ import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileAccess } from "../file-access.js"
import { FileMutation } from "../file-mutation.js"
import { Formatter } from "../formatter.js"
import { Form } from "../form.js"
@@ -44,7 +45,6 @@ import { Integration } from "../integration.js"
import { Job } from "../job.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { Mcp } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
@@ -103,6 +103,7 @@ const services = [
Credential.Service,
Bus.Service,
Environment.Service,
FileAccess.Service,
FileMutation.Service,
Formatter.Service,
LocationWatcherPolicy.Service,
@@ -116,7 +117,6 @@ const services = [
Job.Service,
KV.Service,
Location.Service,
LocationMutation.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
@@ -152,6 +152,7 @@ export const requirements = LayerNode.group([
Credential.node,
Bus.node,
Environment.node,
FileAccess.node,
FileMutation.node,
Formatter.node,
LocationWatcherPolicy.node,
@@ -165,7 +166,6 @@ export const requirements = LayerNode.group([
Job.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Mcp.node,
Npm.node,
@@ -263,6 +263,7 @@ export const OpenAIPlugin = define({
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
originator: "opencode",
"x-codex-beta-features": "remote_compaction_v2",
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
})
for (const model of item.models.values()) {
+231 -83
View File
@@ -15,12 +15,15 @@ import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import { SessionHistory } from "./history.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionProviderContext } from "./provider-context.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionRunnerRetry } from "./runner/retry.js"
import { SessionSchema } from "./schema.js"
@@ -93,6 +96,8 @@ export type Editor = {
export type AutoInput = {
readonly context: SessionContext.Loaded
readonly prepare: SessionModelRequest.Interface["prepare"]
/** Known overflow must recover from durable history, not submit the overflowing native window again. */
readonly overflow?: boolean
}
type RequiredInput = {
@@ -124,7 +129,10 @@ type ExecuteInput = AutoInput & {
}
export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| (Pick<SessionMessage.CompactionCompleted, "status"> & {
/** Consumes the logical step's one overflow rebuild even when the native attempt overflowed first. */
readonly recoveredOverflow?: boolean
})
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface extends State.Transformable<Editor> {
@@ -136,14 +144,14 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
const hasInputUsage = (message: SessionMessage.Info) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0
export const estimateTokens = (input: RequiredInput) => {
const index = input.messages.findLastIndex(
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
)
const index = input.messages.findLastIndex(hasInputUsage)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
@@ -199,6 +207,32 @@ const estimatePart = (part: ContentPart): number => {
)
}
/** Keep whole, real user messages, never synthetic guidance or half an attachment/tool exchange. */
export const retainUsers = (
messages: readonly SessionMessage.Info[],
model: Pick<SessionRunnerModel.Resolved, "ref" | "capabilities">,
keepTokens: number,
) => {
const users = SessionModelRequest.boundImages(
SessionModelRequest.unsupportedParts(
toLLMMessages(
messages.filter((message) => message.type === "user").map((message) => ({ ...message, skills: undefined })),
model.ref,
),
model.capabilities,
),
)
let tokens = 0
let start = users.length
for (let index = users.length - 1; index >= 0; index--) {
const size = users[index].content.reduce((sum, part) => sum + estimatePart(part), 0)
if (tokens + size > keepTokens) break
tokens += size
start = index
}
return users.slice(start)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
@@ -336,6 +370,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const db = (yield* Database.Service).db
const state = State.create<Settings, Editor>({
name: "session-compaction",
@@ -357,6 +392,154 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const started = (input: ExecuteInput, recent: string) =>
input.started
? Effect.void
: bus.publish(SessionEvent.Compaction.Started, {
sessionID: input.context.session.id,
reason: input.reason,
recent,
inputID: input.inputID,
})
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
const interrupted = (input: ExecuteInput) =>
input.reason === "auto"
? failed({
sessionID: input.context.session.id,
reason: input.reason,
inputID: input.inputID,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
}).pipe(Effect.asVoid)
: Effect.void
const compactionRequest = (
input: ExecuteInput,
messages: readonly SessionMessage.Info[],
prompt: Message[],
webSocket?: "session",
) => {
const context = input.context
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages,
})
return input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
...prompt,
],
},
webSocket,
})
}
/** The durable transcript since the last local summary, re-expanding every native window. */
const original = (sessionID: SessionSchema.ID) => SessionHistory.load(db, sessionID, "local").pipe(Effect.orDie)
const recoverLocally = (input: ExecuteInput) =>
original(input.context.session.id).pipe(
Effect.flatMap((messages) => execute({ ...input, context: { ...input.context, messages } })),
)
const executeProvider = Effect.fn("SessionCompaction.executeProvider")(function* (input: ExecuteInput) {
const context = input.context
const reject = (message: string) =>
failed({
sessionID: context.session.id,
reason: input.reason,
inputID: input.inputID,
error: { type: "provider.unsupported-operation", message },
})
const prepared = yield* compactionRequest(input, context.messages, [], "session")
const request = prepared.request
const provenance = SessionProviderContext.provenance(context.model)
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
// History is selected before request hooks. Until that interface can select on the final route,
// require routing in the catalog; never install a checkpoint that the next request would skip.
if (
!SessionProviderContext.compatible(
provenance,
SessionProviderContext.provenance({ model: request.model, ref: context.model.ref }),
)
)
return yield* reject(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
})
yield* started(input, "")
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
// Transient provider failures retry like any other request; only a known automatic overflow permits
// local recovery, and nothing is installed until the provider returns a checkpoint.
const result = yield* restore(
Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
const result = yield* llm
.compact(request, { ...prepared.options, mechanism: "trigger" })
.pipe(transient)
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
}
if (LLMClient.canCompact(request))
return yield* llm
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
.pipe(transient)
// Model resolution admits provider policies only for routes with a compaction operation.
return yield* Effect.die(
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
)
}),
)
if (result.usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
sessionID: context.session.id,
source: "compaction" as const,
...SessionUsage.record(result.usage, context.model.cost),
})
yield* bus.publish(SessionEvent.Compaction.Ended, {
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
text: "",
recent: "",
providerContext: SessionProviderContext.encode(provenance, result.replacement),
})
return { status: "completed" as const }
}),
).pipe(
Effect.onInterrupt(() => interrupted(input)),
Effect.catchTag(
"AI.Error",
(cause): Effect.Effect<Outcome> =>
input.reason === "auto" && isContextOverflowFailure(cause)
? recoverLocally({ ...input, started: true }).pipe(
Effect.map((result) =>
result.status === "completed" ? { ...result, recoveredOverflow: true } : result,
),
)
: failed({
sessionID: context.session.id,
reason: input.reason,
inputID: input.inputID,
error: toSessionError(cause),
}),
),
)
})
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
const context = input.context
const history = splitHistory(context.messages, state.get().tokens)
@@ -367,13 +550,7 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
if (!input.started)
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: context.session.id,
reason: input.reason,
recent: history.recent,
inputID: input.inputID,
})
yield* started(input, history.recent)
const chunks: string[] = []
let failure: SessionError.Error | undefined
@@ -388,37 +565,19 @@ export const layer = Layer.effect(
})
: Effect.void,
)
const transcript = SessionModelRequest.baseTranscript({
agent: context.agent.info,
model: context.model,
tools: context.tools,
initial: context.initial,
messages: history.messages,
})
const prepared = yield* input.prepare({
kind: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
contextAgentID: context.agent.id,
model: context.model,
tools: context.tools,
},
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
],
},
})
const retry = yield* SessionRunnerRetry.policy(context.session.id)
const prepared = yield* compactionRequest(input, history.messages, [
Message.user(
buildPrompt(
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
),
),
])
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
})
for (const request of [
prepared.request,
LLMRequest.update(prepared.request, {
@@ -479,42 +638,13 @@ export const layer = Layer.effect(
}
return Effect.void
}),
Effect.retry({
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* retry({
cause,
error: toSessionError(cause),
agent: Agent.ID.make("compaction"),
model: context.model.ref,
hook: prepared.retry,
retry: SessionRunnerRetry.isRetryable(cause),
})
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
}),
transient,
Effect.catchTag("AI.Error", (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
),
Effect.onInterrupt(() =>
recordUsage.pipe(
Effect.andThen(
input.reason === "auto"
? failed({
sessionID: context.session.id,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
),
Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))),
)
if (failure || hasSummarySection(chunks.join(""))) break
}
@@ -544,13 +674,24 @@ export const layer = Layer.effect(
})
return { status: "completed" as const }
})
const compact = (input: AutoInput) => execute({ ...input, reason: "auto" })
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
// Native usage describes the compaction operation, not the replacement's size. Wait for
// a primary response to anchor the new window, including after restart or new admission.
if (
input.messages.findLastIndex(hasInputUsage) < input.messages.findLastIndex(SessionProviderContext.isCheckpoint)
)
return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
@@ -559,7 +700,12 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
return estimateTokens(input) >= promptCeiling
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -578,15 +724,17 @@ export const layer = Layer.effect(
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (context) =>
execute({
onSuccess: (context) => {
const request = {
context,
instructionUpdate: context.instructionUpdate,
prepare: input.prepare,
reason: "manual",
reason: "manual" as const,
inputID: input.inputID,
started: input.started,
}),
}
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
},
}),
)
})
@@ -604,5 +752,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient],
deps: [Bus.node, Database.node, llmClient],
})
+7 -1
View File
@@ -18,6 +18,7 @@ import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
import { InstructionEntry } from "./instruction-entry.js"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
@@ -156,7 +157,12 @@ const layer = Layer.effect(
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
const history = yield* SessionHistory.entriesForRunner(
db,
selection.session.id,
selection.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session: selection.session,
agent: selection.agent,
+7 -1
View File
@@ -9,6 +9,7 @@ import type { Instructions } from "../instructions/index.js"
import { SessionContext } from "./context.js"
import type { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import type { SessionSchema } from "./schema.js"
@@ -29,7 +30,12 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
const context = yield* SessionContext.Service
const selection = yield* context.select(input.session.id)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const history = yield* SessionHistory.preview(
database.db,
selection.session.id,
selection.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
model,
+63 -9
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -6,13 +6,30 @@ import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { Instructions } from "../instructions/index.js"
import { InstructionState } from "./instruction-state.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionMessageTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
/**
* Which completed compactions bound a history read. Local summaries always do. Native
* windows do for model-neutral readers (`latest`), never for the original transcript
* (`local`), and only when the target model can replay them (a provenance).
*/
export type Boundary = "latest" | "local" | SessionProviderContext.Provenance
const replayable = (message: SessionMessage.Info, boundary: Boundary) =>
!SessionProviderContext.isCheckpoint(message) ||
boundary === "latest" ||
(boundary !== "local" && SessionProviderContext.compatible(message.providerContext.provenance, boundary))
export const latestCompaction = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@@ -21,6 +38,19 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
boundary === "latest"
? undefined
: or(
sql`json_extract(${SessionMessageTable.data}, '$.providerContext') is null`,
boundary === "local"
? undefined
: and(
...Object.entries(boundary).map(
([key, value]) =>
sql`json_extract(${SessionMessageTable.data}, ${`$.providerContext.provenance.${key}`}) = ${value}`,
),
),
),
),
)
.orderBy(desc(SessionMessageTable.seq))
@@ -31,6 +61,11 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
: Effect.void,
),
Effect.mapError(
() =>
new MessageDecodeError({
@@ -40,8 +75,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
),
)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const messageEntries = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
const compaction = yield* latestCompaction(db, sessionID, boundary)
const rows = yield* db
.select()
.from(SessionMessageTable)
@@ -54,24 +93,38 @@ const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, session
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, (row) =>
const entries = yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
// Re-expansion may cross a native checkpoint whose completion already advanced the instruction
// epoch: the baseline supersedes the chronological updates before it. Forks seed their baseline
// at sequence 0 but retain parent sequences, so the copied checkpoint still retires them.
const native = entries.findLast((entry) => SessionProviderContext.isCheckpoint(entry.message))
// Skipped native checkpoints are not textual summaries. Their original transcript remains available.
return entries.filter(
(entry) =>
!(entry.message.type === "system" && native && entry.seq < native.seq) && replayable(entry.message, boundary),
)
})
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
export const load = Effect.fn("SessionHistory.load")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: Boundary,
) {
return (yield* messageEntries(db, sessionID, boundary)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.List,
boundary: Boundary,
) {
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
const messages = yield* messageEntries(db, sessionID, boundary)
return {
initial: yield* InstructionState.initial(db, sessionID, instructions),
entries: messages,
@@ -85,12 +138,13 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.List,
boundary: Boundary,
) {
const observed = yield* Instructions.read(instructions)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
const messages = yield* messageEntries(db, sessionID, boundary)
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
const unsettled = messages.findIndex(
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
@@ -413,6 +413,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
})
return
@@ -427,6 +428,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
model: event.data.model,
providerState: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
time: { created },
}),
@@ -15,6 +15,7 @@ import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionSystemPrompt } from "./system-prompt.js"
@@ -345,6 +346,21 @@ export const layer = Layer.effect(
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
}),
)
// History selects native windows against the catalog route before hooks run. A newly installed
// routing hook must not send an existing opaque window to another deployment; `prepare` has no
// error channel, so like hook failures this surfaces as a defect.
const selected = SessionProviderContext.provenance(resolved)
if (
selected &&
!SessionProviderContext.compatible(
selected,
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
) &&
request.messages.some((message) => message.content.some((part) => part.type === "compaction"))
)
return yield* Effect.die(
new Error("Provider context is incompatible with the route selected by model request hooks"),
)
const hasHttpHooks =
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
@@ -0,0 +1,69 @@
export * as SessionProviderContext from "./provider-context.js"
import { Message } from "@opencode-ai/ai"
import { SessionProviderContext } from "@opencode-ai/schema/session-provider-context"
import { Schema } from "effect"
import { isDeepStrictEqual } from "node:util"
import { Hash } from "@opencode-ai/util/hash"
import type { SessionMessage } from "./message.js"
import type { SessionRunnerModel } from "./runner/model.js"
export type Provenance = SessionProviderContext.Provenance
export const Info = SessionProviderContext.Info
export type Info = SessionProviderContext.Info
const messages = Schema.toCodecJson(Schema.Array(Message))
/** No guessed endpoints. Dynamic URL builders cannot establish a durable deployment identity here. */
export function provenance(resolved: Pick<SessionRunnerModel.Resolved, "model" | "ref">): Provenance | undefined {
const model = resolved.model
const endpoint = model.route.endpoint
if (!endpoint.baseURL || typeof endpoint.path !== "string") return undefined
return {
providerID: resolved.ref.providerID,
provider: model.provider,
modelID: model.id,
route: model.route.id,
protocol: model.route.protocol,
endpoint: Hash.sha256(
JSON.stringify([
endpoint.baseURL,
endpoint.path,
Object.entries(endpoint.query ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
]),
),
}
}
export const compatible = (source: Provenance, target: Provenance | undefined) =>
target !== undefined && isDeepStrictEqual(source, target)
/** A completed compaction that installed a native replacement window instead of a local summary. */
export const isCheckpoint = (
message: SessionMessage.Info,
): message is SessionMessage.CompactionCompleted & { readonly providerContext: Info } =>
message.type === "compaction" && message.status === "completed" && message.providerContext !== undefined
/** Stores the canonical replacement, not a local summary or transport continuation.
* Provider and attachment metadata can contain optional undefined entries. Use JSON's
* omission semantics, while preserving canonical binary media as equivalent base64.
*/
export const encode = (provenance: Provenance, replacement: ReadonlyArray<Message>): Info => ({
version: 1,
provenance,
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(
JSON.stringify(
replacement.map((message) => ({
...message,
content: message.content.map((part) =>
part.type === "media" && part.data instanceof Uint8Array
? { ...part, data: Buffer.from(part.data).toString("base64") }
: part,
),
})),
),
),
})
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
+13 -4
View File
@@ -11,6 +11,7 @@ import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionHistory } from "../history.js"
import { SessionProviderContext } from "../provider-context.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
@@ -113,7 +114,12 @@ const layer = Layer.effect(
const selected = yield* context.select(session.id)
const model = yield* context.resolveModel(selected.session)
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
const history = yield* SessionHistory.preview(
db,
session.id,
selected.instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session: selected.session,
agent: selected.agent,
@@ -206,8 +212,9 @@ const layer = Layer.effect(
prepare: context.prepare,
}
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
const result = yield* compaction.compact(compactionInput)
if (result.status !== "completed") return yield* new StepFailedError({ error: result.error })
if (result.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
continue
}
@@ -250,7 +257,9 @@ const layer = Layer.effect(
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
? compaction
.compact({ ...compactionInput, overflow: true })
.pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
@@ -35,6 +35,8 @@ export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
@@ -57,6 +59,7 @@ export const resolved = (
readonly variant?: Model.VariantID
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
readonly compaction?: Provider.Compaction
},
): Resolved => ({
model,
@@ -68,6 +71,7 @@ export const resolved = (
capabilities: options.capabilities,
cost: options.cost,
limit: options.limit,
compaction: options.compaction,
})
const layer = Layer.effect(
+20 -1
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { AIError, isContextOverflowFailure } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -10,6 +10,7 @@ import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { toSessionError } from "../to-session-error.js"
interface Input {
readonly cause: AIError
@@ -106,6 +107,24 @@ export const policy = (sessionID: SessionSchema.ID) =>
})
})
/**
* Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the
* session retry hook adjust each decision. Context overflow is never transient: callers recover it.
*/
export const transient =
(decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) =>
<A, R>(effect: Effect.Effect<A, AIError, R>) =>
Effect.retry(effect, {
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) })
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
})
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const decide = yield* policy(sessionID)
@@ -3,6 +3,7 @@ import type { Model } from "@opencode-ai/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import { SessionMessage } from "../message.js"
import { SessionProviderContext } from "../provider-context.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
@@ -274,6 +275,9 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
return assistant(message, model, providerMetadataKey)
case "compaction":
if (message.status !== "completed") return []
// History selection only keeps native windows the target model can replay.
if (SessionProviderContext.isCheckpoint(message))
return [...SessionProviderContext.decode(message.providerContext)]
return [
Message.make({
id: message.id,
+1 -1
View File
@@ -172,7 +172,7 @@ const layer = Layer.effect(
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID, "latest")),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()
@@ -46,6 +46,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
return { type: "provider.unsupported-operation", message: cause.message }
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
+5 -13
View File
@@ -15,7 +15,7 @@ import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -109,7 +109,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -143,16 +143,8 @@ export const Plugin = {
})
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
}
const target = yield* access.resolve({ path: input.path, kind: "file" })
yield* access.authorizeExternal([target], context)
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
@@ -218,7 +210,7 @@ export const Plugin = {
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
fileMutation.withLock([FileAccess.resolvePath(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+4 -11
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
import { Permission } from "../../permission.js"
@@ -48,7 +48,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -63,15 +63,8 @@ export const Plugin = {
Effect.gen(function* () {
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* access.resolve({ path: searchPath ?? ".", kind: "directory" })
yield* access.authorizeExternal([target], context)
yield* permission.assert({
action: name,
resources: [input.pattern],
+4 -10
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
@@ -67,7 +67,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -82,14 +82,8 @@ export const Plugin = {
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* access.resolve({ path: input.path ?? "." })
yield* access.authorizeExternal([target], context)
yield* permission.assert({
action: name,
resources: [input.pattern],
+12 -18
View File
@@ -9,7 +9,7 @@ import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
import { FileMutation } from "../../file-mutation.js"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission.js"
import DESCRIPTION from "../patch.txt"
@@ -45,29 +45,29 @@ export const toModelContent = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: LocationMutation.Target
readonly target: FileAccess.Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: LocationMutation.Target
readonly target: FileAccess.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly target: FileAccess.Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: LocationMutation.Target
readonly moveTarget?: FileAccess.Target
})
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const fileMutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
@@ -86,9 +86,9 @@ export const Plugin = {
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
LocationMutation.resolvePath(location.directory, hunk.path),
FileAccess.resolvePath(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
? [FileAccess.resolvePath(location.directory, hunk.movePath)]
: []),
])
: []
@@ -114,17 +114,11 @@ export const Plugin = {
const prepared: Prepared[] = []
const updates = new Map<string, string>()
const resolveTarget = Effect.fnUntraced(function* (value: string) {
const target = yield* mutation.resolve({ path: value, kind: "file" })
const target = yield* access.resolve({ path: value, kind: "file" })
if (!target.externalDirectory) return target
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
yield* access.authorizeExternal([target], context, {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
})
return target
})
+6 -34
View File
@@ -6,8 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { FileAccess } from "../../file-access.js"
import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js"
import { ReadToolFileSystem } from "../read-filesystem.js"
@@ -31,8 +30,7 @@ export const Plugin = {
id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
const access = yield* FileAccess.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
@@ -48,37 +46,13 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
Effect.gen(function* () {
if (target.externalDirectory && authorizeExternal)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
})
const read = (target: LocationMutation.Target) =>
reader.read(AbsolutePath.make(target.absolute), target.resource, {
const read = (target: FileAccess.Target) =>
reader.read(target.absolute, target.resource, {
offset: input.offset,
limit: input.limit,
})
const requested = yield* mutation.resolve({ path: input.path })
yield* authorize(requested)
const requested = yield* access.authorizeRead(input.path, context)
const result = yield* read(requested).pipe(
Effect.map((content) => ({ content, target: requested, path: input.path })),
Effect.catchIf(
@@ -89,9 +63,7 @@ export const Plugin = {
Effect.orElseSucceed(() => undefined),
)
if (!alternate) return yield* missing(input.path, requested.absolute)
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
// The candidate is a sibling under the external directory already approved above.
yield* authorize(target, false)
const target = yield* access.authorizeRead(alternate, context, { siblingOf: requested })
const content = yield* read(target).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
+6 -18
View File
@@ -8,7 +8,7 @@ import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
import { Job } from "../../job.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { NonNegativeInt } from "../../schema.js"
import { Session } from "../../session.js"
@@ -104,7 +104,7 @@ export const Plugin = {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const compatibleShell = shellSelect.resolve({ priority: "compat" })
@@ -117,30 +117,18 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const target = yield* access.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),
access.resolve({
path: FileAccess.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,
})
yield* access.authorizeExternal([target, ...directories], context)
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
+4 -11
View File
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { LocationMutation } from "../../location-mutation.js"
import { FileAccess } from "../../file-access.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -46,7 +46,7 @@ export const toModelContent = (output: Output) =>
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -68,15 +68,8 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* access.resolve({ path: input.path, kind: "file" })
yield* access.authorizeExternal([target], context)
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
)
+1 -1
View File
@@ -25,7 +25,7 @@ const InputObject = Schema.StructWithRest(
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
webfetch: Schema.optional(Rule),
websearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
+3
View File
@@ -1342,6 +1342,7 @@ describe("Config", () => {
bash: "ask",
edit: { "*.md": "allow", "*": "deny" },
question: "deny",
webfetch: { "*": "ask", "https://en.wikipedia.org/*": "allow" },
},
agent: {
reviewer: {
@@ -1420,6 +1421,8 @@ describe("Config", () => {
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" },
{ action: "webfetch", resource: "*", effect: "ask" },
{ action: "webfetch", resource: "https://en.wikipedia.org/*", effect: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toMatchObject({
system: "Review changes.",
@@ -32,6 +32,54 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
custom: {
package: "@opencode-ai/ai/providers/openai/responses",
compaction: { mode: "provider", threshold: 120_000 },
models: {
native: {},
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode-ai/ai/providers/openai/chat" },
unsupported: { package: "@opencode-ai/ai/providers/openai/chat" },
},
},
default: { package: "@opencode-ai/ai/providers/openai/chat", models: { chat: {} } },
},
}),
}),
])
const native = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("native")))
const local = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
expect(yield* ModelResolver.fromCatalogModel(unsupported).pipe(Effect.flip)).toMatchObject({
_tag: "SessionRunnerModel.UnsupportedCompactionError",
message: "Provider compaction is not supported by custom/unsupported (openai-chat)",
})
}),
)
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -4,17 +4,20 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped, withTempDir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, {
LayerNode.compile(FileAccess.node, {
replacements: [
Permission.node.replace(permissionLayer()),
Location.node.replace(
Layer.succeed(
Location.Service,
@@ -31,21 +34,14 @@ function provide(directory: string, projectDirectory = directory) {
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationMutation", () => {
describe("FileAccess.resolve", () => {
it.live("resolves an active relative existing file target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -57,11 +53,11 @@ describe("LocationMutation", () => {
)
it.live("resolves an active relative prospective file target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -71,10 +67,10 @@ describe("LocationMutation", () => {
)
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "../outside.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -89,11 +85,12 @@ describe("LocationMutation", () => {
)
it.live("allows a relative path outside the Location but inside the project worktree", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const active = path.join(directory, "packages", "opencode")
yield* Effect.promise(() => fs.mkdir(active, { recursive: true }))
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../../README.md" })
expect(target).toMatchObject({
absolute: path.join(directory, "README.md"),
resource: "../../README.md",
@@ -104,37 +101,34 @@ describe("LocationMutation", () => {
)
it.live("does not treat a filesystem-root project sentinel as an internal boundary", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "../outside.txt" })
expect(target.externalDirectory).toBeDefined()
}).pipe(provide(directory, path.parse(directory).root)),
),
)
it.live("resolves a prospective target below an external symlink lexically", () =>
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
const outside = yield* tmpdirScoped()
yield* Effect.promise(() => fs.symlink(outside.path, path.join(directory, "escape")))
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
})
expect(target.externalDirectory).toBeUndefined()
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory))
}),
}).pipe(provide(directory)),
),
)
it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
@@ -142,8 +136,8 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
const mutation = yield* LocationMutation.Service
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
const access = yield* FileAccess.Service
expect(yield* access.resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -152,11 +146,11 @@ describe("LocationMutation", () => {
)
it.live("accepts an explicit absolute in-location target without external approval", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -167,12 +161,12 @@ describe("LocationMutation", () => {
)
it.live("requires external-directory authorization for an explicit external absolute target", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -188,26 +182,26 @@ describe("LocationMutation", () => {
)
it.live("resolves an existing external file target", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
expect(target.externalDirectory?.directory).toBe(AbsolutePath.make(outside))
}).pipe(provide(directory)),
),
),
)
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: outside, kind: "file" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -218,12 +212,12 @@ describe("LocationMutation", () => {
)
it.live("authorizes prospective external descendants at their lexical parent", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -234,19 +228,18 @@ describe("LocationMutation", () => {
),
)
test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
test("ignores unknown path input fields", () => {
expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
})
})
test("expands a leading tilde against the home directory", () => {
const home = path.resolve("/Users/aiden")
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
expect(FileAccess.resolvePath("/project", "~", home)).toBe(home)
expect(FileAccess.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(FileAccess.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(FileAccess.resolvePath("/project", "~\\notes.md", home)).toBe(
process.platform === "win32" ? path.resolve(home, "notes.md") : path.resolve("/project", "~\\notes.md"),
)
})
@@ -257,16 +250,16 @@ describe("LocationMutation", () => {
["/cygdrive/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
["/mnt/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
])("normalizes Windows shell drive path %s before resolution", (input, windows) => {
expect(LocationMutation.resolvePath("/project", input)).toBe(
expect(FileAccess.resolvePath("/project", input)).toBe(
process.platform === "win32" ? path.resolve(windows) : path.resolve(input),
)
})
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
@@ -282,8 +275,8 @@ describe("LocationMutation", () => {
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "~/notes.md" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempLocationLayer } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { toolIdentity } from "./lib/tool"
const invocation = {
...toolIdentity,
sessionID: Session.ID.make("ses_file_access"),
id: Tool.CallID.make("call-read"),
}
const slash = (file: string) => file.replaceAll("\\", "/")
function provide(requests: Permission.AssertInput[], denied?: string) {
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [
Location.node.replace(tempLocationLayer),
Permission.node.replace(
permissionLayer({
assert: (input) =>
Effect.gen(function* () {
requests.push(input)
if (input.action === denied)
yield* new Permission.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
})
}),
}),
),
]),
)
}
describe("FileAccess.authorizeRead", () => {
it.live("returns an absolute target and preserves invocation identity on the read assertion", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const location = yield* Location.Service
const target = yield* access.authorizeRead("src/../README.md", invocation)
const absolute: AbsolutePath = target.absolute
expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md")))
expect(target.externalDirectory).toBeUndefined()
expect(requests).toEqual([
{
action: "read",
resources: ["README.md"],
save: ["*"],
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
},
])
}).pipe(provide(requests))
})
it.live("authorizes an external directory before the file's read rules", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const target = yield* access.authorizeRead("../notes.txt", invocation)
expect(requests).toMatchObject([
{ action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] },
{ action: "read", resources: [slash(target.absolute)] },
])
for (const request of requests) {
expect(request).toMatchObject({
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
})
}
}).pipe(provide(requests))
})
for (const action of ["external_directory", "read"]) {
it.live(`propagates ${action} denial without continuing authorization`, () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip)
expect(error).toBeInstanceOf(Permission.BlockedError)
expect(requests.map((request) => request.action)).toEqual(
action === "external_directory" ? ["external_directory"] : ["external_directory", "read"],
)
}).pipe(provide(requests, action))
})
}
it.live("reuses a sibling's directory approval only for the supplied recovery call", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const requested = yield* access.authorizeRead("../report final.txt", invocation)
const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested })
yield* access.authorizeRead("../notes.txt", invocation)
expect(requests.map((request) => request.action)).toEqual([
"external_directory",
"read",
"read",
"external_directory",
"read",
])
expect(requests[2].resources).toEqual([slash(recovered.absolute)])
}).pipe(provide(requests))
})
it.live("checks the external directory for a target that is not a sibling", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const access = yield* FileAccess.Service
const requested = yield* access.authorizeRead("README.md", invocation)
yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested })
expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"])
}).pipe(provide(requests))
})
it.live("batches external resources in first-seen order and preserves broader repository saves", () => {
const requests: Permission.AssertInput[] = []
return Effect.gen(function* () {
const external = yield* tmpdirScoped()
const git = path.join(external.path, "git")
const hg = path.join(external.path, "hg")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(git, ".git"), { recursive: true })
await fs.mkdir(path.join(git, "nested"))
await fs.mkdir(path.join(hg, ".hg"), { recursive: true })
await fs.mkdir(path.join(hg, "nested"))
})
const access = yield* FileAccess.Service
const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" })
const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" })
const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" })
const internal = yield* access.resolve({ path: "README.md" })
const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) }
yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata)
expect(requests).toEqual([
{
action: "external_directory",
resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))],
save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))],
metadata,
sessionID: invocation.sessionID,
agent: invocation.agent,
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
},
])
yield* access.authorizeExternal([internal], invocation)
expect(requests).toHaveLength(1)
yield* access.authorizeExternal([second], invocation)
expect(requests).toHaveLength(2)
expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))])
expect(Object.hasOwn(requests[1], "metadata")).toBe(false)
}).pipe(provide(requests))
})
})
+30 -30
View File
@@ -7,12 +7,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { withTempDir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
const activeLocation = Layer.succeed(
@@ -20,27 +22,22 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
AppNodeBuilder.build(LayerNode.group([FileAccess.node, FileMutation.node]), [
Location.node.replace(activeLocation),
Permission.node.replace(permissionLayer()),
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
]),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("FileMutation", () => {
it.live("writes an existing internal file and returns a stable result", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: "hello.txt" })
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write",
@@ -54,9 +51,10 @@ describe("FileMutation", () => {
)
it.live("writes a prospective internal file and creates parent directories", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({
const access = yield* FileAccess.Service
const target = yield* access.resolve({
path: path.join("src", "nested", "hello.txt"),
})
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
@@ -73,12 +71,13 @@ describe("FileMutation", () => {
)
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const preservedPath = path.join(directory, "preserved.txt")
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const access = yield* FileAccess.Service
const preserved = yield* access.resolve({ path: "preserved.txt" })
const created = yield* access.resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
@@ -91,11 +90,12 @@ describe("FileMutation", () => {
)
it.live("writes an explicitly resolved external target", () =>
withTmp((directory) =>
withTmp((outside) =>
withTempDir(({ path: directory }) =>
withTempDir(({ path: outside }) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const access = yield* FileAccess.Service
const target = yield* access.resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
expect(result).toEqual({
@@ -111,7 +111,7 @@ describe("FileMutation", () => {
)
it.live("serializes concurrent writes to the same absolute target", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
@@ -133,10 +133,10 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
const firstPlan = yield* access.resolve({ path: "shared.txt" })
const secondPlan = yield* access.resolve({ path: "shared.txt" })
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
@@ -154,7 +154,7 @@ describe("FileMutation", () => {
)
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -183,7 +183,7 @@ describe("FileMutation", () => {
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -205,7 +205,7 @@ describe("FileMutation", () => {
)
it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) =>
withTempDir(({ path: directory }) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
@@ -222,10 +222,10 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const access = yield* FileAccess.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
const firstPlan = yield* access.resolve({ path: "first.txt" })
const secondPlan = yield* access.resolve({ path: "second.txt" })
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
+85
View File
@@ -164,4 +164,89 @@ describe("MCP OAuth", () => {
test("rejects an invalid redirect URL", async () => {
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
})
describe("client registration", () => {
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
const authorizationServer = (metadata: Record<string, unknown>) => {
const registrations: unknown[] = []
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/oauth-authorization-server")
return Response.json({
issuer: url.origin,
authorization_endpoint: `${url.origin}/authorize`,
token_endpoint: `${url.origin}/token`,
registration_endpoint: `${url.origin}/register`,
response_types_supported: ["code"],
...metadata,
})
if (request.method === "POST" && url.pathname === "/register") {
registrations.push(await request.json())
return Response.json({ client_id: "registered", redirect_uris: [] })
}
if (request.method === "POST" && url.pathname === "/token") {
tokenRequests.push(new URLSearchParams(await request.text()))
return Response.json({ access_token: "access", token_type: "Bearer" })
}
return new Response(null, { status: 404 })
},
})
return { server, registrations, tokenRequests }
}
const start = (server: ReturnType<typeof Bun.serve>, oauth?: ConfigMCP.OAuth) =>
Effect.gen(function* () {
const authorization = yield* McpOAuth.authorize({
name: "test",
config: new ConfigMCP.Remote({ type: "remote", url: server.url.href, ...(oauth ? { oauth } : {}) }),
methodID: Integration.MethodID.make("oauth"),
})
return { authorization, url: new URL(authorization.url) }
})
const cimd = { client_id_metadata_document_supported: true, token_endpoint_auth_methods_supported: ["none"] }
test("uses the client metadata document when the server supports public CIMD clients", async () => {
const { server, registrations, tokenRequests } = authorizationServer(cimd)
const credential = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { authorization, url } = yield* start(server)
expect(url.searchParams.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
const redirect = new URL(url.searchParams.get("redirect_uri")!)
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", url.searchParams.get("state")!)
yield* Effect.promise(() => fetch(redirect))
return yield* authorization.callback
}),
),
).finally(() => server.stop(true))
expect(registrations).toHaveLength(0)
expect(tokenRequests[0]?.get("client_id")).toBe(McpOAuth.CLIENT_METADATA_URL)
expect(McpOAuth.clientFromCredential(credential)).toEqual({ client_id: McpOAuth.CLIENT_METADATA_URL })
})
test("registers dynamically when the server does not accept public clients", async () => {
const { server, registrations } = authorizationServer({
client_id_metadata_document_supported: true,
token_endpoint_auth_methods_supported: ["client_secret_post"],
})
const { url } = await Effect.runPromise(Effect.scoped(start(server))).finally(() => server.stop(true))
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(1)
})
test("registers dynamically when a custom redirect_uri is configured", async () => {
const { server, registrations } = authorizationServer(cimd)
const { url } = await Effect.runPromise(
Effect.scoped(start(server, { redirect_uri: "http://127.0.0.1:0/custom" })),
).finally(() => server.stop(true))
expect(url.searchParams.get("client_id")).toBe("registered")
expect(registrations).toHaveLength(1)
})
})
})
@@ -145,7 +145,11 @@ describe("OpenAIPlugin", () => {
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(provider.headers).toMatchObject({
originator: "opencode",
"chatgpt-account-id": "acct_123",
"x-codex-beta-features": "remote_compaction_v2",
})
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.hasHttpHooks).toBe(false)
@@ -206,6 +210,8 @@ describe("OpenAIPlugin", () => {
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.capabilities.responsesWebsockets).toBe(true)
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
@@ -197,6 +197,20 @@ it.effect("auto compaction estimates current content against the buffered prompt
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const native = (
tokens: number,
limit: { context: number; input?: number; output: number } = inputLimited,
threshold?: number,
) => {
const selected = input(tokens, limit)
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
}
expect(compaction.required(native(251_999))).toBe(false)
expect(compaction.required(native(252_000))).toBe(true)
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
@@ -42,7 +42,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
LocationMutation.node,
FileAccess.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -64,7 +64,7 @@ const testLayer = AppNodeBuilder.build(
Session.node,
Location.node,
FSUtil.node,
LocationMutation.node,
FileAccess.node,
ReadToolFileSystem.node,
readToolNode,
Tool.node,
@@ -0,0 +1,437 @@
import { expect, test } from "bun:test"
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionHistory } from "@opencode-ai/core/session/history"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionInbox.node,
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
PluginHooks.node,
llmClient,
]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)
const setup = Effect.fnUntraced(function* (endpoint = false) {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
const store = yield* SessionStore.Service
const compaction = yield* SessionCompaction.Service
const requests = yield* SessionModelRequest.Service
const hooks = yield* PluginHooks.Service
const blocked = Deferred.makeUnsafe<void>()
const hanging = Promise.withResolvers<Response>()
const state = { failure: false, flaky: false, hang: false, overflow: false, localFailure: false, calls: 0 }
const bodies: Record<string, unknown>[] = []
const headers: Headers[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
state.calls++
headers.push(request.headers)
bodies.push(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)))(
await request.text(),
),
)
if (state.hang) {
Deferred.doneUnsafe(blocked, Effect.void)
return hanging.promise
}
// Persistent failures opt out of retries so the schedule's backoff stays out of these tests.
if (state.failure || state.flaky) {
const retry = state.flaky
state.flaky = false
return Response.json(
{ error: { message: "fixture rate limit", type: "rate_limit_error" } },
{ status: 429, headers: retry ? {} : { "x-should-retry": "false" } },
)
}
const trigger = JSON.stringify(bodies.at(-1)).includes("compaction_trigger")
if (state.overflow && (trigger || state.localFailure))
return Response.json(
{
error: {
message: "Your input exceeds the context window",
code: "context_length_exceeded",
type: "invalid_request_error",
},
},
{ status: 400 },
)
const checkpoint = {
type: "compaction",
id: `cmp_${state.calls}`,
encrypted_content: `encrypted_${state.calls}`,
}
if (new URL(request.url).pathname.endsWith("/compact"))
return Response.json({
id: "compact_endpoint",
object: "response.compaction",
output: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "endpoint retained" }] },
checkpoint,
],
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
})
const output = trigger ? [checkpoint] : []
const summary = state.overflow
? [
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "summary", role: "assistant", content: [] },
},
{
type: "response.output_text.delta",
item_id: "summary",
output_index: 0,
content_index: 0,
delta: "## Objective\n- Recovered locally",
},
]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join("")
: ""
return new Response(
`${summary}data: ${JSON.stringify({
type: "response.completed",
response: {
id: `resp_${state.calls}`,
status: "completed",
output,
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) =>
Effect.sync(() => {
hanging.resolve(new Response("cancelled"))
void server.stop(true)
}),
)
const native = OpenAI.configure({ apiKey: "fixture", baseURL: server.url.toString() }).responses("gpt-5.4-mini")
const model = SessionRunnerModel.resolved(
endpoint
? LanguageModel.update(native, {
route: native.route.with({ compact: { endpoint: native.route.compact.endpoint } }),
})
: native,
{
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
compaction: { mode: "provider" },
},
)
const sessionID = SessionSchema.ID.create()
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "native-compaction",
version: "test",
})
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die("Missing fixture session")
const instructions = Instructions.make({
key: Instructions.Key.make("test/native"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.succeed("Current instructions"),
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
yield* InstructionState.prepare(db, bus, instructions, sessionID)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-test-hook"] = event.kind
}),
)
yield* hooks.register("session", "http.request", (event) =>
Effect.sync(() => event.request.headers.set("x-http-hook", event.kind)),
)
const prompt = Effect.fnUntraced(function* (text: string, synthetic = false) {
const id = SessionMessage.ID.create()
yield* inbox.admit({
id,
sessionID,
item: { type: synthetic ? "synthetic" : "user", payload: { text }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
})
const load = Effect.gen(function* () {
const history = yield* SessionHistory.preview(
db,
sessionID,
instructions,
SessionProviderContext.provenance(model) ?? "local",
)
return {
session,
model,
initial: history.initial,
messages: history.messages,
instructionUpdate: history.instructionUpdate,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
tools: {
definitions: [
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
],
execute: () => Effect.die("Compaction must never dispatch tools"),
},
}
})
const compact = Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: SessionMessage.ID.create(),
resolveContext: () => load,
prepare: requests.prepare,
})
})
const checkpoint = Effect.gen(function* () {
const messages = (yield* load).messages
const last = messages.findLast((message) => message.type === "compaction" && message.status === "completed")
if (last?.type !== "compaction" || last.status !== "completed" || !last.providerContext)
return yield* Effect.die("Missing native checkpoint")
expect(last.summary).toBe("")
expect(last.recent).toBe("")
return last.providerContext
})
return {
compact,
automatic: Effect.gen(function* () {
return yield* compaction.compact({ context: yield* load, prepare: requests.prepare })
}),
checkpoint,
prompt,
load,
requests,
bodies,
headers,
state,
blocked,
sessionID,
store,
hooks,
model,
}
})
it.live(
"manual trigger persists and continues, retains earlier users repeatedly, and preserves context on failure/cancellation",
() =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("First real user request")
yield* fixture.prompt("Synthetic context, not a user request", true)
expect(yield* fixture.compact).toEqual({ status: "completed" })
const first = yield* fixture.checkpoint
expect(SessionProviderContext.decode(first).map((message) => message.role)).toEqual(["user", "assistant"])
expect(JSON.stringify(first.messages)).not.toContain("Synthetic context")
expect(fixture.bodies[0]).toMatchObject({
input: expect.arrayContaining([{ type: "compaction_trigger" }]),
tools: [expect.objectContaining({ name: "read" })],
})
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
expect(fixture.headers[0]?.get("x-test-hook")).toBe("compaction")
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
yield* fixture.prompt("Second real user request")
const context = yield* fixture.load
const prepared = yield* fixture.requests.prepare({
kind: "primary",
scope: { session: context.session, model: context.model, agentID: context.agent.id, tools: context.tools },
transcript: SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }),
})
const client = yield* LLMClient.Service
yield* client.generate(prepared.request, prepared.options)
expect(JSON.stringify(fixture.bodies[1])).toContain("encrypted_1")
expect(JSON.stringify(fixture.bodies[1])).toContain("Current instructions")
expect(JSON.stringify(fixture.bodies[1])).toContain("Second real user request")
expect(yield* fixture.compact).toEqual({ status: "completed" })
const second = yield* fixture.checkpoint
expect(
SessionProviderContext.decode(second)
.filter((message) => message.role === "user")
.map((message) => message.content),
).toEqual([[Message.text("First real user request")], [Message.text("Second real user request")]])
expect(JSON.stringify(second.messages)).not.toContain("encrypted_1")
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 40, output: 8 } })
// Nothing new since the checkpoint is not compactable, exactly like a fresh local summary.
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "compaction.unavailable" } })
yield* fixture.prompt("Third real user request")
fixture.state.failure = true
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(4)
expect(yield* fixture.checkpoint).toEqual(second)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.compact.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.checkpoint).toEqual(second)
// A transient provider failure retries under the shared session policy and its plugin hook.
fixture.state.hang = false
const retries: PluginHooks.Domains["session"]["retry"][] = []
yield* fixture.hooks.register("session", "retry", (event) =>
Effect.sync(() => {
retries.push(event)
event.decision = { retry: true, delay: 0 }
}),
)
fixture.state.flaky = true
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(7)
expect(retries).toMatchObject([
{
agent: "compaction",
attempt: 2,
error: { type: "provider.rate-limit" },
decision: { retry: true, delay: 0 },
},
])
expect(
SessionProviderContext.decode(yield* fixture.checkpoint).filter((message) => message.role === "user"),
).toHaveLength(3)
}),
15000,
)
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
Effect.gen(function* () {
const fixture = yield* setup(true)
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(yield* fixture.automatic).toEqual({ status: "completed" })
const replacement = SessionProviderContext.decode(yield* fixture.checkpoint)
expect(replacement[0]?.content).toEqual([Message.text("endpoint retained")])
expect(JSON.stringify(replacement)).not.toContain("Original user")
expect(fixture.state.calls).toBe(2)
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
}),
)
it.live("only known automatic native overflow falls back locally and failed recovery retains the checkpoint", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original durable request")
expect(yield* fixture.compact).toEqual({ status: "completed" })
const installed = yield* fixture.checkpoint
yield* fixture.prompt("Recent request")
fixture.state.failure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
expect(fixture.state.calls).toBe(2)
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.failure = false
fixture.state.hang = true
const pending = yield* fixture.automatic.pipe(Effect.forkScoped)
yield* Deferred.await(fixture.blocked)
yield* Fiber.interrupt(pending)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.interrupted" },
})
expect(yield* fixture.checkpoint).toEqual(installed)
fixture.state.hang = false
fixture.state.overflow = true
fixture.state.localFailure = true
expect(yield* fixture.automatic).toMatchObject({ status: "failed" })
expect(fixture.state.calls).toBe(5)
expect(yield* fixture.checkpoint).toEqual(installed)
expect(JSON.stringify(fixture.bodies[4])).toContain("Original durable request")
expect(JSON.stringify(fixture.bodies[4])).not.toContain("encrypted_1")
fixture.state.localFailure = false
expect(yield* fixture.automatic).toEqual({ status: "completed", recoveredOverflow: true })
expect(fixture.state.calls).toBe(7)
expect((yield* fixture.load).messages).toContainEqual(
expect.objectContaining({ type: "compaction", summary: "## Objective\n- Recovered locally" }),
)
}),
)
it.live("rejects request-hook route rewrites before provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original user")
yield* fixture.hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.baseURL = "https://another.example/v1"
}),
)
expect(yield* fixture.compact).toMatchObject({
status: "failed",
error: { type: "provider.unsupported-operation" },
})
expect(fixture.state.calls).toBe(0)
}),
)
test("retained user budget counts attachments and drops whole oldest messages", () => {
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
})
const user = (text: string) =>
SessionMessage.User.make({
id: SessionMessage.ID.create(),
type: "user",
text,
time: { created: DateTime.makeUnsafe(0) },
})
const newest = {
...user("x".repeat(63_000 * 4)),
files: [{ mime: "image/png", data: "aGVsbG8=", source: { type: "inline" as const } }],
}
expect(SessionCompaction.retainUsers([user("old"), newest], model, 64_000)).toEqual([])
expect(
SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
).toHaveLength(1)
})
@@ -0,0 +1,303 @@
import { expect, test } from "bun:test"
import { CompactionPart, LanguageModel, Message, ToolCallPart } from "@opencode-ai/ai"
import { OpenAIResponses } from "@opencode-ai/ai/protocols"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionHistory } from "@opencode-ai/core/session/history"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Model } from "@opencode-ai/schema/model"
import { asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 128_000, output: 4096 },
},
)
const target = SessionProviderContext.provenance(model)
if (!target) throw new Error("Fixture must have a concrete endpoint")
const replacement = [
Message.user("retained request"),
Message.assistant(
CompactionPart.make({ provider: model.model.provider, encrypted: "opaque-checkpoint", id: "cp_1" }),
),
]
const providerContext = SessionProviderContext.encode(target, replacement)
const sessionID = SessionSchema.ID.make("ses_provider_context")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)
const setup = Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "provider-context",
version: "test",
})
const state = { value: "initial instructions" }
const instructions = Instructions.make({
key: Instructions.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => state.value),
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
const prepare = InstructionState.prepare(database.db, bus, instructions, sessionID)
const prompt = Effect.fnUntraced(function* (text: string) {
const id = SessionMessage.ID.create()
yield* inbox.admit({ id, sessionID, item: { type: "user", payload: { text }, delivery: "steer" } })
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
return id
})
const compact = (context?: SessionProviderContext.Info) =>
bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: context ? "" : "local summary",
recent: "",
providerContext: context,
})
const load = (boundary: SessionHistory.Boundary) =>
SessionHistory.entriesForRunner(database.db, sessionID, instructions, boundary)
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
})
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
const messages = [
...replacement,
Message.assistant(ToolCallPart.make({ id: "call_1", name: "read", input: { path: "file" } })),
Message.tool({ id: "call_1", name: "read", result: { text: "result" } }),
Message.user({ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) }),
]
const context = SessionProviderContext.encode(providerContext.provenance, messages)
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(SessionProviderContext.Info))(JSON.stringify(context))
const decoded = SessionProviderContext.decode(stored)
expect(decoded.slice(0, -1)).toEqual(messages.slice(0, -1))
expect(decoded.at(-1)?.content).toEqual([{ type: "media", mediaType: "image/png", data: "AQID" }])
const optionalMetadata = SessionProviderContext.encode(providerContext.provenance, [
Message.make({
role: "user",
content: [
{ type: "text", text: "attachment", metadata: { attachment: { name: undefined, source: { type: "inline" } } } },
],
providerMetadata: { openai: { itemId: undefined, type: "message", status: undefined, phase: undefined } },
}),
])
expect(SessionProviderContext.decode(optionalMetadata)[0]).toMatchObject({
providerMetadata: { openai: { type: "message" } },
content: [{ metadata: { attachment: { source: { type: "inline" } } } }],
})
expect(() =>
SessionProviderContext.decode({
...context,
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai" }] }],
}),
).toThrow()
})
test("compatibility uses the actual deployment and endpoint rather than a catalog alias or variant", () => {
expect(
SessionProviderContext.compatible(
providerContext.provenance,
SessionProviderContext.provenance({
...model,
ref: { ...model.ref, id: Model.ID.make("alias"), variant: Model.VariantID.make("high") },
}),
),
).toBe(true)
for (const changed of [
{ ...model, model: LanguageModel.update(model.model, { id: "other-deployment" }) },
{
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { baseURL: "https://another.example/v1?api-key=secret" } }),
}),
},
{ ...model, model: LanguageModel.update(model.model, { route: model.model.route.with({ id: "other-route" }) }) },
])
expect(
SessionProviderContext.compatible(providerContext.provenance, SessionProviderContext.provenance(changed)),
).toBe(false)
const privateEndpoint = SessionProviderContext.provenance({
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { baseURL: "https://user:secret@example.com/v1?api-key=secret" } }),
}),
})
expect(JSON.stringify(privateEndpoint)).not.toContain("secret")
expect(
SessionProviderContext.provenance({
...model,
model: LanguageModel.update(model.model, {
route: model.model.route.with({ endpoint: { path: () => "/dynamic" } }),
}),
}),
).toBeUndefined()
expect(SessionProviderContext.compatible(providerContext.provenance, undefined)).toBe(false)
})
it.effect(
"advances the native instruction epoch and omits superseded chronological updates after durable replay and provider switches",
() =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("original request")
s.state.value = "changed instructions"
yield* s.prepare
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
const completed = yield* s.compact(providerContext)
s.state.value = "newest instructions"
yield* s.prepare
yield* s.prompt("continue")
const verify = Effect.gen(function* () {
expect(
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toMatchObject({
epoch_start: completed.durable.seq,
initial_values: { "test/context": Instructions.hash("changed instructions") },
current_values: { "test/context": Instructions.hash("newest instructions") },
})
const native = yield* s.load(target)
expect(native.initial).toBe("changed instructions")
expect(
toLLMMessages(
native.entries.map((entry) => entry.message),
model.ref,
),
).toEqual([
...replacement,
Message.system("newest instructions"),
expect.objectContaining({ role: "user", content: [Message.text("continue")] }),
])
for (const incompatible of [
"local" as const,
{ ...providerContext.provenance, modelID: "other" },
{ ...providerContext.provenance, provider: "other" },
]) {
const expanded = yield* s.load(incompatible)
expect(expanded.initial).toBe("changed instructions")
expect(
toLLMMessages(
expanded.entries.map((entry) => entry.message),
model.ref,
).map((message) => message.content),
).toEqual([
[Message.text("original request")],
[Message.text("newest instructions")],
[Message.text("continue")],
])
}
const preview = yield* SessionHistory.preview(s.db, sessionID, s.instructions, target)
expect(preview.initial).toBe("changed instructions")
expect(preview.messages).toEqual(native.entries.map((entry) => entry.message))
const store = yield* SessionStore.Service
expect((yield* store.messages({ sessionID })).map((message) => message.type)).toEqual([
"user",
"system",
"compaction",
"system",
"user",
])
})
yield* verify
const recorded = yield* s.db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
expect(recorded.filter((event) => event.data.providerContext !== undefined)).toHaveLength(1)
yield* s.bus.remove(sessionID)
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
for (const event of recorded)
yield* s.bus.replay({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
})
yield* verify
}),
)
it.effect("falls back to an earlier compatible native or local checkpoint", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.prepare
yield* s.prompt("before local")
s.state.value = "local baseline"
yield* s.prepare
yield* s.compact()
yield* s.prompt("after local")
yield* s.compact(providerContext)
yield* s.prompt("after native")
s.state.value = "new native baseline"
yield* s.prepare
yield* s.compact({ ...providerContext, provenance: { ...providerContext.provenance, modelID: "other" } })
s.state.value = "post-epoch update"
yield* s.prepare
const native = yield* s.load(target)
expect(native.initial).toBe("new native baseline")
expect(native.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "system"])
expect(native.entries[0]?.message).toMatchObject({ providerContext })
expect(
toLLMMessages(
native.entries.map((entry) => entry.message),
model.ref,
).filter((message) => message.role === "system"),
).toEqual([Message.system("post-epoch update")])
const local = yield* s.load("local")
expect(local.initial).toBe("new native baseline")
expect(local.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "user", "system"])
expect(local.entries[0]?.message).toMatchObject({ summary: "local summary" })
}),
)
it.effect("rejects malformed persisted native windows instead of silently dropping them", () =>
Effect.gen(function* () {
const s = yield* setup
yield* s.compact({ ...providerContext, messages: [{ role: "invalid", content: [] }] })
expect(yield* SessionHistory.load(s.db, sessionID, target).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageDecodeError",
})
const store = yield* SessionStore.Service
expect(yield* store.context(sessionID).pipe(Effect.flip)).toMatchObject({ _tag: "Session.MessageDecodeError" })
}),
)
+174 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import {
AIError,
CompactionPart,
CompactionCheckpointResponse,
HttpContext,
LLMEvent,
LLMRequest,
@@ -40,6 +42,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -192,7 +195,7 @@ test("does not apply an ineligible tier without base pricing", () => {
).toBe(Money.USD.zero)
})
const makeRunnerState = () => {
const makeRunnerState = (compaction?: SessionRunnerModel.Resolved["compaction"]) => {
let toolBarrier: ToolBarrier | undefined
const releaseTools = (barrier: ToolBarrier) =>
Effect.sync(() => {
@@ -200,6 +203,7 @@ const makeRunnerState = () => {
}).pipe(Effect.andThen(Deferred.succeed(barrier.release, undefined)), Effect.asVoid)
return {
currentModel: model,
compaction,
modelResolveHook: Effect.void,
systemBaseline: "Initial context",
systemRemoved: false,
@@ -319,6 +323,7 @@ const layer = Layer.unwrap(
cost: [],
limit: modelLimits.get(String(selected.id)) ?? defaultModelLimit,
variant: session.model?.variant,
compaction: state.compaction,
})
}),
),
@@ -1435,6 +1440,92 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.inbox).toEqual([])
})
scenario(
"restores installed native context with auto disabled and preserves it across fork and revert",
function* (s) {
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => editor.configure({ auto: false }))
yield* s.runPrompt("Original request")
s.systemBaseline = "Checkpoint instructions"
yield* s.runPrompt("Before checkpoint")
const target = SessionProviderContext.provenance({
model: s.currentModel,
ref: Model.Ref.make({
id: Model.ID.make(s.currentModel.id),
providerID: Provider.ID.make(s.currentModel.provider),
}),
})
if (!target) throw new Error("Expected concrete fixture endpoint")
const replacement = [
Message.assistant(CompactionPart.make({ provider: s.currentModel.provider, encrypted: "checkpoint" })),
]
const providerContext = SessionProviderContext.encode(target, replacement)
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
sessionID,
reason: "manual",
text: "",
recent: "",
providerContext,
})
const checkpoint = (yield* s.messages).find((message) => message.type === "compaction")
if (!checkpoint) throw new Error("Expected checkpoint")
s.systemBaseline = "Newest instructions"
const after = yield* s.runPrompt("After checkpoint")
const continued = s.requests.at(-1)
if (!continued) throw new Error("Expected continuation request")
expect(continued.messages[0]).toEqual(replacement[0])
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
expect(systemTexts(continued)).toEqual(["Newest instructions"])
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[0])
expect(s.requests.at(-1)?.system.map((part) => part.text)).toContain("Newest instructions")
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
Message.system("Newest instructions"),
])
expect(
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
).toMatchObject({ providerContext })
const original = s.currentModel
s.currentModel = LanguageModel.update(original, { id: "different-deployment" })
yield* s.session.prompt({ sessionID: forked.id, text: "Switched fork", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
Message.system("Newest instructions"),
])
s.currentModel = original
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: checkpoint.id })
yield* s.runPrompt("After revert")
expect(
s.requests
.at(-1)
?.messages.flatMap((message) => message.content)
.some((part) => part.type === "compaction"),
).toBe(false)
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
expect(
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
).toMatchObject({ providerContext })
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.baseURL = "https://another-deployment.example/v1"
}),
)
const before = s.requests.length
yield* s.session.prompt({ sessionID: forked.id, text: "Changed route", resume: false })
expect(yield* s.session.resume(forked.id).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
expect(s.requests).toHaveLength(before)
},
)
scenario("seeds a fork with the parent's newest instruction values", function* (s) {
yield* s.runPrompt("First")
s.systemBaseline = "Changed context"
@@ -2678,6 +2769,88 @@ describe("SessionRunnerLLM", () => {
})
})
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.defaultID, (agent) => {
agent.steps = 2
}),
)
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("First real request")
const checkpoint = (encrypted: string) =>
CompactionCheckpointResponse.make({
responseID: `resp_${encrypted}`,
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted },
})
yield* s.llm.push(
checkpoint("first"),
TestLLM.tool("echo-native", "echo", { text: "continue" }),
TestLLM.text("No usage yet", "no-usage"),
)
yield* s.runPrompt("Second real request")
expect(s.requests).toHaveLength(4)
expect(s.requests[2].toolChoice).not.toEqual({ type: "none" })
expect(s.executions).toEqual(["continue"])
expect(JSON.stringify(s.requests[2].messages)).toContain("first")
const installed = (yield* s.messages).filter((message) => message.type === "compaction")
expect(installed).toMatchObject([{ status: "completed", reason: "auto", providerContext: { version: 1 } }])
// New input without a post-checkpoint usage anchor must not retrigger compaction.
yield* s.llm.push(TestLLM.textWithUsage("Measured", "measured", 10_000))
yield* s.runPrompt("Third real request")
expect(s.requests).toHaveLength(5)
yield* s.llm.push(checkpoint("second"), TestLLM.textWithUsage("Continued", "continued", 10_000))
yield* s.runPrompt("Fourth real request")
expect(s.requests).toHaveLength(7)
expect(userTexts(s.requests[6])).toEqual([
"First real request",
"Second real request",
"Third real request",
"Fourth real request",
])
expect(JSON.stringify(s.requests[6].messages)).not.toContain('"encrypted":"first"')
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => editor.configure({ auto: false }))
yield* replaySessionProjection(sessionID)
yield* s.llm.push(TestLLM.text("Disabled auto still replays", "disabled"))
yield* s.runPrompt("Fifth real request")
expect(s.requests).toHaveLength(8)
expect(JSON.stringify(s.requests[7].messages)).toContain('"encrypted":"second"')
})
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
s.compaction = { mode: "provider", threshold: 10_000 }
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
yield* s.runPrompt("Original durable request")
yield* s.llm.push(
CompactionCheckpointResponse.make({
responseID: "resp_native",
checkpoint: { type: "compaction", provider: s.currentModel.provider, encrypted: "native-window" },
}),
TestLLM.text("After native", "after-native"),
)
yield* s.runPrompt("Before native checkpoint")
s.requests.length = 0
yield* s.llm.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("## Objective\n- Recovered original history", "local-recovery"),
TestLLM.text("Recovered", "recovered"),
)
yield* s.runPrompt("Overflow request")
expect(s.requests).toHaveLength(3)
expect(JSON.stringify(s.requests[0].messages)).toContain("native-window")
expect(JSON.stringify(s.requests[1].messages)).not.toContain("native-window")
expect(userTexts(s.requests[1])).toContain("Original durable request")
expect(userTexts(s.requests[1]).at(-1)).toBe(SessionCompaction.buildPrompt(false))
expect(yield* s.context).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recovered original history" },
{ type: "assistant" },
])
})
scenario("does not compact immediately when the advertised output limit fills the context", function* (s) {
s.currentModel = fullOutputModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-full-output-first", 9_500))
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileAccess.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -91,7 +91,7 @@ const withTool = <A, E, R>(
return yield* body(registry)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, editToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
read: (target, range) =>
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileAccess.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, patchToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
read: (target, range) =>
+73 -66
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer } from "effect"
import { Effect, Exit, Layer, Result } from "effect"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -12,7 +12,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { location } from "./fixture/location"
import { Tool } from "@opencode-ai/core/tool"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
@@ -30,7 +30,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
LocationMutation.node,
FileAccess.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -47,7 +47,7 @@ const readCalls: {
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: AbsolutePath[] = []
let resolveFailure: unknown
let readDefect: unknown
let directoryEntries: string[] = []
let directoryEntryDetails: Environment.DirEntry[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
@@ -69,7 +69,7 @@ const reader = Layer.succeed(
},
read: (input, resource, page = {}) => {
readCalls.push({ input, page })
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readDefect !== undefined) return Effect.die(readDefect)
if (readOverride) return readOverride(input, resource, page)
if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult)
@@ -77,13 +77,14 @@ const reader = Layer.succeed(
}),
)
let allow = true
let deniedResource: string | undefined
const permission = permissionLayer({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(
Effect.andThen(
allow
allow && !input.resources.some((resource) => resource === deniedResource)
? Effect.void
: Effect.fail(
new Permission.BlockedError({
@@ -112,30 +113,6 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
)
const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
const directory = path.dirname(absolute)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
absolute,
resource,
externalDirectory: external
? {
action: "external_directory" as const,
directory,
resource: externalResource,
save: externalResource,
}
: undefined,
})
},
}),
)
const unavailableImage = Layer.mock(Image.Service, {
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
})
@@ -146,7 +123,6 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
Permission.node.replace(permission),
Config.node.replace(config),
Image.node.replace(imageLayer),
LocationMutation.node.replace(mutation),
FSUtil.node.replace(testFileSystem),
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
@@ -165,7 +141,8 @@ describe("ReadTool", () => {
readCalls.length = 0
listCalls.length = 0
allow = true
resolveFailure = undefined
deniedResource = undefined
readDefect = undefined
directoryEntries = []
directoryEntryDetails = []
readResult = {
@@ -620,18 +597,21 @@ describe("ReadTool", () => {
it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () {
resolveFailure = new Error("unexpected")
readDefect = new Error("unexpected")
const registry = yield* Tool.Service
expect(
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
}).pipe(Effect.exit),
),
).toBe(true)
const exit = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
}).pipe(Effect.exit)
expect(Result.getOrThrow(Exit.findDefect(exit))).toBe(readDefect)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
page: { offset: undefined, limit: undefined },
},
])
}),
)
@@ -721,6 +701,57 @@ describe("ReadTool", () => {
}),
)
it.effect("recovers an external filename without repeating directory approval", () =>
Effect.gen(function* () {
const directory = path.join(path.parse(process.cwd()).root, "external-read")
const requested = path.join(directory, "report final.txt")
const recovered = path.join(directory, "report\u202ffinal.txt")
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
readOverride = (input) =>
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-external-recovery", name: "read", input: { path: requested } },
}),
).toMatchObject({ status: "completed" })
expect(assertions).toMatchObject([
{ action: "external_directory", resources: [path.join(directory, "*").replaceAll("\\", "/")] },
{ action: "read", resources: [requested.replaceAll("\\", "/")] },
{ action: "read", resources: [recovered.replaceAll("\\", "/")] },
])
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested), AbsolutePath.make(recovered)])
}),
)
it.effect("does not read a recovered filename denied by its own read rules", () =>
Effect.gen(function* () {
const requested = path.join(process.cwd(), "report final.txt")
const recovered = path.join(process.cwd(), "report\u202ffinal.txt")
deniedResource = path.basename(recovered)
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
readOverride = (input) =>
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
const registry = yield* Tool.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-recovery", name: "read", input: { path: requested } },
}),
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
expect(assertions).toMatchObject([
{ action: "read", resources: [path.basename(requested)] },
{ action: "read", resources: [path.basename(recovered)] },
])
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested)])
}),
)
it.effect("does not recover ambiguous files", () =>
Effect.gen(function* () {
const requested = "report final.txt"
@@ -860,30 +891,6 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
resolveFailure = new Error("missing")
expect(
Exit.isFailure(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
}),
)
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.TextPage({
+3 -3
View File
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
+2 -2
View File
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -131,7 +131,7 @@ const shellPluginSupervisor = makeLocationNode({
deps: [
Config.node,
Environment.node,
LocationMutation.node,
FileAccess.node,
Permission.node,
Session.node,
Job.node,
+3 -3
View File
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { FileAccess } from "@opencode-ai/core/file-access"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -79,7 +79,7 @@ const withTool = <A, E, R>(
return yield* body(registry)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [
Environment.node.replace(
transformEnvironmentFiles((files) => ({
write: (target, content) =>
+1
View File
@@ -353,6 +353,7 @@ export interface DialogSelectOption<Value> {
readonly title: string
readonly value: Value
readonly description?: string
readonly footer?: string
readonly category?: string
readonly disabled?: boolean
}
+85
View File
@@ -14384,6 +14384,9 @@
"Config.ModelEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"modelID": {
"type": "string"
},
@@ -14489,6 +14492,9 @@
"Config.ProviderEncoded": {
"type": "object",
"properties": {
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"canonical": {
"type": "string"
},
@@ -16432,6 +16438,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -17365,6 +17374,36 @@
"required": ["id"],
"additionalProperties": false
},
"Provider.Compaction": {
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["local"]
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["provider"]
},
"threshold": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["mode"],
"additionalProperties": false
}
]
},
"Provider.Info": {
"type": "object",
"properties": {
@@ -17387,6 +17426,9 @@
"package": {
"type": "string"
},
"compaction": {
"$ref": "#/components/schemas/Provider.Compaction"
},
"settings": {
"type": "object"
},
@@ -18355,6 +18397,9 @@
},
"recent": {
"type": "string"
},
"providerContext": {
"$ref": "#/components/schemas/Session.ProviderContext"
}
},
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
@@ -18903,6 +18948,46 @@
"Session.Metadata": {
"type": "object"
},
"Session.ProviderContext": {
"type": "object",
"properties": {
"version": {
"type": "number",
"enum": [1]
},
"provenance": {
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
},
"messages": {}
},
"required": ["version", "provenance", "messages"],
"additionalProperties": false
},
"Session.ProviderContext.Provenance": {
"type": "object",
"properties": {
"providerID": {
"type": "string"
},
"provider": {
"type": "string"
},
"modelID": {
"type": "string"
},
"route": {
"type": "string"
},
"protocol": {
"type": "string"
},
"endpoint": {
"type": "string"
}
},
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
"additionalProperties": false
},
"Session.Revert": {
"type": "object",
"properties": {
+7 -9
View File
@@ -246,15 +246,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.remove",
summary: "Delete session",
description: "Delete a session and its child sessions.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.remove",
summary: "Delete session",
description: "Delete a session and its child sessions.",
}),
),
)
.add(
HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", {
+2
View File
@@ -41,6 +41,7 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
compaction: Provider.Compaction.pipe(optional),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
@@ -58,6 +59,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
compaction: Provider.Compaction.pipe(optional),
canonical: Provider.ID.pipe(optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
+1
View File
@@ -106,6 +106,7 @@ export const Info = Schema.Struct({
name: Schema.String,
compatibility: Compatibility.pipe(optional),
package: Provider.Package.pipe(optional),
compaction: Provider.Compaction.pipe(optional),
...Provider.Overlays,
capabilities: Capabilities,
variants: Schema.Array(Variant),
+8 -1
View File
@@ -2,7 +2,7 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { optional, statics } from "./schema.js"
import { optional, PositiveInt, statics } from "./schema.js"
export const ID = Schema.String.pipe(
Schema.brand("Provider.ID"),
@@ -28,6 +28,12 @@ export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export type Compaction = typeof Compaction.Type
export const Compaction = Schema.Union([
Schema.Struct({ mode: Schema.Literal("local") }),
Schema.Struct({ mode: Schema.Literal("provider"), threshold: PositiveInt.pipe(optional) }),
]).annotate({ identifier: "Provider.Compaction" })
export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
@@ -52,6 +58,7 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
compaction: Compaction.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
+1
View File
@@ -587,6 +587,7 @@ export namespace Compaction {
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
text: Schema.String,
recent: Schema.String,
},
+2
View File
@@ -1,6 +1,7 @@
export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
import { Location } from "./location.js"
@@ -254,6 +255,7 @@ export const CompactionCompleted = Schema.Struct({
providerState: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
providerContext: SessionProviderContext.Info.pipe(optional),
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
@@ -0,0 +1,24 @@
export * as SessionProviderContext from "./session-provider-context.js"
import { Schema } from "effect"
import { Provider } from "./provider.js"
/** Exact producing model/deployment and route identity, never credentials or a connection ID. */
export interface Provenance extends Schema.Schema.Type<typeof Provenance> {}
export const Provenance = Schema.Struct({
providerID: Provider.ID,
provider: Schema.String,
modelID: Schema.String,
route: Schema.String,
protocol: Schema.String,
/** Digest of the configured endpoint; raw URLs and query values are not persisted. */
endpoint: Schema.String,
}).annotate({ identifier: "Session.ProviderContext.Provenance" })
/** Core validates the versioned canonical AI Message[] payload on installation and replay. */
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
version: Schema.Literal(1),
provenance: Provenance,
messages: Schema.Json,
}).annotate({ identifier: "Session.ProviderContext" })
+19
View File
@@ -56,6 +56,25 @@ describe("Model.Compatibility", () => {
})
describe("Model.Info", () => {
test("provider compaction policy is optional and uses the canonical closed schema", () => {
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
expect(Schema.encodeSync(Model.Info)({ ...model, compaction: undefined })).not.toHaveProperty("compaction")
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, compaction: { mode: "provider" } }).compaction).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "local" })).toEqual({ mode: "local" })
expect(Schema.encodeSync(Provider.Compaction)({ mode: "provider", threshold: undefined })).toEqual({
mode: "provider",
})
expect(Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold: 120_000 })).toEqual({
mode: "provider",
threshold: 120_000,
})
for (const threshold of [0, -1, 1.5])
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "provider", threshold })).toThrow()
expect(() => Schema.decodeUnknownSync(Provider.Compaction)({ mode: "automatic" })).toThrow()
})
test("uses practical token limits for unknown models", () => {
const model = Model.Info.default(Provider.ID.make("custom"), Model.ID.make("gpt-5.6"))
@@ -48,3 +48,25 @@ test("failed steps only override the assistant finish for content filters", () =
})
expect(() => decode({ ...input, finish: "stop" })).toThrow()
})
test("provider compaction context is optional, versioned and JSON-only", () => {
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
const encode = Schema.encodeSync(SessionEvent.Compaction.Ended.data)
const local = { sessionID: "ses_context", reason: "manual" as const, text: "summary", recent: "" }
expect(encode({ ...decode(local), providerContext: undefined })).toEqual(local)
const providerContext = {
version: 1 as const,
provenance: {
providerID: "openai",
provider: "openai",
modelID: "deployment",
route: "responses",
protocol: "responses",
endpoint: "digest",
},
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai", encrypted: "opaque" }] }],
}
expect(encode(decode({ ...local, providerContext }))).toEqual({ ...local, providerContext })
expect(() => decode({ ...local, providerContext: { ...providerContext, version: 2 } })).toThrow()
expect(() => decode({ ...local, providerContext: { ...providerContext, messages: [() => "invalid"] } })).toThrow()
})
@@ -388,7 +388,13 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
return (
<div data-component="session-compaction-message">
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.messagePart.compaction")} />
<TimelineSeparator
label={i18n.t(
props.message.status === "completed" && props.message.providerContext
? "ui.messagePart.providerCompaction"
: "ui.messagePart.compaction",
)}
/>
</div>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
+5 -1
View File
@@ -2113,7 +2113,11 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>Compaction</text>
<text fg={color()}>
{props.message.status === "completed" && props.message.providerContext
? "Provider compaction"
: "Compaction"}
</text>
<Show when={cancelled()}>
<text fg={color()}>· cancelled</text>
</Show>
+24 -5
View File
@@ -171,8 +171,18 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const appendPart = (ref: PartRef, part: AppendPart) =>
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
append(draft, ref, part, queuedStart(draft))
if (!hasPart(draft, ref)) {
append(draft, ref, part, queuedStart(draft))
return
}
if (part.type !== "reasoning" || part.time?.completed === undefined) return
const row = draft.find(
(row) =>
row.type === "group" &&
row.kind === "reasoning" &&
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
)
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
}),
)
@@ -250,7 +260,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ type: "reasoning" },
{ type: "reasoning", time: { completed: event.created } },
)
}),
data.on("session.tool.input.started", (event) => {
@@ -437,17 +447,26 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
return message.content.filter((part) => part.type === match[1])[ordinal]
}
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
if (part.type === "reasoning") {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
rows.splice(index, 0, {
type: "group",
kind: "reasoning",
refs: [ref],
completed: part.time?.completed !== undefined,
})
return
}
if (part.type === "tool" && exploration(part.name)) {

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