Compare commits

..
Author SHA1 Message Date
Kit Langton b4836589f4 Revert "research: delete Hono backend (do not merge) (#25667)" and cleanup
This reverts:
- 28b03595b research: delete Hono backend (do not merge) (#25667)
- b24a4e897 chore(server): clean up post-Hono-deletion scar tissue (#26542)

v1.14.42 broke startup for users with plugins that depend on the
Hono wire format (most visibly opencode-gemini-auth, see #26546).
Restoring Hono as the default backend on stable channels while we
investigate the actual plugin compatibility story.

OPENCODE_EXPERIMENTAL_HTTPAPI flag and dual-backend selection come
back. Stable installs default to Hono; dev/beta default to HTTP API.

Conflict resolution: took the pre-deletion side for control-plane
schemas and the seven test files where post-deletion follow-up PRs
had also touched the conflicting lines. The HTTP API code added
since the deletion (compression, cors-vary, fence, lifecycle log,
account error mapping, etc.) is preserved as-is — those still apply
on the HTTP API path for users on dev/beta channels.
2026-05-09 13:37:36 -04:00
352 changed files with 12526 additions and 9018 deletions
-1
View File
@@ -9,7 +9,6 @@
### General Principles ### General Principles
- Keep things in one function unless composable or reusable - Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Avoid `try`/`catch` where possible - Avoid `try`/`catch` where possible
- Avoid using the `any` type - Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()` - Use Bun APIs when possible, like `Bun.file()`
+27 -21
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -147,7 +147,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -171,7 +171,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -195,7 +195,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.14.46", "version": "1.14.42",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -229,7 +229,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -283,7 +283,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -329,7 +329,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -342,7 +342,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.14.46", "version": "1.14.42",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -393,6 +393,10 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
@@ -412,7 +416,6 @@
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2", "@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
@@ -434,6 +437,8 @@
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
@@ -460,6 +465,7 @@
"xdg-basedir": "5.1.0", "xdg-basedir": "5.1.0",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "catalog:", "zod": "catalog:",
"zod-to-json-schema": "3.24.5",
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
@@ -492,11 +498,12 @@
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5",
}, },
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -534,7 +541,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -549,7 +556,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -584,7 +591,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -633,7 +640,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -678,7 +685,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
}, },
"overrides": { "overrides": {
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -1233,6 +1239,8 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -2037,8 +2045,6 @@
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", "x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", "aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", "aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" "x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
} }
} }
-1
View File
@@ -133,7 +133,6 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch" "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.46", "version": "1.14.42",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -28,12 +28,6 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
canReview: () => boolean canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[] diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
@@ -76,8 +70,7 @@ export function SessionSidePanel(props: {
}) })
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => { const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b if (!a) return b
@@ -88,7 +81,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) { for (const diff of props.diffs()) {
const file = normalize(diff.file) const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.46", "version": "1.14.42",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -67,7 +67,6 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
padding: 0; padding: 0;
background: transparent; background: transparent;
border: none; border: none;
@@ -80,7 +79,6 @@
} }
svg { svg {
flex-shrink: 0;
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
@@ -53,7 +53,7 @@ export function UsageSection() {
} }
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => { const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.outputTokens return u.outputTokens + (u.reasoningTokens ?? 0)
} }
const goPrev = async () => { const goPrev = async () => {
@@ -889,6 +889,10 @@ export async function handler(
const inputCost = modelCost.input * inputTokens * 100 const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100 const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => { const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined if (!modelCost.cacheRead) return undefined
@@ -905,11 +909,17 @@ export async function handler(
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100 return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})() })()
const totalCostInCent = const totalCostInCent =
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0) inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
return { return {
totalCostInCent, totalCostInCent,
inputCost, inputCost,
outputCost, outputCost,
reasoningCost,
cacheReadCost, cacheReadCost,
cacheWrite5mCost, cacheWrite5mCost,
cacheWrite1hCost, cacheWrite1hCost,
@@ -931,7 +941,8 @@ export async function handler(
) { ) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
usageInfo usageInfo
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
costInfo
logger.metric({ logger.metric({
"tokens.input": inputTokens, "tokens.input": inputTokens,
@@ -942,12 +953,14 @@ export async function handler(
"tokens.cache_write_1h": cacheWrite1hTokens, "tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input.microcents": centsToMicroCents(inputCost), "cost.input.microcents": centsToMicroCents(inputCost),
"cost.output.microcents": centsToMicroCents(outputCost), "cost.output.microcents": centsToMicroCents(outputCost),
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
"cost.total.microcents": centsToMicroCents(totalCostInCent), "cost.total.microcents": centsToMicroCents(totalCostInCent),
// deprecated - remove after May 20, 2026 // deprecated - remove after May 20, 2026
"cost.input": Math.round(inputCost), "cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost), "cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined, "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined, "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined, "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
return { return {
inputTokens: inputTokens - (cacheReadTokens ?? 0), inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens, outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens, reasoningTokens,
cacheReadTokens, cacheReadTokens,
cacheWrite5mTokens: undefined, cacheWrite5mTokens: undefined,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.14.46", "version": "1.14.42",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.46", "version": "1.14.42",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.46", "version": "1.14.42",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46", "version": "1.14.42",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+11 -9
View File
@@ -11,12 +11,9 @@ function falsy(key: string) {
return value === "false" || value === "0" return value === "false" || value === "0"
} }
// Channels where new experiments default to ON (unstable / internal users). // Channels that default to the new effect-httpapi server backend. The legacy
// Stable channels (`prod`, `latest`) stay opt-in. // hono backend remains the default for stable (`prod`/`latest`) installs.
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
function unstableDefault(key: string) {
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
}
function number(key: string) { function number(key: string) {
const value = process.env[key] const value = process.env[key]
@@ -56,9 +53,6 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
// Default-on for dev/beta/local; opt-in for stable. Set
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
@@ -94,6 +88,14 @@ export const Flag = {
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
// Defaults to true on dev/beta/local channels so internal users exercise the
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
// on the legacy hono backend until the rollout is complete. An explicit env
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
// stable users and an escape hatch for dev/beta users.
OPENCODE_EXPERIMENTAL_HTTPAPI:
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.14.46", "version": "1.14.42",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.46", "version": "1.14.42",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.14.46" version = "1.14.42"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.46", "version": "1.14.42",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
-214
View File
@@ -1,214 +0,0 @@
# @opencode-ai/http-recorder
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
exercise real request shapes against deterministic, version-controlled
cassettes — no manual mocks, no flakes from upstream drift.
## Install
Internal package; depended on as `@opencode-ai/http-recorder` from another
workspace package.
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
```
## Quickstart
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
By default the layer records on first run and replays on subsequent runs —
no env-var ternary at the call site, and `CI=true` forces strict replay so
missing cassettes fail loudly in CI rather than silently re-recording.
```ts
import { Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpRecorder } from "@opencode-ai/http-recorder"
const program = Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
return yield* response.json
})
// Records if the cassette is missing, replays if it exists.
// In CI (CI=true) always replays — fails loudly on missing fixtures.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
// Force a refresh — always hits upstream and overwrites.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
```
## Modes
| Mode | Behavior |
| ------------- | ----------------------------------------------------------------------------------- |
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
| `replay` | Strict — match the request to a recorded interaction; error if none. |
| `record` | Execute upstream, append the interaction, write the cassette. |
| `passthrough` | Bypass the recorder entirely — just call upstream. |
## Cassette format
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
```json
{
"version": 1,
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
"interactions": [
{
"transport": "http",
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
"response": { "status": 200, "headers": {...}, "body": "..." }
}
]
}
```
Cassettes are normal source files — review them, diff them, commit them.
## Request matching
By default, requests match on canonicalized method, URL, headers, and JSON
body (object keys sorted). Two dispatch strategies are available:
- **`match`** (default) — find the first recorded interaction whose request
matches the incoming request. Same request twice returns the same response.
- **`sequential`** — return interactions in the order they were recorded,
validating each one matches as the cursor advances. Use for ordered flows
where the same URL is hit multiple times with meaningful state changes
(pagination, retries, polling).
```ts
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
```
Supply your own matcher via `match: (incoming, recorded) => boolean` for
custom equivalence (e.g. ignoring a timestamp field in the body).
## Redaction & secret safety
Cassettes get checked in, so the recorder is aggressive about not letting
secrets escape. Redaction is configured by composing a `Redactor`:
```ts
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
HttpRecorder.cassetteLayer("anthropic/messages", {
redactor: Redactor.defaults({
requestHeaders: { allow: ["content-type", "anthropic-version"] },
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
}),
})
```
`Redactor.defaults({ … })` composes the four built-in redactors with your
overrides. For full control, build the stack yourself:
```ts
const redactor = Redactor.compose(
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
Redactor.responseHeaders(),
Redactor.url({ query: ["session-id"] }),
Redactor.body((parsed) => /* … */),
)
```
What each layer does:
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
allow-list (request default: `content-type`, `accept`, `openai-beta`;
response default: `content-type`). Sensitive headers within the
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
…) are replaced with `[REDACTED]`.
- **`url`** — query parameters matching common secret names (`api_key`,
`token`, `signature`, AWS signing params, …) are replaced with
`[REDACTED]`. URL user/password are replaced. `transform` runs after
built-in redaction for path-level scrubbing.
- **`body`** — receives the parsed JSON request body and returns a redacted
version. No-op for non-JSON bodies.
After assembling the cassette, the recorder scans every string for known
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
environment variable named like a credential. If anything is found, the
cassette is **not written** and the request fails with `UnsafeCassetteError`
listing what was detected.
## WebSocket recording
WebSocket support records the open frame plus client/server message
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
can live in the same cassette.
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "ws/subscribe",
cassette,
live: liveExecutor,
})
// use executor.open(...)
})
```
## Inspecting cassettes programmatically
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
returns the recorded interactions for a name; the file format is hidden
behind the seam. Useful for CI checks:
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const entries = yield* cassettes.list()
const issues = yield* Effect.forEach(entries, (entry) =>
cassettes
.read(entry.name)
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
)
return issues.filter((i) => i.findings.length > 0)
})
```
`cassetteLayer` is the batteries-included entry point — it provides
`Cassette.fileSystem({ directory })` automatically. If you want to provide
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
`Cassette.memory` yourself.
## Options reference
```ts
type RecordReplayOptions = {
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
directory?: string // default: <cwd>/test/fixtures/recordings
metadata?: Record<string, unknown> // merged into cassette.metadata
redactor?: Redactor // default: Redactor.defaults()
dispatch?: "match" | "sequential" // default: "match"
match?: (incoming, recorded) => boolean // custom matcher
}
```
## Layout
| File | Purpose |
| -------------- | -------------------------------------------------------------------------------- |
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46", "version": "1.14.42",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+72 -114
View File
@@ -1,76 +1,54 @@
import { Context, Effect, FileSystem, Layer, Schema } from "effect" import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
import * as fs from "node:fs"
import * as path from "node:path" import * as path from "node:path"
import { secretFindings, type SecretFinding } from "./redaction" import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") export interface Entry {
readonly name: string
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", { readonly path: string
cassetteName: Schema.String,
}) {
override get message() {
return `Cassette "${this.cassetteName}" not found`
}
}
export interface AppendResult {
readonly findings: ReadonlyArray<SecretFinding>
} }
export interface Interface { export interface Interface {
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError> readonly path: (name: string) => string
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult> readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean> readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>> readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {} export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => export const layer = (options: { readonly directory?: string } = {}) =>
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
const buildCassette = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
interactions,
})
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const fileSystem = (
options: { readonly directory?: string } = {},
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fileSystem = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>() const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
const directoriesEnsured = new Set<string>()
const cassettePath = (name: string) => path.join(directory, `${name}.json`) const pathFor = (name: string) => cassettePath(name, directory)
const ensureDirectory = (name: string) => const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () { Effect.gen(function* () {
const dir = path.dirname(cassettePath(name)) const entries = yield* fileSystem
if (directoriesEnsured.has(dir)) return .readDirectory(directory)
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) .pipe(Effect.catch(() => Effect.succeed([] as string[])))
directoriesEnsured.add(dir)
})
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => { const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(current, entry) const full = path.join(directory, entry)
return fs.stat(full).pipe( return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])), Effect.catch(() => Effect.succeed([] as string[])),
) )
@@ -78,73 +56,53 @@ export const fileSystem = (
return nested.flat() return nested.flat()
}) })
return Service.of({ const read = Effect.fn("Cassette.read")(function* (name: string) {
read: (name) => return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
fs.readFileString(cassettePath(name)).pipe( })
Effect.map((raw) => parseCassette(raw).interactions),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
), yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
append: (name, interaction, metadata) => yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
Effect.gen(function* () { })
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
if (!recorded.has(name)) recorded.set(name, entry) const append = Effect.fn("Cassette.append")(function* (
entry.interactions.push(interaction) name: string,
entry.findings.push(...secretFindings(interaction)) interaction: Interaction,
const cassette = buildCassette(name, entry.interactions, metadata) metadata: CassetteMetadata | undefined,
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] ) {
if (findings.length === 0) { const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
yield* ensureDirectory(name) new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie) )
} const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
return { findings } const findings = cassetteSecretFindings(cassette)
}), if (findings.length === 0) yield* write(name, cassette)
exists: (name) => return { cassette, findings }
fs.access(cassettePath(name)).pipe( })
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true), Effect.as(true),
Effect.catch(() => Effect.succeed(false)), Effect.catch(() => Effect.succeed(false)),
), )
list: () => })
walk(directory).pipe(
Effect.map((files) => const list = Effect.fn("Cassette.list")(function* () {
files return (yield* walk(directory))
.filter((file) => file.endsWith(".json")) .filter((file) => file.endsWith(".json"))
.map((file) => .map((file) => ({
path name: path
.relative(directory, file) .relative(directory, file)
.replace(/\\/g, "/") .replace(/\\/g, "/")
.replace(/\.json$/, ""), .replace(/\.json$/, ""),
) path: file,
.toSorted((a, b) => a.localeCompare(b)), }))
), .toSorted((a, b) => a.name.localeCompare(b.name))
),
}) })
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}), }),
) )
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> => export const defaultLayer = layer()
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
return Service.of({ export * as Cassette from "./cassette"
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) =>
Effect.sync(() => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const findings = accumulatedFindings.get(name)
if (findings) findings.push(...secretFindings(interaction))
else accumulatedFindings.set(name, [...secretFindings(interaction)])
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
return { findings: accumulatedFindings.get(name) ?? [] }
}),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
})
+95
View File
@@ -0,0 +1,95 @@
import { Option } from "effect"
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
import { decodeJson } from "./matching"
import { REDACTED, redactUrl, secretFindings } from "./redaction"
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (
expected &&
received &&
typeof expected === "object" &&
typeof received === "object" &&
!Array.isArray(expected) &&
!Array.isArray(received)
) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) =>
valueDiffs(
(expected as Record<string, unknown>)[key],
(received as Record<string, unknown>)[key],
`${base}.${key}`,
limit,
),
)
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
const lines = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
const interactions = httpInteractions(cassette)
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
+127 -60
View File
@@ -1,37 +1,62 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect" import { Effect, Layer, Option, Ref } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers,
HttpBody,
HttpClient, HttpClient,
HttpClientError, HttpClientError,
HttpClientRequest, HttpClientRequest,
HttpClientResponse, HttpClientResponse,
UrlParams,
} from "effect/unstable/http" } from "effect/unstable/http"
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import {
httpInteractions,
type Cassette,
type CassetteMetadata,
type HttpInteraction,
type ResponseSnapshot,
} from "./schema"
import * as CassetteService from "./cassette" import * as CassetteService from "./cassette"
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { redactUrl } from "./redaction"
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
export type RecordReplayMode = "record" | "replay" | "passthrough"
export interface RecordReplayOptions { export interface RecordReplayOptions {
readonly mode?: RecordReplayMode readonly mode?: RecordReplayMode
readonly directory?: string readonly directory?: string
readonly metadata?: CassetteMetadata readonly metadata?: CassetteMetadata
readonly redactor?: Redactor readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly responseHeaders?: ReadonlyArray<string>
readonly redactBody?: (body: unknown) => unknown
readonly dispatch?: "match" | "sequential" readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher readonly match?: RequestMatcher
} }
const responseHeaders = (
response: HttpClientResponse.HttpClientResponse,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> | undefined,
) => {
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
return merged
}
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"] const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
const isBinaryContentType = (contentType: string | undefined) => const isBinaryContentType = (contentType: string | undefined) => {
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token)) if (!contentType) return false
const lower = contentType.toLowerCase()
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
isBinaryContentType(contentType) isBinaryContentType(contentType)
@@ -43,19 +68,34 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
const decodeResponseBody = (snapshot: ResponseSnapshot) => const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
new HttpClientError.HttpClientError({ new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
}),
})
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
}),
})
const unsafeCassette = (
request: HttpClientRequest.HttpClientRequest,
name: string,
findings: ReadonlyArray<SecretFinding>,
) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
}),
}) })
export const recordingLayer = ( export const recordingLayer = (
@@ -67,22 +107,61 @@ export const recordingLayer = (
Effect.gen(function* () { Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.Service const cassetteService = yield* CassetteService.Service
const redactor = options.redactor ?? defaults() const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
const match = options.match ?? defaultMatcher const match = options.match ?? defaultMatcher
const requested = options.mode ?? "auto" const mode = options.mode ?? "replay"
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
const sequential = options.dispatch === "sequential" const sequential = options.dispatch === "sequential"
const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const replay = yield* Ref.make<Cassette | undefined>(undefined)
const cursor = yield* Ref.make(0)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () { Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
return redactor.request({ const raw = yield* Effect.promise(() => web.text())
method: web.method, const body = options.redactBody
url: web.url, ? Option.match(decodeJson(raw), {
headers: Object.fromEntries(web.headers.entries()), onNone: () => raw,
body: yield* Effect.promise(() => web.text()), onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
}) })
: raw
return {
method: web.method,
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
Object.fromEntries(web.headers.entries()),
requestHeadersAllow,
options.redact?.headers,
),
body,
}
})
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
Effect.gen(function* () {
const interactions = httpInteractions(cassette)
if (sequential) {
const index = yield* Ref.get(cursor)
const interaction = interactions[index]
if (!interaction)
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request)) {
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
}
yield* Ref.update(cursor, (n) => n + 1)
return { interaction, detail: "" }
}
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
})
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
yield* Ref.set(replay, cassette)
return cassette
}) })
return HttpClient.make((request) => { return HttpClient.make((request) => {
@@ -90,21 +169,18 @@ export const recordingLayer = (
if (mode === "record") { if (mode === "record") {
return Effect.gen(function* () { return Effect.gen(function* () {
const incoming = yield* snapshotRequest(request) const currentRequest = yield* snapshotRequest(request)
const response = yield* upstream.execute(request) const response = yield* upstream.execute(request)
const captured = yield* captureResponseBody(response, response.headers["content-type"]) const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
const captured = yield* captureResponseBody(response, headers["content-type"])
const interaction: HttpInteraction = { const interaction: HttpInteraction = {
transport: "http", transport: "http",
request: incoming, request: currentRequest,
response: redactor.response({ response: { status: response.status, headers, ...captured },
status: response.status,
headers: response.headers as Record<string, string>,
...captured,
}),
} }
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe( const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))), const findings = result.findings
) if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
return HttpClientResponse.fromWeb( return HttpClientResponse.fromWeb(
request, request,
new Response(decodeResponseBody(interaction.response), interaction.response), new Response(decodeResponseBody(interaction.response), interaction.response),
@@ -113,23 +189,14 @@ export const recordingLayer = (
} }
return Effect.gen(function* () { return Effect.gen(function* () {
const cassette = yield* loadReplay(request)
const incoming = yield* snapshotRequest(request) const incoming = yield* snapshotRequest(request)
const interactions = yield* replay.load.pipe( const { interaction, detail } = yield* selectInteraction(cassette, incoming)
Effect.mapError(() => if (!interaction) return yield* fixtureMismatch(request, name, detail)
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
),
)
const result = sequential
? selectSequential(interactions, incoming, match, yield* replay.cursor)
: selectMatch(interactions, incoming, match)
if (!result.interaction)
return yield* Effect.fail(
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
)
if (sequential) yield* replay.advance
return HttpClientResponse.fromWeb( return HttpClientResponse.fromWeb(
request, request,
new Response(decodeResponseBody(result.interaction.response), result.interaction.response), new Response(decodeResponseBody(interaction.response), interaction.response),
) )
}) })
}) })
@@ -138,7 +205,7 @@ export const recordingLayer = (
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> => export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe( recordingLayer(name, options).pipe(
Layer.provide(CassetteService.fileSystem({ directory: options.directory })), Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
) )
+7 -23
View File
@@ -1,26 +1,10 @@
export type { export * from "./schema"
CassetteMetadata, export * from "./redaction"
HttpInteraction, export * from "./matching"
Interaction, export * from "./diff"
RequestSnapshot, export * from "./storage"
ResponseSnapshot, export * from "./websocket"
WebSocketFrame, export * from "./effect"
WebSocketInteraction,
} from "./schema"
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
export { defaultMatcher, type RequestMatcher } from "./matching"
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
export { UnsafeCassetteError } from "./recorder"
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
export {
makeWebSocketExecutor,
type WebSocketConnection,
type WebSocketExecutor,
type WebSocketRecordReplayOptions,
type WebSocketRequest,
} from "./websocket"
export * as Cassette from "./cassette" export * as Cassette from "./cassette"
export * as Redactor from "./redactor"
export * as HttpRecorder from "." export * as HttpRecorder from "."
+1 -89
View File
@@ -1,6 +1,5 @@
import { Option, Schema } from "effect" import { Option, Schema } from "effect"
import { REDACTED, secretFindings } from "./redaction" import type { RequestSnapshot } from "./schema"
import type { HttpInteraction, RequestSnapshot } from "./schema"
const JsonValue = Schema.fromJsonString(Schema.Unknown) const JsonValue = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownOption(JsonValue) export const decodeJson = Schema.decodeUnknownOption(JsonValue)
@@ -35,90 +34,3 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
export const defaultMatcher: RequestMatcher = (incoming, recorded) => export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded) canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (isRecord(expected) && isRecord(received)) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
const lines: string[] = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const selectMatch = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
}
export const selectSequential = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
index: number,
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
const interaction = interactions[index]
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request))
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
return { interaction, detail: "" }
}
-73
View File
@@ -1,73 +0,0 @@
import { Effect, Ref, Schema, Scope } from "effect"
import type * as CassetteService from "./cassette"
import type { CassetteNotFoundError } from "./cassette"
import { SecretFindingSchema } from "./redaction"
import type { CassetteMetadata, Interaction } from "./schema"
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
}
}
export type ResolvedMode = "record" | "replay" | "passthrough"
const isCI = () => {
const value = process.env.CI
return value !== undefined && value !== "" && value !== "false" && value !== "0"
}
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
Effect.gen(function* () {
if (isCI()) return "replay"
return (yield* cassette.exists(name)) ? "replay" : "record"
})
export const appendOrFail = (
cassette: CassetteService.Interface,
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
): Effect.Effect<void, UnsafeCassetteError> =>
cassette
.append(name, interaction, metadata)
.pipe(
Effect.flatMap(({ findings }) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
),
)
export interface ReplayState<T> {
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
readonly cursor: Effect.Effect<number>
readonly advance: Effect.Effect<void>
}
export const makeReplayState = <T>(
cassette: CassetteService.Interface,
name: string,
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
Effect.gen(function* () {
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
const position = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const used = yield* Ref.get(position)
if (used === 0) return
const interactions = yield* load.pipe(Effect.orDie)
if (used < interactions.length)
yield* Effect.die(
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
)
}),
)
return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) }
})
+8 -7
View File
@@ -1,3 +1,5 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]" export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [ const DEFAULT_REDACT_HEADERS = [
@@ -95,13 +97,10 @@ export const redactHeaders = (
) )
} }
import { Schema } from "effect" export type SecretFinding = {
readonly path: string
export const SecretFindingSchema = Schema.Struct({ readonly reason: string
path: Schema.String, }
reason: Schema.String,
})
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [ stringEntries(value).flatMap((entry) => [
@@ -113,3 +112,5 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
.filter((item) => entry.value.includes(item.value)) .filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
]) ])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
-76
View File
@@ -1,76 +0,0 @@
import { Option } from "effect"
import { decodeJson } from "./matching"
import { redactHeaders, redactUrl } from "./redaction"
import type { RequestSnapshot, ResponseSnapshot } from "./schema"
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
const identity = <T>(value: T) => value
export interface Redactor {
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
}
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
return {
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
}
}
export interface HeaderOptions {
readonly allow?: ReadonlyArray<string>
readonly redact?: ReadonlyArray<string>
}
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
request: (snapshot) => ({
...snapshot,
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
}),
})
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
response: (snapshot) => ({
...snapshot,
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
}),
})
export interface UrlOptions {
readonly query?: ReadonlyArray<string>
readonly transform?: (url: string) => string
}
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
})
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
request: (snapshot) => ({
...snapshot,
body: Option.match(decodeJson(snapshot.body), {
onNone: () => snapshot.body,
onSome: (parsed) => JSON.stringify(transform(parsed)),
}),
}),
})
export interface DefaultRedactorOverrides {
readonly requestHeaders?: HeaderOptions
readonly responseHeaders?: HeaderOptions
readonly url?: UrlOptions
readonly body?: (parsed: unknown) => unknown
}
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
compose(
requestHeaders(overrides.requestHeaders),
responseHeaders(overrides.responseHeaders),
url(overrides.url),
...(overrides.body ? [body(overrides.body)] : []),
)
+2 -3
View File
@@ -52,10 +52,9 @@ export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction) export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) => export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({ export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1), version: Schema.Literal(1),
+34
View File
@@ -0,0 +1,34 @@
import { Option } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
name,
recordedAt: new Date().toISOString(),
...(metadata ?? {}),
})
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: metadataFor(name, metadata),
interactions,
})
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
const file = cassettePath(name, options.directory)
if (!fs.existsSync(file)) return false
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
}
+96 -51
View File
@@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http" import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette" import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching" import { canonicalizeJson, decodeJson } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import type { RecordReplayMode } from "./effect" import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export interface WebSocketRequest { export interface WebSocketRequest {
readonly url: string readonly url: string
@@ -24,36 +24,67 @@ export interface WebSocketExecutor<E> {
export interface WebSocketRecordReplayOptions<E> { export interface WebSocketRecordReplayOptions<E> {
readonly name: string readonly name: string
readonly mode?: RecordReplayMode readonly mode?: "record" | "replay" | "passthrough"
readonly metadata?: CassetteMetadata readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E> readonly live: WebSocketExecutor<E>
readonly redactor?: Redactor readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly compareClientMessagesAsJson?: boolean readonly compareClientMessagesAsJson?: boolean
} }
const headersRecord = (headers: Headers.Headers): Record<string, string> => const headersRecord = (headers: Headers.Headers) =>
Object.fromEntries( Object.fromEntries(
Object.entries(headers as Record<string, unknown>).filter( Object.entries(headers as Record<string, unknown>)
(entry): entry is [string, string] => typeof entry[1] === "string", .filter((entry): entry is [string, string] => typeof entry[1] === "string")
), .toSorted(([a], [b]) => a.localeCompare(b)),
) )
const encodeFrame = (message: string | Uint8Array): WebSocketFrame => const openSnapshot = (
typeof message === "string" request: WebSocketRequest,
? { kind: "text", body: message } options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } ) => ({
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
headersRecord(request.headers),
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
options.redact?.headers,
),
})
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array => const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
const frameText = (frame: WebSocketFrame) => {
if (frame.kind === "text") return frame.body
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
}
const frameMessage = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const decodeFrameText = (frame: WebSocketFrame) => const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64")) typeof message === "string"
? textFrame(message)
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
new Error(
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
)
const mismatch = (message: string, actual: unknown, expected: unknown) =>
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
const assertEqual = (message: string, actual: unknown, expected: unknown) => const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => { Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) throw mismatch(message, actual, expected)
}) })
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
@@ -63,7 +94,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
return Effect.sync(() => { return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`) throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
}) })
const expectedText = decodeFrameText(expected) const expectedText = frameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText) if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText)) return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
} }
@@ -72,18 +103,7 @@ export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>, options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> => ): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () { Effect.gen(function* () {
const requested = options.mode ?? "auto" const mode = options.mode ?? "replay"
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
const redactor = options.redactor ?? defaults()
const openSnapshot = (request: WebSocketRequest) => {
const redacted = redactor.request({
method: "GET",
url: request.url,
headers: headersRecord(request.headers),
body: "",
})
return { url: redacted.url, headers: redacted.headers }
}
if (mode === "passthrough") return options.live if (mode === "passthrough") return options.live
@@ -98,21 +118,21 @@ export const makeWebSocketExecutor = <E>(
const closeOnce = Effect.gen(function* () { const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close yield* connection.close
yield* appendOrFail( const result = yield* options.cassette
options.cassette, .append(
options.name, options.name,
{ transport: "websocket", open: openSnapshot(request), client, server }, { transport: "websocket", open: openSnapshot(request, options), client, server },
options.metadata, options.metadata,
).pipe(Effect.orDie) )
.pipe(Effect.orDie)
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
}) })
return { return {
sendText: (message) => sendText: (message: string) =>
connection connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
.sendText(message)
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
messages: connection.messages.pipe( messages: connection.messages.pipe(
Stream.map((message) => { Stream.map((message) => {
server.push(encodeFrame(message)) server.push(receivedFrame(message))
return message return message
}), }),
), ),
@@ -122,20 +142,44 @@ export const makeWebSocketExecutor = <E>(
} }
} }
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
undefined,
)
const cursor = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const input = yield* Ref.get(replay)
if (!input) return
yield* assertEqual(
`Unused recorded WebSocket interactions in ${options.name}`,
yield* Ref.get(cursor),
input.interactions.length,
)
}),
)
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const input = {
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
}
yield* Ref.set(replay, input)
return input
})
return { return {
open: (request) => open: (request) => {
Effect.gen(function* () { return Effect.gen(function* () {
const interactions = yield* replay.load.pipe(Effect.orDie) const input = yield* loadReplay()
const index = yield* replay.cursor const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
const interaction = interactions[index] const interaction = input.interactions[index]
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`)) if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* replay.advance yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
const messageIndex = yield* Ref.make(0) const messageIndex = yield* Ref.make(0)
return { return {
sendText: (message) => sendText: (message: string) =>
Effect.gen(function* () { Effect.gen(function* () {
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1) const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage( yield* compareClientMessage(
@@ -145,7 +189,7 @@ export const makeWebSocketExecutor = <E>(
options.compareClientMessagesAsJson === true, options.compareClientMessagesAsJson === true,
) )
}), }),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)), messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
close: Effect.gen(function* () { close: Effect.gen(function* () {
yield* assertEqual( yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`, `WebSocket client frame count for interaction ${index + 1}`,
@@ -154,6 +198,7 @@ export const makeWebSocketExecutor = <E>(
) )
}), }),
} }
}), })
},
} }
}) })
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
@@ -6,16 +6,7 @@ import * as fs from "node:fs"
import * as os from "node:os" import * as os from "node:os"
import * as path from "node:path" import * as path from "node:path"
import { HttpRecorder } from "../src" import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/effect" import { redactedErrorRequest } from "../src/diff"
import type { Interaction } from "../src/schema"
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
Effect.runPromise(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
)
const post = (url: string, body: object) => const post = (url: string, body: object) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -42,7 +33,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
Effect.scoped( Effect.scoped(
effect.pipe( effect.pipe(
Effect.provide( Effect.provide(
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }), HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
), ),
Effect.provide(NodeFileSystem.layer), Effect.provide(NodeFileSystem.layer),
), ),
@@ -117,7 +108,7 @@ describe("http-recorder", () => {
test("detects secret-looking values without returning the secret", () => { test("detects secret-looking values without returning the secret", () => {
expect( expect(
HttpRecorder.secretFindings({ HttpRecorder.cassetteSecretFindings({
version: 1, version: 1,
interactions: [ interactions: [
{ {
@@ -145,7 +136,7 @@ describe("http-recorder", () => {
test("detects secret-looking values inside metadata", () => { test("detects secret-looking values inside metadata", () => {
expect( expect(
HttpRecorder.secretFindings({ HttpRecorder.cassetteSecretFindings({
version: 1, version: 1,
metadata: { token: "sk-123456789012345678901234" }, metadata: { token: "sk-123456789012345678901234" },
interactions: [], interactions: [],
@@ -153,11 +144,43 @@ describe("http-recorder", () => {
).toEqual([{ path: "metadata.token", reason: "API key" }]) ).toEqual([{ path: "metadata.token", reason: "API key" }])
}) })
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => { test("formats websocket cassettes with shared metadata", () => {
await Effect.runPromise( const cassette = HttpRecorder.cassetteFor(
Effect.scoped( "websocket/basic",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () { Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
HttpRecorder.cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({ const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay", name: "websocket/replay",
cassette, cassette,
@@ -174,21 +197,7 @@ describe("http-recorder", () => {
yield* connection.close yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}).pipe(
Effect.provide(
HttpRecorder.Cassette.memory({
"websocket/replay": [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
}), }),
),
),
),
) )
}) })
@@ -218,14 +227,17 @@ describe("http-recorder", () => {
yield* connection.messages.pipe(Stream.runDrain) yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject([ expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{ {
transport: "websocket", transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
}, },
]) ],
})
}), }),
) )
}) })
@@ -288,49 +300,6 @@ describe("http-recorder", () => {
) )
}) })
test("auto mode replays when the cassette exists", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
await seedCassetteDirectory(directory, "auto-replay", [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/echo",
headers: { "content-type": "application/json" },
body: JSON.stringify({ step: 1 }),
},
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
},
])
const result = await runWith(
"auto-replay",
{ directory, mode: "auto" },
post("https://example.test/echo", { step: 1 }),
)
expect(result).toBe('{"reply":"hi"}')
})
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
const previous = process.env.CI
process.env.CI = "true"
try {
const exit = await Effect.runPromise(
Effect.exit(
post("https://example.test/echo", { step: 1 }).pipe(
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
} finally {
if (previous === undefined) delete process.env.CI
else process.env.CI = previous
}
})
test("mismatch diagnostics show closest redacted request differences", async () => { test("mismatch diagnostics show closest redacted request differences", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
+1 -1
View File
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
stream: { stream: {
event: Schema.String, event: Schema.String,
initial: () => undefined, initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
onHalt: () => [{ type: "request-finish", reason: "stop" }], onHalt: () => [{ type: "request-finish", reason: "stop" }],
}, },
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46", "version": "1.14.42",
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMEvent,
Usage, Usage,
type CacheHint, type CacheHint,
type FinishReason, type FinishReason,
type LLMEvent,
type LLMRequest, type LLMRequest,
type ProviderMetadata, type ProviderMetadata,
type ToolCallPart, type ToolCallPart,
@@ -415,13 +415,14 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
? String((block.content as Record<string, unknown>).type) ? String((block.content as Record<string, unknown>).type)
: "" : ""
const isError = errorPayload.endsWith("_tool_result_error") const isError = errorPayload.endsWith("_tool_result_error")
return LLMEvent.toolResult({ return {
type: "tool-result",
id: block.tool_use_id ?? "", id: block.tool_use_id ?? "",
name: SERVER_TOOL_RESULT_NAMES[block.type], name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true, providerExecuted: true,
providerMetadata: anthropicMetadata({ blockType: block.type }), providerMetadata: anthropicMetadata({ blockType: block.type }),
}) }
} }
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>] type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -452,17 +453,18 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
} }
if (block.type === "text" && block.text) { if (block.type === "text" && block.text) {
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] return [state, [{ type: "text-delta", text: block.text }]]
} }
if (block.type === "thinking" && block.thinking) { if (block.type === "thinking" && block.thinking) {
return [ return [
state, state,
[ [
LLMEvent.reasoningDelta({ {
id: `reasoning-${event.index ?? 0}`, type: "reasoning-delta",
text: block.thinking, text: block.thinking,
}), ...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
},
], ],
] ]
} }
@@ -478,25 +480,17 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
const delta = event.delta const delta = event.delta
if (delta?.type === "text_delta" && delta.text) { if (delta?.type === "text_delta" && delta.text) {
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
} }
if (delta?.type === "thinking_delta" && delta.thinking) { if (delta?.type === "thinking_delta" && delta.thinking) {
return [ return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
state,
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
] satisfies StepResult
} }
if (delta?.type === "signature_delta" && delta.signature) { if (delta?.type === "signature_delta" && delta.signature) {
return [ return [
state, state,
[ [{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
LLMEvent.reasoningEnd({
id: `reasoning-${event.index ?? 0}`,
providerMetadata: anthropicMetadata({ signature: delta.signature }),
}),
],
] satisfies StepResult ] satisfies StepResult
} }
@@ -530,20 +524,21 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
return [ return [
{ ...state, usage }, { ...state, usage },
[ [
LLMEvent.requestFinish({ {
type: "request-finish",
reason: mapFinishReason(event.delta?.stop_reason), reason: mapFinishReason(event.delta?.stop_reason),
usage, usage,
providerMetadata: event.delta?.stop_sequence ...(event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) ? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
: undefined, : {}),
}), },
], ],
] ]
} }
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state, state,
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })], [{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
] ]
const step = (state: ParserState, event: AnthropicEvent) => { const step = (state: ParserState, event: AnthropicEvent) => {
+9 -19
View File
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMEvent,
Usage, Usage,
type CacheHint, type CacheHint,
type FinishReason, type FinishReason,
type LLMEvent,
type LLMRequest, type LLMRequest,
type ToolCallPart, type ToolCallPart,
type ToolDefinition, type ToolDefinition,
@@ -400,26 +400,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
} }
if (event.contentBlockDelta?.delta?.text) { if (event.contentBlockDelta?.delta?.text) {
return [ return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
state,
[
LLMEvent.textDelta({
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
text: event.contentBlockDelta.delta.text,
}),
],
] as const
} }
if (event.contentBlockDelta?.delta?.reasoningContent?.text) { if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
return [ return [
state, state,
[ [{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
LLMEvent.reasoningDelta({
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
text: event.contentBlockDelta.delta.reasoningContent.text,
}),
],
] as const ] as const
} }
@@ -462,13 +449,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
event.modelStreamErrorException?.message ?? event.modelStreamErrorException?.message ??
event.serviceUnavailableException?.message ?? event.serviceUnavailableException?.message ??
"Bedrock Converse stream error" "Bedrock Converse stream error"
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
} }
if (event.validationException || event.throttlingException) { if (event.validationException || event.throttlingException) {
const message = const message =
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const return [
state,
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
] as const
} }
return [state, []] as const return [state, []] as const
@@ -478,7 +468,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.pendingFinish state.pendingFinish
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] ? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
: [] : []
// ============================================================================= // =============================================================================
+4 -8
View File
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type LLMEvent,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type TextPart, type TextPart,
@@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.finishReason || state.usage state.finishReason || state.usage
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] ? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
: [] : []
const step = (state: ParserState, event: GeminiEvent) => { const step = (state: ParserState, event: GeminiEvent) => {
@@ -332,18 +332,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
for (const part of candidate.content.parts) { for (const part of candidate.content.parts) {
if ("text" in part && part.text.length > 0) { if ("text" in part && part.text.length > 0) {
events.push( events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
part.thought
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
)
continue continue
} }
if ("functionCall" in part) { if ("functionCall" in part) {
const input = part.functionCall.args const input = part.functionCall.args
const id = `tool_${nextToolCallId++}` const id = `tool_${nextToolCallId++}`
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) events.push({ type: "tool-call", id, name: part.functionCall.name, input })
hasToolCalls = true hasToolCalls = true
} }
} }
+6 -3
View File
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type LLMEvent,
type LLMRequest, type LLMRequest,
type TextPart, type TextPart,
type ToolCallPart, type ToolCallPart,
@@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const toolDeltas = delta?.tool_calls ?? [] const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools let tools = state.tools
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) if (delta?.content) events.push({ type: "text-delta", text: delta.content })
for (const tool of toolDeltas) { for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart( const result = ToolStream.appendOrStart(
@@ -348,7 +348,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => { const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const hasToolCalls = state.toolCallEvents.length > 0 const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] return [
...state.toolCallEvents,
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
]
} }
// ============================================================================= // =============================================================================
+28 -14
View File
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
import { HttpTransport, WebSocketTransport } from "../route/transport" import { HttpTransport, WebSocketTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type LLMEvent,
type LLMRequest, type LLMRequest,
type ProviderMetadata, type ProviderMetadata,
type TextPart, type TextPart,
@@ -348,20 +348,22 @@ const hostedToolEvents = (
const tool = HOSTED_TOOLS[item.type] const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id }) const providerMetadata = openaiMetadata({ itemId: item.id })
return [ return [
LLMEvent.toolCall({ {
type: "tool-call",
id: item.id, id: item.id,
name: tool.name, name: tool.name,
input: tool.input(item), input: tool.input(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata,
}), },
LLMEvent.toolResult({ {
type: "tool-result",
id: item.id, id: item.id,
name: tool.name, name: tool.name,
result: hostedToolResult(item), result: hostedToolResult(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata,
}), },
] ]
} }
@@ -377,7 +379,17 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS] if (!event.delta) return [state, NO_EVENTS]
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] return [
state,
[
{
type: "text-delta",
id: event.item_id,
text: event.delta,
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
},
],
]
} }
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -446,28 +458,30 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state, state,
[ [
LLMEvent.requestFinish({ {
type: "request-finish",
reason: mapFinishReason(event, state.hasFunctionCall), reason: mapFinishReason(event, state.hasFunctionCall),
usage: mapUsage(event.response?.usage), usage: mapUsage(event.response?.usage),
providerMetadata: ...(event.response?.id || event.response?.service_tier
event.response?.id || event.response?.service_tier ? {
? openaiMetadata({ providerMetadata: openaiMetadata({
responseId: event.response.id, responseId: event.response.id,
serviceTier: event.response.service_tier, serviceTier: event.response.service_tier,
})
: undefined,
}), }),
}
: {}),
},
], ],
] ]
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state, state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })], [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
] ]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state, state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })], [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
] ]
const step = (state: ParserState, event: OpenAIResponsesEvent) => { const step = (state: ParserState, event: OpenAIResponsesEvent) => {
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@@ -49,24 +49,34 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
return next return next
} }
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
LLMEvent.toolInputDelta({ type: "tool-input-delta",
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
text, text,
}) ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
Effect.map( Effect.map(
(input): ToolCall => (input): ToolCall =>
LLMEvent.toolCall({ tool.providerExecuted
? {
type: "tool-call",
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
input, input,
providerExecuted: tool.providerExecuted ? true : undefined, providerExecuted: true,
providerMetadata: tool.providerMetadata, ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
}), }
: {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
},
), ),
) )
+26 -89
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
import { ModelRef } from "./options" import { ModelRef } from "./options"
import { ToolResultValue } from "./messages" import { ToolResultValue } from "./messages"
@@ -14,87 +14,60 @@ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
}) {} }) {}
export const RequestStart = Schema.Struct({ export const RequestStart = Schema.Struct({
type: Schema.tag("request-start"), type: Schema.Literal("request-start"),
id: ResponseID, id: Schema.String,
model: ModelRef, model: ModelRef,
}).annotate({ identifier: "LLM.Event.RequestStart" }) }).annotate({ identifier: "LLM.Event.RequestStart" })
export type RequestStart = Schema.Schema.Type<typeof RequestStart> export type RequestStart = Schema.Schema.Type<typeof RequestStart>
export const StepStart = Schema.Struct({ export const StepStart = Schema.Struct({
type: Schema.tag("step-start"), type: Schema.Literal("step-start"),
index: Schema.Number, index: Schema.Number,
}).annotate({ identifier: "LLM.Event.StepStart" }) }).annotate({ identifier: "LLM.Event.StepStart" })
export type StepStart = Schema.Schema.Type<typeof StepStart> export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({ export const TextStart = Schema.Struct({
type: Schema.tag("text-start"), type: Schema.Literal("text-start"),
id: ContentBlockID, id: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" }) }).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart> export type TextStart = Schema.Schema.Type<typeof TextStart>
export const TextDelta = Schema.Struct({ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"), type: Schema.Literal("text-delta"),
id: ContentBlockID, id: Schema.optional(Schema.String),
text: Schema.String, text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" }) }).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta> export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({ export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"), type: Schema.Literal("text-end"),
id: ContentBlockID, id: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" }) }).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd> export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"),
id: ContentBlockID,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
export const ReasoningDelta = Schema.Struct({ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"), type: Schema.Literal("reasoning-delta"),
id: ContentBlockID, id: Schema.optional(Schema.String),
text: Schema.String, text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) }).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta> export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({ export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"), type: Schema.Literal("tool-input-delta"),
id: ToolCallID, id: Schema.String,
name: Schema.String, name: Schema.String,
text: Schema.String, text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" }) }).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta> export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolCall = Schema.Struct({ export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"), type: Schema.Literal("tool-call"),
id: ToolCallID, id: Schema.String,
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -103,8 +76,8 @@ export const ToolCall = Schema.Struct({
export type ToolCall = Schema.Schema.Type<typeof ToolCall> export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({ export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"), type: Schema.Literal("tool-result"),
id: ToolCallID, id: Schema.String,
name: Schema.String, name: Schema.String,
result: ToolResultValue, result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -113,8 +86,8 @@ export const ToolResult = Schema.Struct({
export type ToolResult = Schema.Schema.Type<typeof ToolResult> export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({ export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"), type: Schema.Literal("tool-error"),
id: ToolCallID, id: Schema.String,
name: Schema.String, name: Schema.String,
message: Schema.String, message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
@@ -122,7 +95,7 @@ export const ToolError = Schema.Struct({
export type ToolError = Schema.Schema.Type<typeof ToolError> export type ToolError = Schema.Schema.Type<typeof ToolError>
export const StepFinish = Schema.Struct({ export const StepFinish = Schema.Struct({
type: Schema.tag("step-finish"), type: Schema.Literal("step-finish"),
index: Schema.Number, index: Schema.Number,
reason: FinishReason, reason: FinishReason,
usage: Schema.optional(Usage), usage: Schema.optional(Usage),
@@ -131,7 +104,7 @@ export const StepFinish = Schema.Struct({
export type StepFinish = Schema.Schema.Type<typeof StepFinish> export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const RequestFinish = Schema.Struct({ export const RequestFinish = Schema.Struct({
type: Schema.tag("request-finish"), type: Schema.Literal("request-finish"),
reason: FinishReason, reason: FinishReason,
usage: Schema.optional(Usage), usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
@@ -139,7 +112,7 @@ export const RequestFinish = Schema.Struct({
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish> export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
export const ProviderErrorEvent = Schema.Struct({ export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"), type: Schema.Literal("provider-error"),
message: Schema.String, message: Schema.String,
retryable: Schema.optional(Schema.Boolean), retryable: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
@@ -152,12 +125,8 @@ const llmEventTagged = Schema.Union([
TextStart, TextStart,
TextDelta, TextDelta,
TextEnd, TextEnd,
ReasoningStart,
ReasoningDelta, ReasoningDelta,
ReasoningEnd,
ToolInputStart,
ToolInputDelta, ToolInputDelta,
ToolInputEnd,
ToolCall, ToolCall,
ToolResult, ToolResult,
ToolError, ToolError,
@@ -166,52 +135,20 @@ const llmEventTagged = Schema.Union([
ProviderErrorEvent, ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type")) ]).pipe(Schema.toTaggedUnion("type"))
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
const responseID = (value: ResponseID | string) => ResponseID.make(value)
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
/** /**
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
* `events.filter(LLMEvent.guards["tool-call"])`. * `events.filter(LLMEvent.guards["tool-call"])`.
*/ */
export const LLMEvent = Object.assign(llmEventTagged, { export const LLMEvent = Object.assign(llmEventTagged, {
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
stepFinish: StepFinish.make,
requestFinish: RequestFinish.make,
providerError: ProviderErrorEvent.make,
is: { is: {
requestStart: llmEventTagged.guards["request-start"], requestStart: llmEventTagged.guards["request-start"],
stepStart: llmEventTagged.guards["step-start"], stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"], textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"], textDelta: llmEventTagged.guards["text-delta"],
textEnd: llmEventTagged.guards["text-end"], textEnd: llmEventTagged.guards["text-end"],
reasoningStart: llmEventTagged.guards["reasoning-start"],
reasoningDelta: llmEventTagged.guards["reasoning-delta"], reasoningDelta: llmEventTagged.guards["reasoning-delta"],
reasoningEnd: llmEventTagged.guards["reasoning-end"],
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"], toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
toolCall: llmEventTagged.guards["tool-call"], toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"], toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"], toolError: llmEventTagged.guards["tool-error"],
-9
View File
@@ -14,15 +14,6 @@ export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
export type ProviderID = typeof ProviderID.Type export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.Literals(ReasoningEfforts) export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort> export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
+6 -14
View File
@@ -4,7 +4,7 @@ import {
type ContentPart, type ContentPart,
type FinishReason, type FinishReason,
type LLMError, type LLMError,
LLMEvent, type LLMEvent,
LLMRequest, LLMRequest,
Message, Message,
type ProviderMetadata, type ProviderMetadata,
@@ -115,19 +115,11 @@ interface StepState {
const accumulate = (state: StepState, event: LLMEvent) => { const accumulate = (state: StepState, event: LLMEvent) => {
if (event.type === "text-delta") { if (event.type === "text-delta") {
appendStreamingText(state, "text", event.text, undefined) appendStreamingText(state, "text", event.text, event.providerMetadata)
return return
} }
if (event.type === "reasoning-delta") { if (event.type === "reasoning-delta") {
appendStreamingText(state, "reasoning", event.text, undefined) appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
return
}
if (event.type === "reasoning-end") {
appendStreamingText(state, "reasoning", "", event.providerMetadata)
return
}
if (event.type === "text-end") {
appendStreamingText(state, "text", "", event.providerMetadata)
return return
} }
if (event.type === "tool-call") { if (event.type === "tool-call") {
@@ -227,10 +219,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> => const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
result.type === "error" result.type === "error"
? [ ? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), { type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
LLMEvent.toolResult({ id: call.id, name: call.name, result }), { type: "tool-result", id: call.id, name: call.name, result },
] ]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })] : [{ type: "tool-result", id: call.id, name: call.name, result }]
const followUpRequest = ( const followUpRequest = (
request: LLMRequest, request: LLMRequest,
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -3
View File
@@ -50,9 +50,7 @@ const request = LLM.request({
}) })
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish" event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
? { type: "request-finish", reason: event.reason }
: { type: "text-delta", id: "text-0", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({ const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
id: "fake", id: "fake",
+1 -1
View File
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
expect( expect(
LLMResponse.text({ LLMResponse.text({
events: [ events: [
{ type: "text-delta", id: "text-0", text: "hi" }, { type: "text-delta", text: "hi" },
{ type: "request-finish", reason: "stop" }, { type: "request-finish", reason: "stop" },
], ],
}), }),
@@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMError } from "../../src" import { LLM, LLMError } from "../../src"
@@ -31,7 +30,7 @@ const recorded = recordedTests({
provider: "anthropic", provider: "anthropic",
protocol: "anthropic-messages", protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"], requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, options: { requestHeaders: ["content-type", "anthropic-version"] },
}) })
describe("Anthropic Messages sad-path recorded", () => { describe("Anthropic Messages sad-path recorded", () => {
@@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => {
cacheReadInputTokens: 1, cacheReadInputTokens: 1,
totalTokens: 7, totalTokens: 7,
}) })
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } }, providerMetadata: { anthropic: { signature: "sig_1" } },
}) })
expect(response.events.at(-1)).toMatchObject({ expect(response.events.at(-1)).toMatchObject({
+3 -3
View File
@@ -204,9 +204,9 @@ describe("Gemini route", () => {
totalTokens: 7, totalTokens: 7,
}) })
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, { type: "reasoning-delta", text: "thinking" },
{ type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" }, { type: "text-delta", text: "!" },
{ {
type: "request-finish", type: "request-finish",
reason: "stop", reason: "stop",
@@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import * as Gemini from "../../src/protocols/gemini" import * as Gemini from "../../src/protocols/gemini"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -67,7 +66,7 @@ const redactCloudflareURL = (url: string) =>
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
const cloudflareOptions = { const cloudflareOptions = {
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }), redact: { url: redactCloudflareURL },
} }
describeRecordedGoldenScenarios([ describeRecordedGoldenScenarios([
@@ -103,7 +102,7 @@ describeRecordedGoldenScenarios([
prefix: "anthropic-messages", prefix: "anthropic-messages",
model: anthropicHaiku, model: anthropicHaiku,
requires: ["ANTHROPIC_API_KEY"], requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, options: { requestHeaders: ["content-type", "anthropic-version"] },
scenarios: ["text", "tool-call"], scenarios: ["text", "tool-call"],
}, },
{ {
@@ -112,7 +111,7 @@ describeRecordedGoldenScenarios([
model: anthropicOpus, model: anthropicOpus,
requires: ["ANTHROPIC_API_KEY"], requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"], tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, options: { requestHeaders: ["content-type", "anthropic-version"] },
scenarios: [{ id: "tool-loop", temperature: false }], scenarios: [{ id: "tool-loop", temperature: false }],
}, },
{ {
@@ -225,8 +225,8 @@ describe("OpenAI Chat route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" }, { type: "text-delta", text: "!" },
{ {
type: "request-finish", type: "request-finish",
reason: "stop", reason: "stop",
@@ -336,8 +336,8 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "!" }, { type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
{ {
type: "request-finish", type: "request-finish",
reason: "stop", reason: "stop",
@@ -394,12 +394,14 @@ describe("OpenAI Responses route", () => {
id: "call_1", id: "call_1",
name: "lookup", name: "lookup",
text: '{"query"', text: '{"query"',
providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-input-delta", type: "tool-input-delta",
id: "call_1", id: "call_1",
name: "lookup", name: "lookup",
text: ':"weather"}', text: ':"weather"}',
providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-call", type: "tool-call",
+1 -1
View File
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata, ...metadata,
} }
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
) )
const requestExecutor = RequestExecutor.layer.pipe( const requestExecutor = RequestExecutor.layer.pipe(
+3 -2
View File
@@ -1,13 +1,14 @@
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder" import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { WebSocketExecutor } from "../src/route" import { WebSocketExecutor } from "../src/route"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
const liveWebSocket = WebSocketExecutor.open const liveWebSocket = WebSocketExecutor.open
type Mode = "record" | "replay" | "passthrough"
export const webSocketCassetteLayer = ( export const webSocketCassetteLayer = (
cassette: string, cassette: string,
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode }, input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> => ): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
Layer.effect( Layer.effect(
WebSocketExecutor.Service, WebSocketExecutor.Service,
-7
View File
@@ -9,13 +9,6 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`. - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`). - **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
## Development server
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
# Module shape # Module shape
Do not use `export namespace Foo { ... }` for module organization. It is not Do not use `export namespace Foo { ... }` for module organization. It is not
@@ -1 +1 @@
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
+16 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46", "version": "1.14.42",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -33,6 +33,11 @@
"node": "./src/pty/pty.node.ts", "node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts" "default": "./src/pty/pty.bun.ts"
}, },
"#hono": {
"bun": "./src/server/adapter.bun.ts",
"node": "./src/server/adapter.node.ts",
"default": "./src/server/adapter.bun.ts"
},
"#httpapi-server": { "#httpapi-server": {
"bun": "./src/server/httpapi-server.node.ts", "bun": "./src/server/httpapi-server.node.ts",
"node": "./src/server/httpapi-server.node.ts", "node": "./src/server/httpapi-server.node.ts",
@@ -69,7 +74,8 @@
"prettier": "3.6.2", "prettier": "3.6.2",
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2" "why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5"
}, },
"dependencies": { "dependencies": {
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
@@ -100,6 +106,10 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
@@ -119,7 +129,6 @@
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2", "@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
@@ -141,6 +150,8 @@
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
@@ -166,7 +177,8 @@
"which": "6.0.1", "which": "6.0.1",
"xdg-basedir": "5.1.0", "xdg-basedir": "5.1.0",
"yargs": "18.0.0", "yargs": "18.0.0",
"zod": "catalog:" "zod": "catalog:",
"zod-to-json-schema": "3.24.5"
}, },
"overrides": { "overrides": {
"drizzle-orm": "catalog:" "drizzle-orm": "catalog:"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Compare SDK types generated from Hono vs HttpApi specs.
# Sorts types alphabetically so only meaningful body differences show.
#
# Usage: ./scripts/diff-sdk-types.sh # full diff
# ./scripts/diff-sdk-types.sh --stat # summary only
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$(cd "$DIR/../sdk/js" && pwd)"
normalize() {
python3 -c "
import re, sys
content = open(sys.argv[1]).read()
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
header, body = blocks[0], blocks[1:]
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
sys.stdout.write(header + ''.join(body))
" "$1"
}
echo "Generating Hono SDK..." >&2
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "Generating HttpApi SDK..." >&2
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
fi
+401
View File
@@ -0,0 +1,401 @@
# HttpApi migration
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
## End State
- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`.
- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware.
- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge.
- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline.
- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction.
## Current State
- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
## Migration Rules
- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs.
- Migrate one route group, or one coherent subset of a route group, at a time.
- Reuse existing services. Do not re-architect service logic during HTTP boundary migration.
- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces.
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
## Route Slice Checklist
Use this checklist for each small HttpApi migration PR:
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
## Hono Deletion Checklist
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
## Experimental Read Slice Guidance
For the experimental route group, port read-only JSON routes before mutations:
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
- Preserve response headers such as pagination cursors if a route is ported.
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
## Schema Notes
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional.
- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful.
- Avoid parallel hand-written Zod and Effect definitions for the same route boundary.
## Phases
### 1. Stabilize The Bridge
Before porting more routes, cover the bridge behavior that every route depends on.
- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly.
- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials.
- Cover `directory` and `x-opencode-directory` instance selection.
- Verify generated SDK output remains unchanged for non-SDK work.
- Fix or remove any implemented-but-unmounted `HttpApi` groups.
### 2. Complete The Inventory
Create a route inventory from the actual Hono registrations and classify each route.
Statuses:
- `bridged`: served through the `HttpApi` bridge when the flag is on.
- `implemented`: `HttpApi` group exists but is not mounted through Hono.
- `next`: good JSON candidate for near-term porting.
- `later`: portable, but needs schema/service cleanup first.
- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`.
### 3. Finish JSON Route Parity
Port remaining JSON routes in small batches.
Good near-term candidates:
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
- simple mutations: `POST /instance/dispose`
- experimental JSON reads: console, tool, worktree list, resource list
- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
Keep large or stateful groups for later:
- `session`
- `sync`
- process-level experimental routes
### 4. Move OpenAPI And SDK Generation
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
Required before route deletion:
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
### 5. Make HttpApi Default For JSON Routes
After JSON parity and SDK generation are covered:
- Flip the bridge default for ported JSON routes.
- Keep a short-lived fallback flag for the old Hono implementation.
- Run the same tests against both the default and fallback path during rollout.
- Stop adding new Hono handlers for JSON routes once the default flips.
### 6. Delete Hono Route Implementations
Delete Hono routes group-by-group after each group meets the deletion criteria.
Deletion criteria:
- `HttpApi` route is mounted by default.
- Behavior is covered by bridge-level tests.
- OpenAPI/SDK generation comes from Effect for that path.
- SDK diff is zero or explicitly accepted.
- Legacy Hono route is no longer needed as a fallback.
After deleting a group:
- Remove its Hono route file or dead endpoints.
- Remove its `.route(...)` registration from `instance/index.ts`.
- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type.
- Regenerate SDK and verify output.
### 7. Replace Special Routes
Special routes need explicit designs before Hono can disappear completely.
- `event`: SSE
- `pty`: websocket
- `tui`: UI/control bridge behavior
- streaming `session` endpoints
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
## Current Route Status
| Area | Status | Notes |
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
| `question` | `bridged` | `GET /question`, reply, reject |
| `permission` | `bridged` | list and reply |
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
| `config` | `bridged` | read, providers, update |
| `project` | `bridged` | list, current, git init, update |
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
| `sync` | `bridged` | start/replay/history |
| `event` | `bridged` | SSE via raw Effect HTTP |
| `pty` | `special` | websocket |
| `tui` | `special` | UI bridge |
## Full Route Checklist
This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
### Top-Level Instance Routes
- [x] `POST /instance/dispose` - dispose active instance after response.
- [x] `GET /path` - current directory and worktree paths.
- [x] `GET /vcs` - current VCS status.
- [x] `GET /vcs/diff` - VCS diff summary.
- [x] `GET /command` - command catalog.
- [x] `GET /agent` - agent catalog.
- [x] `GET /skill` - skill catalog.
- [x] `GET /lsp` - LSP status.
- [x] `GET /formatter` - formatter status.
### Config Routes
- [x] `GET /config` - read config.
- [x] `PATCH /config` - update config and dispose active instance after response.
- [x] `GET /config/providers` - config provider summary.
### Project Routes
- [x] `GET /project` - list projects.
- [x] `GET /project/current` - current project.
- [x] `POST /project/git/init` - initialize git and reload active instance after response.
- [x] `PATCH /project/:projectID` - update project metadata.
### Provider Routes
- [x] `GET /provider` - list providers.
- [x] `GET /provider/auth` - list provider auth methods.
- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
### Question Routes
- [x] `GET /question` - list questions.
- [x] `POST /question/:requestID/reply` - reply to question.
- [x] `POST /question/:requestID/reject` - reject question.
### Permission Routes
- [x] `GET /permission` - list permission requests.
- [x] `POST /permission/:requestID/reply` - reply to permission request.
### File Routes
- [x] `GET /find` - text search.
- [x] `GET /find/file` - file search.
- [x] `GET /find/symbol` - symbol search.
- [x] `GET /file` - list directory entries.
- [x] `GET /file/content` - read file content.
- [x] `GET /file/status` - file status.
### MCP Routes
- [x] `GET /mcp` - MCP status.
- [x] `POST /mcp` - add MCP server at runtime.
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
- [x] `POST /mcp/:name/connect` - connect MCP server.
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
### Experimental Routes
- [x] `GET /experimental/console` - active Console provider metadata.
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
- [x] `POST /experimental/console/switch` - switch active Console org.
- [x] `GET /experimental/tool/ids` - tool IDs.
- [x] `GET /experimental/tool` - tools for provider/model.
- [x] `GET /experimental/worktree` - list worktrees.
- [x] `POST /experimental/worktree` - create worktree.
- [x] `DELETE /experimental/worktree` - remove worktree.
- [x] `POST /experimental/worktree/reset` - reset worktree.
- [x] `GET /experimental/session` - global session list.
- [x] `GET /experimental/resource` - MCP resources.
### Workspace Routes
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
- [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status.
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
### Sync Routes
- [x] `POST /sync/start` - start workspace sync.
- [x] `POST /sync/replay` - replay sync events.
- [x] `POST /sync/history` - list sync event history.
### Session Routes
- [x] `GET /session` - list sessions.
- [x] `GET /session/status` - session status map.
- [x] `GET /session/:sessionID` - get session.
- [x] `GET /session/:sessionID/children` - get child sessions.
- [x] `GET /session/:sessionID/todo` - get session todos.
- [x] `POST /session` - create session.
- [x] `DELETE /session/:sessionID` - delete session.
- [x] `PATCH /session/:sessionID` - update session metadata.
- [x] `POST /session/:sessionID/init` - run project init command.
- [x] `POST /session/:sessionID/fork` - fork session.
- [x] `POST /session/:sessionID/abort` - abort session.
- [x] `POST /session/:sessionID/share` - share session.
- [x] `GET /session/:sessionID/diff` - session diff.
- [x] `DELETE /session/:sessionID/share` - unshare session.
- [x] `POST /session/:sessionID/summarize` - summarize session.
- [x] `GET /session/:sessionID/message` - list session messages.
- [x] `GET /session/:sessionID/message/:messageID` - get message.
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
- [x] `POST /session/:sessionID/message` - prompt with streaming response.
- [x] `POST /session/:sessionID/prompt_async` - async prompt.
- [x] `POST /session/:sessionID/command` - run command.
- [x] `POST /session/:sessionID/shell` - run shell command.
- [x] `POST /session/:sessionID/revert` - revert message.
- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
### Event Routes
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
### PTY Routes
- [x] `GET /pty` - list PTY sessions.
- [x] `POST /pty` - create PTY session.
- [x] `GET /pty/:ptyID` - get PTY session.
- [x] `PUT /pty/:ptyID` - update PTY session.
- [x] `DELETE /pty/:ptyID` - remove PTY session.
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
### TUI Routes
- [x] `POST /tui/append-prompt` - append prompt.
- [x] `POST /tui/open-help` - open help.
- [x] `POST /tui/open-sessions` - open sessions.
- [x] `POST /tui/open-themes` - open themes.
- [x] `POST /tui/open-models` - open models.
- [x] `POST /tui/submit-prompt` - submit prompt.
- [x] `POST /tui/clear-prompt` - clear prompt.
- [x] `POST /tui/execute-command` - execute command.
- [x] `POST /tui/show-toast` - show toast.
- [x] `POST /tui/publish` - publish TUI event.
- [x] `POST /tui/select-session` - select session.
- [x] `GET /tui/control/next` - get next TUI request.
- [x] `POST /tui/control/response` - submit TUI control response.
## Remaining PR Plan
Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
1. [x] Bridge `PATCH /project/:projectID`.
2. [x] Bridge MCP add/connect/disconnect routes.
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
4. [x] Bridge experimental console switch and tool list routes.
5. [x] Bridge experimental global session list.
6. [x] Bridge workspace create/remove/session-restore routes.
7. [x] Bridge sync start/replay/history routes.
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
## Checklist
- [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
- [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Centralize auth via Effect `Config` for the Effect backend.
- [x] Support `auth_token` as a query security scheme.
- [x] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [x] Port remaining top-level JSON reads.
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`.
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
- [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
+2 -2
View File
@@ -59,10 +59,10 @@ Rules:
## Schema → Zod interop ## Schema → Zod interop
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
```ts ```ts
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
``` ```
+3 -3
View File
@@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims.
Use Effect Schema as the source of truth for domain models, IDs, inputs, Use Effect Schema as the source of truth for domain models, IDs, inputs,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@opencode-ai/core/effect-zod`. schema via `@/util/effect-zod`.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server The long-term driver is `specs/effect/http-api.md` — once the HTTP server
moves to `@effect/platform`, every Schema-first DTO can flow through moves to `@effect/platform`, every Schema-first DTO can flow through
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
## Escape hatches ## Escape hatches
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for The walker in `@/util/effect-zod` exposes two explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted. comments document when each can be deleted.
@@ -389,7 +389,7 @@ piecewise.
## Notes ## Notes
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Use `@/util/effect-zod` for all Schema → Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and - Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type. Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more - Keep the migration incremental. Converting the domain model first is more
@@ -1,204 +0,0 @@
# OpenAPI Translation Cleanup Plan
## Goal
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
## Current Culprit
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
## Non-Negotiables
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
- Prefer endpoint or schema annotations over post-generation spec surgery.
- Remove one category of rewrite at a time, with focused compatibility checks.
## PR Checklist
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
Current combined PR scope:
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
### PR 1: Add OpenAPI/Runtime Query Drift Tests
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
- `[x]` Keep this part test-only.
Verification:
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
- `[x]` `bun typecheck` from `packages/opencode`.
### PR 2: Delete Spec-Only Workspace Query Injection
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
- `[x]` Delete `InstanceQueryParameters`.
- `[x]` Delete the `isInstanceRoute` constant.
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
Notes:
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
Expected code shape:
```ts
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
```
Verification:
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
- `[x]` `bun typecheck` from `packages/opencode`.
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
Concrete first targets:
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
Verification:
- Focused HTTP tests for changed query fields.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK request param types before deleting each override.
- `bun typecheck` from `packages/opencode`.
### PR 4: Move Path Parameter Patterns Into ID Schemas
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
- Delete one path override only after generated OpenAPI is unchanged for that param.
Concrete first targets:
- `[x]` `sessionID`
- `[x]` `messageID`
- `[x]` `partID`
- `[x]` `permissionID`
- `[x]` `ptyID`
- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern.
Verification:
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated path param types and patterns.
- `bun typecheck` from `packages/opencode`.
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
- Update handlers to fail with the declared API errors at the boundary.
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
- Do this group by group, starting with one small route group.
Concrete first targets:
- `groups/config.ts` `PATCH /config` bad request.
- `groups/session.ts` endpoints that already translate domain not-found errors.
- `groups/file.ts` if any handler currently relies on built-in error shape.
Verification:
- Focused HTTP tests asserting response body shape for changed error paths.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect SDK error union diff.
- `bun typecheck` from `packages/opencode`.
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
- Decide whether SDK should expose auth in generated operation metadata.
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
- If removing it, update SDK generation expectations and docs in the same PR.
Verification:
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated client call signatures and error unions.
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
### PR 7: Tackle Component Shape Rewrites One At A Time
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
Concrete first targets:
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
Verification:
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK type-name and optionality diffs.
## Upstream Middleware Query Support
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
Target in `effect-smol`:
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
- Make runtime request decoding include middleware query schemas.
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
## Suggested PR Order
1. Add drift detection tests only.
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
3. Convert query type overrides into route/schema-level helpers where possible.
4. Convert path parameter overrides into schema annotations or upstream fixes.
5. Replace built-in error response rewrites with explicit declared API errors by route group.
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
## Verification Checklist Per PR
- Focused HTTP tests for changed routes.
- OpenAPI drift tests.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK diff for public API churn.
- `bun typecheck` from `packages/opencode`.
@@ -1,67 +0,0 @@
# TUI Command Shim Removal
Problem:
- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization
- v2 should expose only the keymap command API
- tests and fixtures should not encode legacy command behavior as expected behavior
## Remove Public Types
In `packages/plugin/src/tui.ts`, remove:
- `TuiCommand`
- `TuiCommandApi`
- `TuiPluginApi.command`
Keep `api.keymap` as the only TUI command registration and execution surface.
## Remove Runtime Shim
Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`.
In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove:
- the `createCommandShim` import
- the `command: createCommandShim(...)` field from `createTuiApi(...)`
In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove:
- the `createCommandShim` import
- the `command: createCommandShim(...)` field from `pluginApi(...)`
## Migration Target
Plugin authors should replace old calls with keymap calls:
```ts
api.keymap.registerLayer({
commands: [
{
name: "plugin.command",
title: "Plugin Command",
namespace: "palette",
slashName: "plugin",
run() {
api.ui.dialog.clear()
},
},
],
bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }],
})
```
Direct replacements:
- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })`
- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)`
- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")`
- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure
## Verification
After removal, run from package directories:
- `bun typecheck` in `packages/plugin`
- `bun typecheck` in `packages/opencode`
- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed
+46 -45
View File
@@ -24,9 +24,11 @@ import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import * as Option from "effect/Option" import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer" import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { withStatics, type DeepMutable } from "@/util/schema"
import { Reference } from "@/reference/reference"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
export const Info = Schema.Struct({ export const Info = Schema.Struct({
name: Schema.String, name: Schema.String,
@@ -301,70 +303,69 @@ export const layer = Layer.effect(
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
} }
function referencePrompt(reference: Reference.Resolved) { function referencePath(value: string) {
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
return path.isAbsolute(value)
? value
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
}
function resolveReference(reference: ReferenceEntry): ResolvedReference {
if (typeof reference === "string") {
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
return { kind: "local", path: referencePath(reference) }
}
return { kind: "git", repository: reference }
}
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
return { kind: "git", repository: reference.repository, branch: reference.branch }
}
function referencePrompt(name: string, reference: ResolvedReference) {
if (reference.kind === "local") { if (reference.kind === "local") {
return [ return [
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`, PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
`Local directory: ${reference.path}`, `Local directory: ${reference.path}`,
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n")
}
if (reference.kind === "invalid") {
return [
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
`Configured repository: ${reference.repository}`,
`Problem: ${reference.message}`,
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
].join("\n\n") ].join("\n\n")
} }
return [ return [
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`, PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a git repository.`,
`Repository: ${reference.repository}`, `Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`Cached directory: ${reference.path}`, `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n") ].join("\n\n")
} }
function referenceDescription(reference: Reference.Resolved) {
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
return `Invalid Scout reference for repository ${reference.repository}`
}
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
const resolvedReferences = Reference.resolveAll({ for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
references: cfg.reference ?? {}, if (agents[name]) continue
directory: ctx.directory, const resolved = resolveReference(reference)
worktree: ctx.worktree, const localPath = resolved.kind === "local" ? resolved.path : undefined
}) agents[name] = {
for (const resolved of resolvedReferences) { name,
if (agents[resolved.name]) continue description:
const localPath = resolved.kind === "invalid" ? undefined : resolved.path resolved.kind === "local"
agents[resolved.name] = { ? `Scout reference for local directory ${resolved.path}`
name: resolved.name, : `Scout reference for repository ${resolved.repository}`,
description: referenceDescription(resolved),
permission: Permission.merge( permission: Permission.merge(
agents.scout.permission, agents.scout.permission,
Permission.fromConfig({ Permission.fromConfig(
repo_clone: "deny", localPath
...(localPath
? { ? {
external_directory: { external_directory: {
[localPath]: "allow", [localPath]: "allow",
[path.join(localPath, "*")]: "allow", [path.join(localPath, "*")]: "allow",
}, },
} }
: {}), : {},
}),
), ),
prompt: referencePrompt(resolved), ),
options: { reference: cfg.reference?.[resolved.name], resolved }, prompt: referencePrompt(name, resolved),
options: { reference },
mode: "subagent", mode: "subagent",
native: false, native: false,
} }
@@ -1,33 +0,0 @@
import type { Permission } from "../permission"
import type { Agent } from "./agent"
/**
* Build the `permission` ruleset for a subagent's session when it's spawned
* via the task tool. Combines:
*
* 1. The parent **agent's** deny rules Plan Mode and other agent-level
* restrictions live on the agent ruleset, not on the session, so a
* subagent that only inherited the parent SESSION's permission would
* silently bypass them. (#26514)
* 2. The parent **session's** deny rules and external_directory rules
* same forwarding the original code already did.
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
return [
...parentAgentDenies,
...input.parentSessionPermission.filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
]
}
-5
View File
@@ -2,8 +2,3 @@ declare module "*.wav" {
const file: string const file: string
export default file export default file
} }
declare module "*.wasm" {
const file: string
export default file
}
+2 -2
View File
@@ -1,7 +1,7 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@opencode-ai/core/schema" import { NonNegativeInt } from "@/util/schema"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
+19
View File
@@ -1,4 +1,6 @@
import z from "zod"
import { Schema } from "effect" import { Schema } from "effect"
import { zodObject } from "@/util/effect-zod"
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = { export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
type: Type type: Type
@@ -16,6 +18,23 @@ export function define<Type extends string, Properties extends Schema.Top>(
return result return result
} }
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
id: z.string(),
type: z.literal(type),
properties: zodObject(def.properties),
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
}
export function effectPayloads() { export function effectPayloads() {
return registry return registry
.entries() .entries()
+3 -3
View File
@@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number })
} }
} }
function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
return diffs?.map((item, i) => ({ return diffs?.map((item, i) => ({
...item, ...item,
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), file: redact(`${kind}-file`, String(i), item.file),
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), patch: redact(`${kind}-patch`, String(i), item.patch),
})) }))
} }
+19 -4
View File
@@ -1,13 +1,28 @@
import { Server } from "../../server/server" import { Server } from "../../server/server"
import type { CommandModule } from "yargs" import type { CommandModule } from "yargs"
type Args = {} type Args = {
httpapi: boolean
hono: boolean
}
export const GenerateCommand = { export const GenerateCommand = {
command: "generate", command: "generate",
builder: (yargs) => yargs, builder: (yargs) =>
handler: async () => { yargs
const specs = (await Server.openapi()) as { paths: Record<string, Record<string, any>> } .option("httpapi", {
type: "boolean",
default: false,
description:
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
})
.option("hono", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
}),
handler: async (args) => {
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
for (const item of Object.values(specs.paths)) { for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) { for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method] const operation = item[method]
+6 -11
View File
@@ -93,6 +93,7 @@ const appBindingCommands = [
"theme.mode.lock", "theme.mode.lock",
"help.show", "help.show",
"docs.open", "docs.open",
"app.exit",
"app.debug", "app.debug",
"app.console", "app.console",
"app.heap_snapshot", "app.heap_snapshot",
@@ -647,6 +648,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
title: "Exit the app", title: "Exit the app",
slashName: "exit", slashName: "exit",
slashAliases: ["quit", "q"], slashAliases: ["quit", "q"],
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
run: () => exit(), run: () => exit(),
category: "System", category: "System",
}, },
@@ -779,17 +785,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
bindings: tuiConfig.keybinds.gather("app", appBindingCommands), bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
})) }))
useBindings(() => ({
enabled: () => {
const ok = command.matcher.get()
if (!ok) return false
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
}))
event.on(TuiEvent.CommandExecute.type, (evt) => { event.on(TuiEvent.CommandExecute.type, (evt) => {
command.run(evt.properties.command) command.run(evt.properties.command)
}) })
@@ -712,6 +712,7 @@ export function Prompt(props: PromptProps) {
...input.traits, ...input.traits,
...computePromptTraits({ ...computePromptTraits({
mode: store.mode, mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible, autocompleteVisible: !!auto()?.visible,
}), }),
} }
@@ -4,6 +4,7 @@ export type PromptMode = "normal" | "shell"
export interface PromptTraitsInput { export interface PromptTraitsInput {
mode: PromptMode mode: PromptMode
disabled: boolean
autocompleteVisible: boolean autocompleteVisible: boolean
} }
@@ -15,9 +16,10 @@ export type PromptTraits = EditorTraits & {
/** /**
* Compute the textarea editor traits for the prompt. * Compute the textarea editor traits for the prompt.
* *
* The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits * `traits.suspend` gates the textarea's keybinding actions (backspace,
* only expose capture/status metadata so focus changes cannot unsuspend the * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
* keymap-managed editor mappings. * editing mode only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor.
*/ */
export function computePromptTraits(input: PromptTraitsInput): PromptTraits { export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
const capture = const capture =
@@ -28,6 +30,7 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
: undefined : undefined
return { return {
capture, capture,
suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined, status: input.mode === "shell" ? "SHELL" : undefined,
owner: "opencode", owner: "opencode",
role: "prompt", role: "prompt",
@@ -1,34 +0,0 @@
/**
* Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
* boundaries so a single 4xx doesn't drown its parallel siblings as
* unhandled rejections every failure surfaces in one labeled message.
*/
export type LabeledSettled = {
name: string
result: PromiseSettledResult<unknown>
}
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
const failed = labeled.filter(
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
)
if (failed.length === 0) return null
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
return err
}
function reasonMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason
if (reason && typeof reason === "object") {
const obj = reason as { message?: unknown; name?: unknown }
if (typeof obj.message === "string") return obj.message
if (typeof obj.name === "string") return obj.name
}
return String(reason)
}
@@ -1,39 +0,0 @@
import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js"
import { Global } from "@opencode-ai/core/global"
const context = createContext<{
path: () => string
format: (input?: string) => string
}>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
return (
<context.Provider
value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}
>
{props.children}
</context.Provider>
)
}
export function usePathFormatter() {
const value = useContext(context)
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
return value
}
function formatPath(input: string | undefined, base: string | undefined) {
if (!input) return ""
const root = base || process.cwd()
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
const relative = path.relative(root, absolute)
if (!relative) return "."
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
return absolute.replace(Global.Path.home, "~")
}
return absolute
}
@@ -32,7 +32,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path" import path from "path"
import { useKV } from "./kv" import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync", name: "Sync",
@@ -392,23 +391,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState) .catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [ const blockingRequests: Promise<unknown>[] = [
{ name: "config.providers", promise: providersPromise }, providersPromise,
{ name: "provider.list", promise: providerListPromise }, providerListPromise,
{ name: "app.agents", promise: agentsPromise }, agentsPromise,
{ name: "config.get", promise: configPromise }, configPromise,
{ name: "project.sync", promise: projectPromise }, projectPromise,
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ...(args.continue ? [sessionListPromise] : []),
] ]
await Promise.allSettled(blockingRequests.map((r) => r.promise)) await Promise.all(blockingRequests)
.then((settled) => {
// Surface every failed endpoint in one labeled message instead of
// letting the first rejection drown its siblings as unhandled
// rejections.
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
if (failure) throw failure
})
.then(async () => { .then(async () => {
const providersResponse = providersPromise.then((x) => x.data!) const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!)
@@ -534,12 +526,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) draft.session[match.index] = session.data! if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!) if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? [] draft.todo[sessionID] = todo.data ?? []
const infos: (typeof draft.message)[string] = [] draft.message[sessionID] = messages.data!.map((x) => x.info)
for (const message of messages.data ?? []) { for (const message of messages.data!) {
infos.push(message.info)
draft.part[message.info.id] = message.parts draft.part[message.info.id] = message.parts
} }
draft.message[sessionID] = infos
draft.session_diff[sessionID] = diff.data ?? [] draft.session_diff[sessionID] = diff.data ?? []
}), }),
) )
+1 -1
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { PositiveInt } from "@opencode-ai/core/schema" import { PositiveInt } from "@/util/schema"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000 const DEFAULT_TOAST_DURATION = 5000
+7 -31
View File
@@ -8,9 +8,9 @@ import {
import { import {
KeymapProvider, KeymapProvider,
reactiveMatcherFromSignal, reactiveMatcherFromSignal,
useBindings,
useKeymap, useKeymap,
useKeymapSelector, useKeymapSelector,
useBindings,
} from "@opentui/keymap/solid" } from "@opentui/keymap/solid"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { TuiConfig } from "./config/tui" import type { TuiConfig } from "./config/tui"
@@ -26,28 +26,6 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap> export type OpenTuiKeymap = ReturnType<typeof useKeymap>
const KEY_ALIASES = {
enter: "return",
esc: "escape",
} as const
function expandKeyAliases(input: string) {
const result = Object.entries(KEY_ALIASES).reduce(
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
input,
)
if (result === input) return
return result
}
function registerKeyAliases(keymap: OpenTuiKeymap) {
return keymap.appendBindingExpander((ctx) => {
const key = expandKeyAliases(ctx.input)
if (!key) return
return [{ key, displays: ctx.displays }]
})
}
const inputCommands = [ const inputCommands = [
"input.move.left", "input.move.left",
"input.move.right", "input.move.right",
@@ -120,13 +98,8 @@ export function formatKeyBindings(
return formatCommandBindingsExtra(bindings, formatOptions(config)) return formatCommandBindingsExtra(bindings, formatOptions(config))
} }
export function registerOpencodeKeymap( export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
keymap: OpenTuiKeymap,
renderer: CliRenderer,
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
) {
const offCommaBindings = addons.registerCommaBindings(keymap) const offCommaBindings = addons.registerCommaBindings(keymap)
const offAliasExpander = registerKeyAliases(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
const offLeader = addons.registerTimedLeader(keymap, { const offLeader = addons.registerTimedLeader(keymap, {
trigger: config.keybinds.get(LEADER_TOKEN), trigger: config.keybinds.get(LEADER_TOKEN),
@@ -135,17 +108,20 @@ export function registerOpencodeKeymap(
}) })
const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
const offInputBindings = keymap.registerLayer({
enabled: () => renderer.currentFocusedEditor !== null, enabled: () => renderer.currentFocusedEditor !== null,
bindings: config.keybinds.gather("input", inputCommands), bindings: config.keybinds.gather("input", inputCommands),
}) })
return () => { return () => {
offInputBindings() offInputBindings()
offInputSuspension()
offInputCommands()
offBackspace() offBackspace()
offEscape() offEscape()
offLeader() offLeader()
offAliasExpander()
offBaseLayout() offBaseLayout()
offCommaBindings() offCommaBindings()
} }
@@ -17,7 +17,6 @@ import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast" import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap" import * as Keymap from "../keymap"
import { createCommandShim } from "./command-shim"
type RouteEntry = { type RouteEntry = {
key: symbol key: symbol
@@ -148,9 +147,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.session.length return sync.data.session.length
}, },
diff(sessionID) { diff(sessionID) {
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => return sync.data.session_diff[sessionID] ?? []
item.file === undefined ? [] : [{ ...item, file: item.file }],
)
}, },
todo(sessionID) { todo(sessionID) {
return sync.data.todo[sessionID] ?? [] return sync.data.todo[sessionID] ?? []
@@ -203,8 +200,6 @@ export function createTuiApi(input: Input): TuiPluginApi {
} }
return { return {
app: appApi(), app: appApi(),
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: { keys: {
formatSequence(parts) { formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig) return Keymap.formatKeySequence(parts, input.tuiConfig)
@@ -1,109 +0,0 @@
// Legacy `api.command` bridge for v1 plugins; remove in v2.
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { TuiKeybind } from "../config/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"
const warned = new Set<string>()
type Warn = (api: string, replacement: string) => void
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
type CommandShimDialog = DialogContext | LegacyDialog
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
function warnCommandShim(api: string, replacement: string) {
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
}
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
if (!("stack" in dialog)) return dialog
return {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
}
}
function warnOnce(api: string, replacement: string, warn: Warn) {
if (warned.has(api)) return
warned.add(api)
warn(api, replacement)
}
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
return {
namespace: "palette",
name: item.value,
title: item.title,
desc: item.description,
category: item.category,
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slashName: item.slash?.name,
slashAliases: item.slash?.aliases,
run() {
return item.onSelect?.(dialog)
},
}
}
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
return commands.flatMap((item) =>
item.keybind
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
? keybinds
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
: [
{
key: item.keybind,
cmd: item.value,
desc: item.title,
},
]
: [],
)
}
export function createCommandShim(
keymap: TuiPluginApi["keymap"],
dialog: CommandShimDialog,
keybinds: LegacyKeybinds,
): TuiPluginApi["command"] {
const shimDialog = createCommandShimDialog(dialog)
return {
register(cb) {
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
const commands = cb()
return keymap.registerLayer({
commands: commands.map((item) => toCommand(item, shimDialog)),
bindings: toBindings(commands, keybinds),
})
},
trigger(value) {
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
keymap.dispatchCommand(value)
},
show() {
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
},
}
}
@@ -39,7 +39,6 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots" import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots" import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { createCommandShim } from "./command-shim"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
@@ -577,8 +576,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return { return {
app: api.app, app: api.app,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
keys: api.keys, keys: api.keys,
keymap, keymap,
route, route,
@@ -75,6 +75,7 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PermissionPrompt } from "./permission" import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question" import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -89,7 +90,6 @@ import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff" import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette" import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap" import { useBindings, useCommandShortcut } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -1078,7 +1078,6 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom)) createEffect(on(() => route.sessionID, toBottom))
return ( return (
<PathFormatterProvider path={session()?.directory}>
<context.Provider <context.Provider
value={{ value={{
get width() { get width() {
@@ -1272,7 +1271,6 @@ export function Session() {
</Show> </Show>
</box> </box>
</context.Provider> </context.Provider>
</PathFormatterProvider>
) )
} }
@@ -1829,7 +1827,7 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) { function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme() const { theme } = useTheme()
const pathFormatter = usePathFormatter() const sync = useSync()
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
@@ -1843,7 +1841,18 @@ function Shell(props: ToolProps<typeof ShellTool>) {
const workdirDisplay = createMemo(() => { const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined if (!workdir || workdir === ".") return undefined
return pathFormatter.format(workdir)
const base = sync.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)
if (absolute === base) return undefined
const home = Global.Path.home
if (!home) return absolute
const match = absolute === home || absolute.startsWith(home + path.sep)
return match ? absolute.replace(home, "~") : absolute
}) })
const title = createMemo(() => { const title = createMemo(() => {
@@ -1885,7 +1894,6 @@ function Shell(props: ToolProps<typeof ShellTool>) {
function Write(props: ToolProps<typeof WriteTool>) { function Write(props: ToolProps<typeof WriteTool>) {
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const code = createMemo(() => { const code = createMemo(() => {
if (!props.input.content) return "" if (!props.input.content) return ""
return props.input.content return props.input.content
@@ -1894,7 +1902,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
return ( return (
<Switch> <Switch>
<Match when={props.metadata.diagnostics !== undefined}> <Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}> <BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}> <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code <code
conceal={false} conceal={false}
@@ -1909,7 +1917,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}> <InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {pathFormatter.format(props.input.filePath)} Write {normalizePath(props.input.filePath!)}
</InlineTool> </InlineTool>
</Match> </Match>
</Switch> </Switch>
@@ -1917,10 +1925,9 @@ function Write(props: ToolProps<typeof WriteTool>) {
} }
function Glob(props: ToolProps<typeof GlobTool>) { function Glob(props: ToolProps<typeof GlobTool>) {
const pathFormatter = usePathFormatter()
return ( return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}> <InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show> Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.count}> <Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show> </Show>
@@ -1930,7 +1937,6 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) { function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme() const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => { const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return [] if (props.part.state.status !== "completed") return []
@@ -1948,13 +1954,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
spinner={isRunning()} spinner={isRunning()}
part={props.part} part={props.part}
> >
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool> </InlineTool>
<For each={loaded()}> <For each={loaded()}>
{(filepath) => ( {(filepath) => (
<box paddingLeft={3}> <box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}> <text paddingLeft={3} fg={theme.textMuted}>
Loaded {pathFormatter.format(filepath)} Loaded {normalizePath(filepath)}
</text> </text>
</box> </box>
)} )}
@@ -1964,10 +1970,9 @@ function Read(props: ToolProps<typeof ReadTool>) {
} }
function Grep(props: ToolProps<typeof GrepTool>) { function Grep(props: ToolProps<typeof GrepTool>) {
const pathFormatter = usePathFormatter()
return ( return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}> <InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show> Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.matches}> <Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show> </Show>
@@ -2066,7 +2071,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
function Edit(props: ToolProps<typeof EditTool>) { function Edit(props: ToolProps<typeof EditTool>) {
const ctx = use() const ctx = use()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const view = createMemo(() => { const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style const diffStyle = ctx.tui.diff_style
@@ -2082,7 +2086,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
return ( return (
<Switch> <Switch>
<Match when={props.metadata.diff !== undefined}> <Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}> <BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<box paddingLeft={1}> <box paddingLeft={1}>
<diff <diff
diff={diffContent()} diff={diffContent()}
@@ -2109,7 +2113,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}> <InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
</InlineTool> </InlineTool>
</Match> </Match>
</Switch> </Switch>
@@ -2119,7 +2123,6 @@ function Edit(props: ToolProps<typeof EditTool>) {
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) { function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use() const ctx = use()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => props.metadata.files ?? []) const files = createMemo(() => props.metadata.files ?? [])
@@ -2158,7 +2161,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
return "← Patched " + file.relativePath return "← Patched " + file.relativePath
} }
@@ -2278,6 +2281,20 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
) )
} }
function normalizePath(input?: string) {
if (!input) return ""
const cwd = process.cwd()
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const relative = path.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
// outside cwd - use absolute
return absolute
}
function input(input: Record<string, any>, omit?: string[]): string { function input(input: Record<string, any>, omit?: string[]): string {
const primitives = Object.entries(input).filter(([key, value]) => { const primitives = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false if (omit?.includes(key)) return false
@@ -11,16 +11,34 @@ import { useProject } from "../../context/project"
import path from "path" import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id" import { ShellID } from "@/tool/shell/id"
import { webSearchProviderLabel } from "@/tool/websearch" import { webSearchProviderLabel } from "@/tool/websearch"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap" import { useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject" type PermissionStage = "permission" | "always" | "reject"
function normalizePath(input?: string) {
if (!input) return ""
const cwd = process.cwd()
const home = Global.Path.home
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const relative = path.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
// outside cwd - use ~ or absolute
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
return absolute.replace(home, "~")
}
return absolute
}
function filetype(input?: string) { function filetype(input?: string) {
if (!input) return "none" if (!input) return "none"
const ext = path.extname(input) const ext = path.extname(input)
@@ -119,7 +137,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
stage: "permission" as PermissionStage, stage: "permission" as PermissionStage,
}) })
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
@@ -203,7 +220,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filepath = typeof raw === "string" ? raw : "" const filepath = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
title: `Edit ${pathFormatter.format(filepath)}`, title: `Edit ${normalizePath(filepath)}`,
body: <EditBody request={props.request} />, body: <EditBody request={props.request} />,
} }
} }
@@ -213,11 +230,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filePath = typeof raw === "string" ? raw : "" const filePath = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
title: `Read ${pathFormatter.format(filePath)}`, title: `Read ${normalizePath(filePath)}`,
body: ( body: (
<Show when={filePath}> <Show when={filePath}>
<box paddingLeft={1}> <box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text> <text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
</box> </box>
</Show> </Show>
), ),
@@ -259,11 +276,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const dir = typeof raw === "string" ? raw : "" const dir = typeof raw === "string" ? raw : ""
return { return {
icon: "→", icon: "→",
title: `List ${pathFormatter.format(dir)}`, title: `List ${normalizePath(dir)}`,
body: ( body: (
<Show when={dir}> <Show when={dir}>
<box paddingLeft={1}> <box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text> <text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box> </box>
</Show> </Show>
), ),
@@ -342,7 +359,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived const raw = parent ?? filepath ?? derived
const dir = pathFormatter.format(raw) const dir = normalizePath(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return { return {
@@ -1,8 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { Schema } from "effect"
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
export async function validateSession(input: { export async function validateSession(input: {
url: string url: string
@@ -13,11 +10,9 @@ export async function validateSession(input: {
}) { }) {
if (!input.sessionID) return if (!input.sessionID) return
let sessionID: SessionID const result = SessionID.zod.safeParse(input.sessionID)
try { if (!result.success) {
sessionID = decodeSessionID(input.sessionID) throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
} catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
} }
await createOpencodeClient({ await createOpencodeClient({
@@ -25,5 +20,5 @@ export async function validateSession(input: {
directory: input.directory, directory: input.directory,
fetch: input.fetch, fetch: input.fetch,
headers: input.headers, headers: input.headers,
}).session.get({ sessionID }, { throwOnError: true }) }).session.get({ sessionID: result.data }, { throwOnError: true })
} }
+2 -2
View File
@@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect" import { Effect, Layer, Context, Schema } from "effect"
import z from "zod" import z from "zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { Skill } from "../skill" import { Skill } from "../skill"
+2 -2
View File
@@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent"
import { Exit, Schema, SchemaGetter } from "effect" import { Exit, Schema, SchemaGetter } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import { PositiveInt, withStatics } from "@/util/schema"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob" import { Glob } from "@opencode-ai/core/util/glob"
@@ -1,30 +0,0 @@
export * as ConfigAttachment from "./attachment"
import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
export const Image = Schema.Struct({
auto_resize: Schema.optional(Schema.Boolean).annotate({
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
}),
max_width: Schema.optional(PositiveInt).annotate({
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
}),
max_height: Schema.optional(PositiveInt).annotate({
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
}),
max_base64_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
}),
})
.annotate({ identifier: "ImageAttachmentConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Image = Schema.Schema.Type<typeof Image>
export const Info = Schema.Struct({
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
})
.annotate({ identifier: "AttachmentConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
+2 -2
View File
@@ -5,8 +5,8 @@ import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob" import { Glob } from "@opencode-ai/core/util/glob"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name" import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error" import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown" import * as ConfigMarkdown from "./markdown"
+5 -9
View File
@@ -22,10 +22,9 @@ import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath } from "../project/instance-context" import { containsPath } from "../project/instance-context"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
import { ConfigAgent } from "./agent" import { ConfigAgent } from "./agent"
import { ConfigAttachment } from "./attachment"
import { ConfigCommand } from "./command" import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter" import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout" import { ConfigLayout } from "./layout"
@@ -122,8 +121,8 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate
}) })
// The Effect Schema is the canonical source of truth. The `.zod` compatibility // The Effect Schema is the canonical source of truth. The `.zod` compatibility
// surface is derived from it so plugin/SDK Zod consumers keep working without // surface is derived so existing Hono validators keep working without a parallel
// a parallel hand-maintained Zod definition. // Zod definition.
// //
// The walker emits `z.object({...})` which is non-strict by default. Config // The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json), // historically uses `.strict()` (additionalProperties: false in openapi.json),
@@ -242,9 +241,6 @@ export const Info = Schema.Struct({
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(ConfigPermission.Info), permission: Schema.optional(ConfigPermission.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
attachment: Schema.optional(ConfigAttachment.Info).annotate({
description: "Attachment processing configuration, including image size limits and resizing behavior",
}),
enterprise: Schema.optional( enterprise: Schema.optional(
Schema.Struct({ Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
@@ -311,7 +307,7 @@ export const Info = Schema.Struct({
})), })),
) )
// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // Uses the shared `DeepMutable` from `@/util/schema`. See the definition
// there for why the local variant is needed over `Types.DeepMutable` from // there for why the local variant is needed over `Types.DeepMutable` from
// effect-smol (the upstream version collapses `unknown` to `{}`). // effect-smol (the upstream version collapses `unknown` to `{}`).
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & { export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@opencode-ai/core/schema" import { NonNegativeInt } from "@/util/schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({ export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
+2 -2
View File
@@ -1,8 +1,8 @@
export * as ConfigFormatter from "./formatter" export * as ConfigFormatter from "./formatter"
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
export const Entry = Schema.Struct({ export const Entry = Schema.Struct({
disabled: Schema.optional(Schema.Boolean), disabled: Schema.optional(Schema.Boolean),
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
export const Layout = Schema.Literals(["auto", "stretch"]) export const Layout = Schema.Literals(["auto", "stretch"])
.annotate({ identifier: "LayoutConfig" }) .annotate({ identifier: "LayoutConfig" })
+2 -2
View File
@@ -1,8 +1,8 @@
export * as ConfigLSP from "./lsp" export * as ConfigLSP from "./lsp"
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
import * as LSPServer from "../lsp/server" import * as LSPServer from "../lsp/server"
export const Disabled = Schema.Struct({ export const Disabled = Schema.Struct({
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import { PositiveInt, withStatics } from "@/util/schema"
export const Local = Schema.Struct({ export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
+2 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect" import { Schema } from "effect"
import z from "zod" import z from "zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
// The original Zod schema carried an external $ref pointing at the models.dev // The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a // JSON schema. That external reference is not a named SDK component — it is a
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
import z from "zod" import z from "zod"
import type { DeepMutable } from "@opencode-ai/core/schema" import type { DeepMutable } from "@/util/schema"
import { InvalidError, JsonError } from "./error" import { InvalidError, JsonError } from "./error"
type ZodSchema<T> = z.ZodType<T> type ZodSchema<T> = z.ZodType<T>
+2 -2
View File
@@ -1,7 +1,7 @@
export * as ConfigPermission from "./permission" export * as ConfigPermission from "./permission"
import { Schema, SchemaGetter } from "effect" import { Schema, SchemaGetter } from "effect"
import { zod } from "@opencode-ai/core/effect-zod" import { zod } from "@/util/effect-zod"
import { withStatics } from "@opencode-ai/core/schema" import { withStatics } from "@/util/schema"
export const Action = Schema.Literals(["ask", "allow", "deny"]) export const Action = Schema.Literals(["ask", "allow", "deny"])
.annotate({ identifier: "PermissionActionConfig" }) .annotate({ identifier: "PermissionActionConfig" })

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