Compare commits

..
Author SHA1 Message Date
Shoubhit Dash cc5086d127 feat(session): add turn diff route
GET /api/session/:sessionID/diff?messageID&to&context returns FileDiff.Info[] for the turn containing a user message (default: the newest one), or the contiguous range through a later user message's turn. A turn runs from the first prompt after the Session was last idle until its idle marker, so steers belong to the turn they interrupted; Sessions without markers fall back to prompt-to-next-prompt. The diff compares the range's first recorded step snapshot with its last recorded one, or with the working copy only while the Session is actively executing, resolves the snapshot repository from the Location in effect at the range (rejecting ranges that span a move), and defaults to full-file patches like vcs.diff. Shared missingMessage and failedSnapshot handler helpers replace the inlined mappings in the session handlers.
2026-09-07 22:08:19 +05:30
Shoubhit Dash b20482461c feat(session): record idle boundaries as messages
Project an idle message when a busy period ends (execution succeeded, failed, or interrupted for any reason other than shutdown, which resumes the same turn). Every step since the previous marker is one turn, including prompts steered in while the Session was busy, so turns are derivable from session_message alone without persisting events or a separate table. The marker is invisible to the model and to the TUI and web transcripts.
2026-09-07 22:00:36 +05:30
Shoubhit Dash 5b5368fe98 perf(core): batch snapshot tree diffs
Git.tree.diff ran --name-status, --numstat, and a patch once per changed file, sequentially, so a turn or revert touching N files cost 1 + 3N git processes (~50ms per file). Run the three once over the tree pair, split the patch with VcsPatch.chunksByFile, cap patch output at MAX_TOTAL_PATCH_BYTES like VCS diffs (capped files get an empty patch, stats stay exact), keep core.quotepath=false so non-ASCII paths still match their chunk, and pass --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice and passing every path as a pathspec.
2026-09-07 21:53:19 +05:30
50 changed files with 1451 additions and 928 deletions
-6
View File
@@ -86,7 +86,6 @@
"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",
@@ -104,7 +103,6 @@
"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",
@@ -3208,8 +3206,6 @@
"@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=="],
@@ -5142,8 +5138,6 @@
"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,7 +6,6 @@ export function createWebApp(domain: string) {
$app.stage === "beta"
? {
OPENCODE_CHANNEL: "beta",
VITE_OPENCODE_SERVER_MODE: "none",
VITE_SENTRY_ENVIRONMENT: "beta",
}
: undefined,
+2 -22
View File
@@ -83,28 +83,8 @@ 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.
`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 hosted app defaults to `http://localhost:49374`, matching the managed V2 service. Saved server selections
override this default. Connecting still requires the service's credentials.
The workflow reuses the repository's `CLOUDFLARE_API_TOKEN` and web Sentry settings. The Cloudflare token
must cover SST's R2 state storage, KV assets, Workers, and custom-domain management in the account that
@@ -1,18 +0,0 @@
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)
})
@@ -1,69 +0,0 @@
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()
})
@@ -1,29 +0,0 @@
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" },
},
})
@@ -1,58 +0,0 @@
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,7 +32,6 @@
"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",
@@ -52,7 +51,6 @@
"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"
@@ -89,7 +87,6 @@
"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,7 +20,6 @@ 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>
+11 -13
View File
@@ -76,24 +76,22 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={server ? ServerConnection.key(server) : undefined}
servers={server ? [server] : []}
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
>
<KeyboardInsets />
{standalone && <PwaRoutePersistence />}
-1
View File
@@ -1,5 +1,4 @@
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"
+19 -23
View File
@@ -198,30 +198,26 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
<Show
when={props.servers.length > 1}
fallback={
<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} />}
<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])}
>
<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>
<Icon name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</HomeProjectNavButton>
</Show>
</Show>
</div>
}
>
<div class={`flex min-w-0 flex-col ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
@@ -311,10 +311,8 @@ export function createHomeSessionsController(home: HomeController) {
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) => {
const server = home.selection.value().server
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
},
}
}
+38 -43
View File
@@ -2,7 +2,6 @@ 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
@@ -10,47 +9,43 @@ export function HomeSessions(props: {
scroll: HomeScrollController
}) {
return (
<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>
<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}
/>
)
}
-14
View File
@@ -366,20 +366,6 @@ 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":
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
+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>
}) => {
@@ -1,60 +0,0 @@
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()
})
})
@@ -1,28 +0,0 @@
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 }
}
@@ -1,62 +0,0 @@
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
@@ -1,119 +0,0 @@
[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
@@ -1,142 +0,0 @@
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>
)
}
+8 -14
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, useServers } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
@@ -15,9 +15,6 @@ 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 })),
)
@@ -74,16 +71,13 @@ function TargetServerRoute(props: ParentProps) {
}
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
</Show>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
</SettingsSurfaceProvider>
</LayoutProvider>
)
}
@@ -14,19 +14,6 @@ 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: Schema.optional(TabStorage.ServerKey),
server: 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 layoutSchem
mobileSidebar: { opened: false },
sessionTabs: {},
sessionView: {},
home: { selection: server ? { server } : {} },
home: { selection: { 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(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
initialLayout(ServerConnection.key(servers.list[0])),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
+1 -3
View File
@@ -6,9 +6,7 @@ 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, VITE_OPENCODE_SERVER_MODE: "origin" })
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
+11 -1
View File
@@ -17,6 +17,7 @@ import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
import type { Config } from "@opencode-ai/schema/config"
@@ -360,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1133,6 +1142,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -68,6 +68,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -594,6 +596,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -744,6 +757,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -62,6 +62,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -844,6 +846,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -2177,6 +2185,7 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3122,6 +3131,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3413,6 +3429,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3704,6 +3727,13 @@ export type SessionImportInput = {
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4193,6 +4223,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
+75 -64
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -308,7 +309,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string> },
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
) {
const result = yield* proc
.run(
@@ -317,7 +318,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin },
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
)
.pipe(
Effect.mapError(
@@ -331,7 +332,8 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -385,9 +387,7 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,13 +464,7 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -499,19 +493,23 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -519,49 +517,57 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+24
View File
@@ -57,8 +57,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { EventLog } from "@opencode-ai/schema/event-log"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -113,6 +116,7 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -142,6 +146,13 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -230,6 +241,7 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -362,6 +374,17 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -450,6 +473,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+138
View File
@@ -0,0 +1,138 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode-ai/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+20 -3
View File
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -123,9 +138,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+33 -16
View File
@@ -131,38 +131,55 @@ const layer = Layer.effect(
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
return {
source: repo.source,
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
return yield* git.tree
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
.diff({
...comparison.input,
repository: compared.repository,
from: compared.from,
to: compared.to,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: input.paths,
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+37
View File
@@ -6,6 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Git } from "@opencode-ai/core/git"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { VcsPatch } from "@opencode-ai/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -196,6 +197,42 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+198
View File
@@ -0,0 +1,198 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
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 { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionDiff } from "@opencode-ai/core/session/diff"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ messageID: steer })).toEqual(busy)
expect(yield* diff({ messageID: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ messageID: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "messageID",
})
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+3 -2
View File
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+182 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18486,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18517,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+26
View File
@@ -30,6 +30,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Location } from "@opencode-ai/schema/location"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { EventLog } from "@opencode-ai/schema/event-log"
import { FileDiff } from "@opencode-ai/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -521,6 +522,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
messageID: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+14
View File
@@ -272,6 +272,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -283,6 +295,7 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -295,4 +308,5 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+23 -1
View File
@@ -1,5 +1,6 @@
import { Session } from "@opencode-ai/core/session"
import { SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
import type { Snapshot } from "@opencode-ai/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+33 -62
View File
@@ -17,10 +17,9 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode-ai/protocol/errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { failedMessageDecode, missingSession } from "./session-error"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -212,15 +211,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -448,32 +439,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -481,23 +454,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -527,6 +490,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
@@ -642,15 +621,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
const message = yield* session.updateMessage({ ...ctx.params, content: ctx.payload.content }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag(
"Session.MessageNotAssistantError",
+98
View File
@@ -0,0 +1,98 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "messageID" },
})
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -765,7 +765,8 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
+1
View File
@@ -305,6 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "idle") return rows
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
+182 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18486,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18517,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+182 -8
View File
@@ -1621,14 +1621,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
}
}
@@ -3249,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18486,6 +18625,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18517,6 +18688,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},