Compare commits

..
Author SHA1 Message Date
Kit Langton 6786076f50 refactor(http-recorder): use Schema.TaggedErrorClass for errors
Matches the codebase convention (LLMError, FileSystemError, AuthError,
etc. all use Schema.TaggedErrorClass). Adds SecretFindingSchema so the
findings field on UnsafeCassetteError has a real Schema rather than
an opaque type.
2026-05-10 12:30:39 -04:00
Kit Langton e101318f92 refactor(http-recorder): hide cassette format behind Cassette seam
The Cassette interface used to expose its JSON wrapper type (Cassette =
{ version, metadata, interactions }) through read/write/scan, plus a
path() method. Consumers had to know the file format to use the service.

New surface — domain types only:

  interface Interface {
    read:   (name) => Effect<readonly Interaction[], CassetteNotFoundError>
    append: (name, interaction, metadata?) => Effect<{ findings }>
    exists: (name) => Effect<boolean>
    list:   () => Effect<readonly string[]>
  }

What's gone from the public surface: path(), write(), scan(), the
Cassette wrapper type, Entry.path, the format helpers (cassetteFor,
formatCassette, parseCassette, cassettePath). All file-private to the
new fileSystem adapter.

Two adapters:
- Cassette.fileSystem({ directory? }) — file-backed (today's behavior,
  was Cassette.layer)
- Cassette.memory(initial?) — in-memory, used by the package's own
  tests to exercise replay without disk IO. Proves the seam works;
  not user-facing.

Drive-bys from the simplify pass:
- Errors converted to Data.TaggedError (CassetteNotFoundError,
  UnsafeCassetteError) for Effect-native equality + integration.
- storage.ts (5 LOC stub) merged into cassette.ts. hasCassetteSync
  is now exported from cassette.ts directly.
- Entry { name } collapsed to plain string.
- recorder.ts re-export indirection cleaned up; index.ts pulls
  CassetteNotFoundError from cassette.ts directly.
- httpInteractions / webSocketInteractions now take ReadonlyArray<Interaction>
  rather than Cassette (matches the new read shape).

External consumer in packages/llm/test/recorded-test.ts updated
(Cassette.layer → Cassette.fileSystem).
2026-05-10 12:21:48 -04:00
opencode-agent[bot] 1c9a2eb239 chore: generate 2026-05-10 16:06:18 +00:00
Kit LangtonandGitHub 4fb417d3b5 feat(http-recorder): default mode to "auto" (#26719) 2026-05-10 16:05:11 +00:00
Kit LangtonandGitHub 11030c627b Scope boolean query overrides 2026-05-10 11:57:52 -04:00
opencode-agent[bot] c104098a66 chore: generate 2026-05-10 15:55:49 +00:00
Kit LangtonandGitHub 49ee3ba85a Source diff message query pattern (#26638) 2026-05-10 11:54:54 -04:00
opencode-agent[bot] 4fc538378d chore: generate 2026-05-10 14:50:21 +00:00
Kit LangtonandGitHub d28b5ad2f4 refactor(http-recorder): Redactor + Recorder seams, README (#26636) 2026-05-10 10:49:22 -04:00
opencode-agent[bot] 6589a66822 chore: generate 2026-05-10 12:28:11 +00:00
Shoubhit DashandGitHub 5cf9abe743 feat(scout): materialize configured reference repos (#26692) 2026-05-10 17:57:11 +05:30
Frank 903d81819d Zen: add Ring 2.6 1T 2026-05-10 03:51:34 -04:00
opencode-agent[bot] 472f9e64a6 chore: update nix node_modules hashes 2026-05-10 07:06:30 +00:00
Frank c04fa9e253 sync: revert
This reverts commit 3a7f617098.
2026-05-10 02:58:46 -04:00
opencode-agent[bot] 3a78fb1f42 chore: generate 2026-05-10 06:49:21 +00:00
Aiden ClineandGitHub 85ce6a5f95 feat: better image handling (auto resize & max size constraints) (#26401) 2026-05-10 01:48:19 -05:00
opencode-agent[bot] 5217e6c1af chore: generate 2026-05-10 06:39:09 +00:00
Frank 3a7f617098 go: add tencent icon 2026-05-10 02:37:50 -04:00
opencode-agent[bot] d9150413cb chore: generate 2026-05-10 06:24:35 +00:00
JackandGitHub bcbc1dba22 Go add hy3 preview (#26533) 2026-05-10 02:23:34 -04:00
Frank ce3235e115 sync 2026-05-10 02:17:32 -04:00
opencode-agent[bot] a9a2a597d5 chore: generate 2026-05-10 04:30:04 +00:00
DaxandGitHub 3753601f87 Format TUI paths relative to session directory (#26648) 2026-05-10 04:29:02 +00:00
Kit LangtonandGitHub fb4bab8a66 Remove redundant ID Zod overrides (#26633) 2026-05-09 23:12:21 -04:00
opencode-agent[bot] b3526f6ce9 chore: generate 2026-05-10 03:03:37 +00:00
Kit LangtonandGitHub f220f02a2f Source workspace path pattern (#26632) 2026-05-09 23:02:31 -04:00
opencode-agent[bot] 235a86fb60 chore: generate 2026-05-10 02:59:46 +00:00
Kit LangtonandGitHub 67b9c9c027 Source HTTP API ID path patterns (#26623) 2026-05-09 22:58:47 -04:00
opencode 2f11c9f7ed sync release versions for v1.14.46 2026-05-10 02:34:36 +00:00
opencode-agent[bot] e1c1193f3e chore: generate 2026-05-10 02:11:45 +00:00
Kit LangtonandGitHub 29250a0efb fix(session): loosen remaining stored numeric schemas to tolerate legacy data (#26622) 2026-05-09 22:10:48 -04:00
Kit LangtonandGitHub c6e6bdf59f fix(session): tolerate negative token counts in stored parts (#26620) 2026-05-09 22:10:44 -04:00
opencode-agent[bot] d80e1199ca chore: generate 2026-05-10 02:06:39 +00:00
Kit LangtonandGitHub 10ea59066f feat(skill): built-in opencode-meta skill (#26617) 2026-05-09 22:05:37 -04:00
Kit LangtonandGitHub 79d6b10d7c fix(mcp): tolerate output schema ref failures (#26614) 2026-05-09 22:03:59 -04:00
Kit LangtonandGitHub 6e78f36a0f Narrow HTTP API numeric query overrides (#26618) 2026-05-09 22:02:51 -04:00
Kit LangtonandGitHub 16866e1180 Share HTTP API boolean query schema (#26615) 2026-05-09 21:41:15 -04:00
opencode-agent[bot] 6d130e5deb chore: generate 2026-05-10 01:13:41 +00:00
Kit LangtonandGitHub e30d8173c1 Fix OpenAPI workspace query drift (#26609) 2026-05-09 21:12:34 -04:00
opencode 7a79f3a5ea sync release versions for v1.14.45 2026-05-10 00:07:24 +00:00
b8ca71d309 fix(task): subagent inherits parent agent's deny rules (Plan Mode security bypass) (#26597)
Co-authored-by: Developer <temp@example.com>
2026-05-09 23:51:55 +00:00
6849f96825 refactor(provider): share model status schema (#26595)
Co-authored-by: Developer <temp@example.com>
2026-05-09 19:20:31 -04:00
00c3248295 fix(config): allow active provider model status (#26592)
Co-authored-by: Developer <temp@example.com>
2026-05-09 18:59:05 -04:00
opencode-agent[bot] 818b56dbd0 chore: generate 2026-05-09 22:47:54 +00:00
Kit LangtonandGitHub 29b5b24787 fix(tui): aggregate bootstrap request failures
TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection.
2026-05-09 18:46:52 -04:00
Kit LangtonandGitHub 11363170ca fix(sdk): wrap thrown error bodies in Error
SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause.
2026-05-09 18:46:43 -04:00
Kit LangtonandGitHub ba9e4b67ed fix(tool/read): match permission patterns against worktree-relative path
Read permission checks now use the same worktree-relative path basis as edit/write/apply_patch, so configured patterns apply consistently.
2026-05-09 18:46:29 -04:00
Kit LangtonandGitHub bd1029b19f test(server): cover HttpApi context inheritance
Adds regression coverage for request context inheritance in promptAsync and explicit context provisioning in stream bodies.
2026-05-09 18:46:10 -04:00
43b51f09d0 fix(httpapi): align runtime query schemas with workspace routing params (#26581)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:50:30 -04:00
opencode-agent[bot] c61ab51886 chore: generate 2026-05-09 20:45:29 +00:00
d373c562f2 fix(session): accept legacy summary diffs (#26579)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:44:24 -04:00
opencode-agent[bot] 5fa5d876fc chore: generate 2026-05-09 20:31:32 +00:00
805af011c9 test(session): regression test for #26574 + mirror loosening on Vcs.FileDiff (#26578)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:30:31 -04:00
opencode-agent[bot] 480aa8b23b chore: generate 2026-05-09 20:24:28 +00:00
OpeOginniandGitHub d62442bb5d fix(sessions): allow optional patch field in diff for migrated sessions (#26574) 2026-05-09 16:23:28 -04:00
Kit LangtonandGitHub 8602937a37 test(session): cover workspace-routed messages (#26576) 2026-05-09 16:19:06 -04:00
77da433e0a fix(session): accept routing params in message list (#26569)
Co-authored-by: Developer <temp@example.com>
2026-05-09 16:02:35 -04:00
opencode-agent[bot] ad79f3e0cf chore: generate 2026-05-09 19:59:00 +00:00
6c2dfd2f52 fix(tui): guard messages.data in session.sync against undefined (#26566)
Co-authored-by: Developer <temp@example.com>
2026-05-09 19:58:03 +00:00
SebastianandGitHub 9a8b54fe62 Plugin command API shim (#26564) 2026-05-09 21:56:49 +02:00
DaxandGitHub dcdbdb218f Move schema utilities into core (#26565) 2026-05-09 19:51:09 +00:00
5e49029e70 fix(provider): isolate plugin model mutations (#26561)
Co-authored-by: Developer <temp@example.com>
2026-05-09 15:47:07 -04:00
opencode 19abadaf27 sync release versions for v1.14.44 2026-05-09 19:33:26 +00:00
Kit Langton 6fea0178eb fix(server): defer validation error body change 2026-05-09 14:50:45 -04:00
Kit Langton ebe9dcf27e fix(server): return validation error bodies 2026-05-09 14:39:25 -04:00
Kit LangtonandGitHub 57efec4429 fix(storage): default workspace time migration (#26556) 2026-05-09 14:18:55 -04:00
opencode e22144806f sync release versions for v1.14.43 2026-05-09 18:06:16 +00:00
249 changed files with 7412 additions and 3199 deletions
+1
View File
@@ -9,6 +9,7 @@
### General Principles
- 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 using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
+21 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -171,7 +171,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -195,7 +195,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.43",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -329,7 +329,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.43",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -412,6 +412,7 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
@@ -495,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -533,7 +534,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -548,7 +549,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -583,7 +584,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -632,7 +633,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -677,6 +678,7 @@
"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",
"@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": {
"@types/bun": "catalog:",
@@ -2035,6 +2037,8 @@
"@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=="],
"@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": {
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
}
}
+1
View File
@@ -133,6 +133,7 @@
},
"patchedDependencies": {
"@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",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.43",
"version": "1.14.46",
"description": "",
"type": "module",
"exports": {
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
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: {
canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const diffs = createMemo(() => props.diffs().filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of props.diffs()) {
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.43",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.43",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.43",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.43",
"version": "1.14.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.46",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+11
View File
@@ -1,4 +1,5 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -10,6 +11,13 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels where new experiments default to ON (unstable / internal users).
// Stable channels (`prod`, `latest`) stay opt-in.
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
function unstableDefault(key: string) {
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -48,6 +56,9 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_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_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.43",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.43",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.43"
version = "1.14.46"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.43",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+214
View File
@@ -0,0 +1,214 @@
# @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",
"version": "1.14.43",
"version": "1.14.46",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+117 -75
View File
@@ -1,54 +1,81 @@
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
import { secretFindings, type SecretFinding } from "./redaction"
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export interface Entry {
readonly name: string
readonly path: string
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()(
"CassetteNotFoundError",
{ cassetteName: Schema.String },
) {
override get message() {
return `Cassette "${this.cassetteName}" not found`
}
}
export interface AppendResult {
readonly findings: ReadonlyArray<SecretFinding>
}
export interface Interface {
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
metadata?: CassetteMetadata,
) => Effect.Effect<AppendResult>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const layer = (options: { readonly directory?: string } = {}) =>
export const hasCassetteSync = (name: string, 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(
Service,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem
const fs = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
const directoriesEnsured = new Set<string>()
const pathFor = (name: string) => cassettePath(name, directory)
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
const ensureDirectory = (name: string) =>
Effect.gen(function* () {
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const dir = path.dirname(cassettePath(name))
if (directoriesEnsured.has(dir)) return
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
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 full = path.join(directory, entry)
return fileSystem.stat(full).pipe(
const full = path.join(current, entry)
return fs.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
@@ -56,53 +83,68 @@ export const layer = (options: { readonly directory?: string } = {}) =>
return nested.flat()
})
const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
return Service.of({
read: (name) =>
fs.readFileString(cassettePath(name)).pipe(
Effect.map((raw) => parseCassette(raw).interactions),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
),
append: (name, interaction, metadata) =>
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
if (!recorded.has(name)) recorded.set(name, entry)
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
if (findings.length === 0) {
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}
return { findings }
}),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
files
.filter((file) => file.endsWith(".json"))
.map((file) => path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, ""))
.toSorted((a, b) => a.localeCompare(b)),
),
),
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
const findings = cassetteSecretFindings(cassette)
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
)
export const defaultLayer = layer()
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
export * as Cassette from "./cassette"
return Service.of({
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
@@ -1,95 +0,0 @@
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,
)
+59 -126
View File
@@ -1,62 +1,37 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option, Ref } from "effect"
import { Effect, Layer, Option } from "effect"
import {
FetchHttpClient,
Headers,
HttpBody,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
UrlParams,
} 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 { 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 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 type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
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 redactor?: Redactor
readonly dispatch?: "match" | "sequential"
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 isBinaryContentType = (contentType: string | undefined) => {
if (!contentType) return false
const lower = contentType.toLowerCase()
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
}
const isBinaryContentType = (contentType: string | undefined) =>
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
isBinaryContentType(contentType)
@@ -68,34 +43,19 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
}),
})
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
const transportError = (request: HttpClientRequest.HttpClientRequest, description: 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(", ")}`,
}),
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
})
export const recordingLayer = (
@@ -107,61 +67,22 @@ export const recordingLayer = (
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.Service
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
const redactor = options.redactor ?? defaults()
const match = options.match ?? defaultMatcher
const mode = options.mode ?? "replay"
const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
const sequential = options.dispatch === "sequential"
const replay = yield* Ref.make<Cassette | undefined>(undefined)
const cursor = yield* Ref.make(0)
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
const raw = yield* Effect.promise(() => web.text())
const body = options.redactBody
? Option.match(decodeJson(raw), {
onNone: () => raw,
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
})
: raw
return {
return redactor.request({
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
url: web.url,
headers: Object.fromEntries(web.headers.entries()),
body: yield* Effect.promise(() => web.text()),
})
})
return HttpClient.make((request) => {
@@ -169,18 +90,21 @@ export const recordingLayer = (
if (mode === "record") {
return Effect.gen(function* () {
const currentRequest = yield* snapshotRequest(request)
const incoming = yield* snapshotRequest(request)
const response = yield* upstream.execute(request)
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
const captured = yield* captureResponseBody(response, headers["content-type"])
const captured = yield* captureResponseBody(response, response.headers["content-type"])
const interaction: HttpInteraction = {
transport: "http",
request: currentRequest,
response: { status: response.status, headers, ...captured },
request: incoming,
response: redactor.response({
status: response.status,
headers: response.headers as Record<string, string>,
...captured,
}),
}
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
const findings = result.findings
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
@@ -189,14 +113,23 @@ export const recordingLayer = (
}
return Effect.gen(function* () {
const cassette = yield* loadReplay(request)
const incoming = yield* snapshotRequest(request)
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
if (!interaction) return yield* fixtureMismatch(request, name, detail)
const interactions = yield* replay.load.pipe(
Effect.mapError(() =>
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(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
)
})
})
@@ -205,7 +138,7 @@ export const recordingLayer = (
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
+23 -7
View File
@@ -1,10 +1,26 @@
export * from "./schema"
export * from "./redaction"
export * from "./matching"
export * from "./diff"
export * from "./storage"
export * from "./websocket"
export * from "./effect"
export type {
CassetteMetadata,
HttpInteraction,
Interaction,
RequestSnapshot,
ResponseSnapshot,
WebSocketFrame,
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 Redactor from "./redactor"
export * as HttpRecorder from "."
+89 -1
View File
@@ -1,5 +1,6 @@
import { Option, Schema } from "effect"
import type { RequestSnapshot } from "./schema"
import { REDACTED, secretFindings } from "./redaction"
import type { HttpInteraction, RequestSnapshot } from "./schema"
const JsonValue = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
@@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
export const defaultMatcher: RequestMatcher = (incoming, 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
@@ -0,0 +1,73 @@
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) }
})
+7 -7
View File
@@ -1,5 +1,3 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
@@ -97,10 +95,13 @@ export const redactHeaders = (
)
}
export type SecretFinding = {
readonly path: string
readonly reason: string
}
import { Schema } from "effect"
export const SecretFindingSchema = Schema.Struct({
path: Schema.String,
reason: Schema.String,
})
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [
@@ -113,4 +114,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
+76
View File
@@ -0,0 +1,76 @@
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)] : []),
)
+3 -2
View File
@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
-34
View File
@@ -1,34 +0,0 @@
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")))
}
+52 -97
View File
@@ -2,10 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
import type { RecordReplayMode } from "./effect"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
export interface WebSocketRequest {
readonly url: string
@@ -24,67 +24,36 @@ export interface WebSocketExecutor<E> {
export interface WebSocketRecordReplayOptions<E> {
readonly name: string
readonly mode?: "record" | "replay" | "passthrough"
readonly mode?: RecordReplayMode
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly redactor?: Redactor
readonly compareClientMessagesAsJson?: boolean
}
const headersRecord = (headers: Headers.Headers) =>
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
Object.fromEntries(
Object.entries(headers as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.toSorted(([a], [b]) => a.localeCompare(b)),
Object.entries(headers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
const openSnapshot = (
request: WebSocketRequest,
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
) => ({
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 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"))
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? textFrame(message)
? { kind: "text", body: 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 decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const mismatch = (message: string, actual: unknown, expected: unknown) =>
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
const decodeFrameText = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw mismatch(message, actual, expected)
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
@@ -94,7 +63,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
})
const expectedText = frameText(expected)
const expectedText = decodeFrameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
}
@@ -103,7 +72,18 @@ export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const mode = options.mode ?? "replay"
const requested = options.mode ?? "auto"
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
@@ -118,21 +98,21 @@ export const makeWebSocketExecutor = <E>(
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
const result = yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request, options), client, server },
options.metadata,
)
.pipe(Effect.orDie)
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
yield* appendOrFail(
options.cassette,
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
).pipe(Effect.orDie)
})
return {
sendText: (message: string) =>
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
sendText: (message) =>
connection
.sendText(message)
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
messages: connection.messages.pipe(
Stream.map((message) => {
server.push(receivedFrame(message))
server.push(encodeFrame(message))
return message
}),
),
@@ -142,44 +122,20 @@ export const makeWebSocketExecutor = <E>(
}
}
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
})
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
return {
open: (request) => {
return Effect.gen(function* () {
const input = yield* loadReplay()
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
const interaction = input.interactions[index]
open: (request) =>
Effect.gen(function* () {
const interactions = yield* replay.load.pipe(Effect.orDie)
const index = yield* replay.cursor
const interaction = interactions[index]
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
yield* replay.advance
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
const messageIndex = yield* Ref.make(0)
return {
sendText: (message: string) =>
sendText: (message) =>
Effect.gen(function* () {
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage(
@@ -189,7 +145,7 @@ export const makeWebSocketExecutor = <E>(
options.compareClientMessagesAsJson === true,
)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
close: Effect.gen(function* () {
yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`,
@@ -198,7 +154,6 @@ export const makeWebSocketExecutor = <E>(
)
}),
}
})
},
}),
}
})
+10
View File
@@ -0,0 +1,10 @@
/* 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 {}
+101 -67
View File
@@ -6,7 +6,19 @@ import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/diff"
import { redactedErrorRequest } from "../src/effect"
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) =>
Effect.gen(function* () {
@@ -33,7 +45,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
Effect.scoped(
effect.pipe(
Effect.provide(
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
),
Effect.provide(NodeFileSystem.layer),
),
@@ -108,7 +120,7 @@ describe("http-recorder", () => {
test("detects secret-looking values without returning the secret", () => {
expect(
HttpRecorder.cassetteSecretFindings({
HttpRecorder.secretFindings({
version: 1,
interactions: [
{
@@ -136,7 +148,7 @@ describe("http-recorder", () => {
test("detects secret-looking values inside metadata", () => {
expect(
HttpRecorder.cassetteSecretFindings({
HttpRecorder.secretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
@@ -144,60 +156,42 @@ describe("http-recorder", () => {
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("formats websocket cassettes with shared metadata", () => {
const cassette = HttpRecorder.cassetteFor(
"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" },
)
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
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* () {
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,
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" }) }],
},
],
}),
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
),
),
)
})
@@ -227,17 +221,14 @@ describe("http-recorder", () => {
yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{
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" }) }],
},
],
})
expect(yield* cassette.read("websocket/record")).toMatchObject([
{
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" }) }],
},
])
}),
)
})
@@ -300,6 +291,49 @@ 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 () => {
await run(
Effect.gen(function* () {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.46",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+10
View File
@@ -0,0 +1,10 @@
/* 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 +1,4 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError } from "../../src"
@@ -30,7 +31,7 @@ const recorded = recordedTests({
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
options: { requestHeaders: ["content-type", "anthropic-version"] },
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
})
describe("Anthropic Messages sad-path recorded", () => {
@@ -1,3 +1,4 @@
import { Redactor } from "@opencode-ai/http-recorder"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import * as Gemini from "../../src/protocols/gemini"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) =>
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
const cloudflareOptions = {
redact: { url: redactCloudflareURL },
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
}
describeRecordedGoldenScenarios([
@@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([
prefix: "anthropic-messages",
model: anthropicHaiku,
requires: ["ANTHROPIC_API_KEY"],
options: { requestHeaders: ["content-type", "anthropic-version"] },
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: ["text", "tool-call"],
},
{
@@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([
model: anthropicOpus,
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { requestHeaders: ["content-type", "anthropic-version"] },
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
scenarios: [{ id: "tool-loop", temperature: false }],
},
{
+1 -1
View File
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer),
)
const requestExecutor = RequestExecutor.layer.pipe(
+2 -3
View File
@@ -1,14 +1,13 @@
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
import { Effect, Layer } from "effect"
import { WebSocketExecutor } from "../src/route"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
const liveWebSocket = WebSocketExecutor.open
type Mode = "record" | "replay" | "passthrough"
export const webSocketCassetteLayer = (
cassette: string,
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
Layer.effect(
WebSocketExecutor.Service,
@@ -1 +1 @@
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0;
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.43",
"version": "1.14.46",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -119,6 +119,7 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
+2 -2
View File
@@ -59,10 +59,10 @@ Rules:
## 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 `@/util/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 `@opencode-ai/core/effect-zod`:
```ts
import { zod } from "@/util/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
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,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@/util/effect-zod`.
schema via `@opencode-ai/core/effect-zod`.
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
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
## Escape hatches
The walker in `@/util/effect-zod` exposes two explicit escape hatches for
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
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
comments document when each can be deleted.
@@ -389,7 +389,7 @@ piecewise.
## Notes
- Use `@/util/effect-zod` for all Schema → Zod conversion.
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more
@@ -0,0 +1,204 @@
# 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`.
@@ -0,0 +1,67 @@
# 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
+45 -46
View File
@@ -24,11 +24,9 @@ import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@/util/effect-zod"
import { withStatics, type DeepMutable } from "@/util/schema"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
import { Reference } from "@/reference/reference"
export const Info = Schema.Struct({
name: Schema.String,
@@ -303,69 +301,70 @@ export const layer = Layer.effect(
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
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) {
function referencePrompt(reference: Reference.Resolved) {
if (reference.kind === "local") {
return [
PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
`Local directory: ${reference.path}`,
`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.`,
`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")
}
return [
PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a git repository.`,
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
`Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
`Cached directory: ${reference.path}`,
`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")
}
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) {
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
if (agents[name]) continue
const resolved = resolveReference(reference)
const localPath = resolved.kind === "local" ? resolved.path : undefined
agents[name] = {
name,
description:
resolved.kind === "local"
? `Scout reference for local directory ${resolved.path}`
: `Scout reference for repository ${resolved.repository}`,
const resolvedReferences = Reference.resolveAll({
references: cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
})
for (const resolved of resolvedReferences) {
if (agents[resolved.name]) continue
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
agents[resolved.name] = {
name: resolved.name,
description: referenceDescription(resolved),
permission: Permission.merge(
agents.scout.permission,
Permission.fromConfig(
localPath
Permission.fromConfig({
repo_clone: "deny",
...(localPath
? {
external_directory: {
[localPath]: "allow",
[path.join(localPath, "*")]: "allow",
},
}
: {},
),
: {}),
}),
),
prompt: referencePrompt(name, resolved),
options: { reference },
prompt: referencePrompt(resolved),
options: { reference: cfg.reference?.[resolved.name], resolved },
mode: "subagent",
native: false,
}
@@ -0,0 +1,33 @@
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,3 +2,8 @@ declare module "*.wav" {
const file: string
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 { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+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) => ({
...item,
file: redact(`${kind}-file`, String(i), item.file),
patch: redact(`${kind}-patch`, String(i), item.patch),
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file),
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch),
}))
}
@@ -0,0 +1,34 @@
/**
* 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)
}
@@ -0,0 +1,39 @@
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,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
{ name: "config.providers", promise: providersPromise },
{ name: "provider.list", promise: providerListPromise },
{ name: "app.agents", promise: agentsPromise },
{ name: "config.get", promise: configPromise },
{ name: "project.sync", promise: projectPromise },
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
]
await Promise.all(blockingRequests)
await Promise.allSettled(blockingRequests.map((r) => r.promise))
.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 () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
@@ -526,10 +534,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages.data!.map((x) => x.info)
for (const message of messages.data!) {
const infos: (typeof draft.message)[string] = []
for (const message of messages.data ?? []) {
infos.push(message.info)
draft.part[message.info.id] = message.parts
}
draft.message[sessionID] = infos
draft.session_diff[sessionID] = diff.data ?? []
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { PositiveInt } from "@/util/schema"
import { PositiveInt } from "@opencode-ai/core/schema"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
@@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap"
import { createCommandShim } from "./command-shim"
type RouteEntry = {
key: symbol
@@ -147,7 +148,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.session.length
},
diff(sessionID) {
return sync.data.session_diff[sessionID] ?? []
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
item.file === undefined ? [] : [{ ...item, file: item.file }],
)
},
todo(sessionID) {
return sync.data.todo[sessionID] ?? []
@@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi {
}
return {
app: appApi(),
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
@@ -0,0 +1,109 @@
// 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,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin"
import { createCommandShim } from "./command-shim"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
@@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return {
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,
keymap,
route,
@@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
addDefaultParsers(parsers.parsers)
@@ -1078,199 +1078,201 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom))
return (
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<box height={1} />
<For each={messages()}>
{(message, index) => (
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
const handleUnrevert = async () => {
const confirmed = await DialogConfirm.show(
dialog,
"Confirm Redo",
"Are you sure you want to restore the reverted messages?",
)
if (confirmed) {
command.run("session.redo")
}
}
return (
<box
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={handleUnrevert}
marginTop={1}
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
</text>
<Show when={revert()!.diffFiles?.length}>
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text fg={theme.text}>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
</Show>
</text>
)}
</For>
</box>
</Show>
</box>
</box>
)
})()}
</Match>
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
<></>
</Match>
<Match when={message.role === "user"}>
<UserMessage
index={index()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={message.id}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
}}
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}
pending={pending()}
/>
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
</Match>
</Switch>
)}
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
<Show when={visible()}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={visible()}
disabled={disabled()}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={visible()}
ref={bind}
disabled={disabled()}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</TuiPluginRuntime.Slot>
</Show>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
<PathFormatterProvider path={session()?.directory}>
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<Sidebar sessionID={route.sessionID} />
<box height={1} />
<For each={messages()}>
{(message, index) => (
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
const handleUnrevert = async () => {
const confirmed = await DialogConfirm.show(
dialog,
"Confirm Redo",
"Are you sure you want to restore the reverted messages?",
)
if (confirmed) {
command.run("session.redo")
}
}
return (
<box
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={handleUnrevert}
marginTop={1}
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
</text>
<Show when={revert()!.diffFiles?.length}>
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text fg={theme.text}>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
</Show>
</text>
)}
</For>
</box>
</Show>
</box>
</box>
)
})()}
</Match>
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
<></>
</Match>
<Match when={message.role === "user"}>
<UserMessage
index={index()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={message.id}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
}}
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}
pending={pending()}
/>
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
</Match>
</Switch>
)}
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
<Show when={visible()}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={visible()}
disabled={disabled()}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={visible()}
ref={bind}
disabled={disabled()}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</TuiPluginRuntime.Slot>
</Show>
</box>
</Match>
</Switch>
</Show>
</box>
</context.Provider>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
</Show>
</box>
</context.Provider>
</PathFormatterProvider>
)
}
@@ -1827,7 +1829,7 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const sync = useSync()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
@@ -1841,18 +1843,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
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
return pathFormatter.format(workdir)
})
const title = createMemo(() => {
@@ -1894,6 +1885,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
function Write(props: ToolProps<typeof WriteTool>) {
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const code = createMemo(() => {
if (!props.input.content) return ""
return props.input.content
@@ -1902,7 +1894,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -1917,7 +1909,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {normalizePath(props.input.filePath!)}
Write {pathFormatter.format(props.input.filePath)}
</InlineTool>
</Match>
</Switch>
@@ -1925,9 +1917,10 @@ function Write(props: ToolProps<typeof WriteTool>) {
}
function Glob(props: ToolProps<typeof GlobTool>) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
<Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show>
@@ -1937,6 +1930,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
@@ -1954,13 +1948,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
spinner={isRunning()}
part={props.part}
>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {normalizePath(filepath)}
Loaded {pathFormatter.format(filepath)}
</text>
</box>
)}
@@ -1970,9 +1964,10 @@ function Read(props: ToolProps<typeof ReadTool>) {
}
function Grep(props: ToolProps<typeof GrepTool>) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
<Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show>
@@ -2071,6 +2066,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
function Edit(props: ToolProps<typeof EditTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style
@@ -2086,7 +2082,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -2113,7 +2109,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
</Switch>
@@ -2123,6 +2119,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => props.metadata.files ?? [])
@@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
return "← Patched " + file.relativePath
}
@@ -2281,20 +2278,6 @@ 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 {
const primitives = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false
@@ -11,34 +11,16 @@ import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id"
import { webSearchProviderLabel } from "@/tool/websearch"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
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) {
if (!input) return "none"
const ext = path.extname(input)
@@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
@@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filepath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Edit ${normalizePath(filepath)}`,
title: `Edit ${pathFormatter.format(filepath)}`,
body: <EditBody request={props.request} />,
}
}
@@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${normalizePath(filePath)}`,
title: `Read ${pathFormatter.format(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
</box>
</Show>
),
@@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${normalizePath(dir)}`,
title: `List ${pathFormatter.format(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
</box>
</Show>
),
@@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
const dir = pathFormatter.format(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return {
+2 -2
View File
@@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
+2 -2
View File
@@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent"
import { Exit, Schema, SchemaGetter } from "effect"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Glob } from "@opencode-ai/core/util/glob"
@@ -0,0 +1,30 @@
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 { Glob } from "@opencode-ai/core/util/glob"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
+7 -3
View File
@@ -22,9 +22,10 @@ import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath } from "../project/instance-context"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
import { ConfigAgent } from "./agent"
import { ConfigAttachment } from "./attachment"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
@@ -241,6 +242,9 @@ export const Info = Schema.Struct({
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(ConfigPermission.Info),
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(
Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
@@ -307,7 +311,7 @@ export const Info = Schema.Struct({
})),
)
// Uses the shared `DeepMutable` from `@/util/schema`. See the definition
// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition
// there for why the local variant is needed over `Types.DeepMutable` from
// effect-smol (the upstream version collapses `unknown` to `{}`).
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt } from "@opencode-ai/core/schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
+2 -2
View File
@@ -1,8 +1,8 @@
export * as ConfigFormatter from "./formatter"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const Entry = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const Layout = Schema.Literals(["auto", "stretch"])
.annotate({ identifier: "LayoutConfig" })
+2 -2
View File
@@ -1,8 +1,8 @@
export * as ConfigLSP from "./lsp"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import * as LSPServer from "../lsp/server"
export const Disabled = Schema.Struct({
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
+2 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
// 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
+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 { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
import z from "zod"
import type { DeepMutable } from "@/util/schema"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { InvalidError, JsonError } from "./error"
type ZodSchema<T> = z.ZodType<T>
+2 -2
View File
@@ -1,7 +1,7 @@
export * as ConfigPermission from "./permission"
import { Schema, SchemaGetter } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const Action = Schema.Literals(["ask", "allow", "deny"])
.annotate({ identifier: "PermissionActionConfig" })
+2 -2
View File
@@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob"
import { Schema } from "effect"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import path from "path"
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
+4 -3
View File
@@ -1,6 +1,7 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
import { ModelStatus } from "@/provider/model-status"
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
@@ -49,7 +50,7 @@ export const Model = Schema.Struct({
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
status: Schema.optional(ModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
+2 -2
View File
@@ -1,8 +1,8 @@
export * as ConfigReference from "./reference"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const Git = Schema.Struct({
repository: Schema.String.annotate({
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
export const Server = Schema.Struct({
port: Schema.optional(PositiveInt).annotate({
+2 -2
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const Info = Schema.Struct({
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
@@ -1,12 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
Schema.brand("WorkspaceID"),
)
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
export type WorkspaceID = typeof workspaceIdSchema.Type
+1 -1
View File
@@ -1,7 +1,7 @@
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import { WorkspaceID } from "./schema"
import type { DeepMutable } from "@/util/schema"
import type { DeepMutable } from "@opencode-ai/core/schema"
export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID,
@@ -43,6 +43,7 @@ import { Format } from "@/format"
import { InstanceLayer } from "@/project/instance-layer"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Reference } from "@/reference/reference"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
@@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Reference.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Pty.defaultLayer,
+2 -2
View File
@@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema"
export const Info = Schema.Struct({
path: Schema.String,
+2 -2
View File
@@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
import { which } from "@/util/which"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
+2 -2
View File
@@ -7,8 +7,8 @@ import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
import * as Formatter from "./formatter"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "format" })
-8
View File
@@ -1,4 +1,3 @@
import z from "zod"
import { randomBytes } from "crypto"
const prefixes = {
@@ -7,19 +6,12 @@ const prefixes = {
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
entry: "ent",
account: "act",
} as const
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
const LENGTH = 26
// State for monotonic ID generation
+180
View File
@@ -0,0 +1,180 @@
import { Config } from "@/config/config"
import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Schema } from "effect"
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
"ImagePhotonUnavailableError",
{},
) {
override get message() {
return "Photon image processor is unavailable"
}
}
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
url: Schema.String,
}) {
override get message() {
return "Image URL must be a base64 data URL"
}
}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
override get message() {
return "Image could not be decoded"
}
}
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
bytes: Schema.Number,
max: Schema.Number,
width: Schema.Number,
height: Schema.Number,
max_width: Schema.Number,
max_height: Schema.Number,
}) {
override get message() {
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
}
}
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
export interface Interface {
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.promise(async () => {
try {
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
.default
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
photonWasm
return await import("@silvia-odwyer/photon-node")
} catch {
return null
}
}),
)
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
const image = (yield* config.get()).attachment?.image
const info = {
autoResize: image?.auto_resize ?? AUTO_RESIZE,
maxWidth: image?.max_width ?? MAX_WIDTH,
maxHeight: image?.max_height ?? MAX_HEIGHT,
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
}
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
return yield* new InvalidDataUrlError({ url: input.url })
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
const photon = yield* loadPhoton
if (!photon) return yield* new PhotonUnavailableError()
const decoded = yield* Effect.sync(() => {
try {
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
} catch {
return undefined
}
})
if (!decoded) return yield* new DecodeError()
try {
const originalWidth = decoded.get_width()
const originalHeight = decoded.get_height()
if (
originalWidth <= info.maxWidth &&
originalHeight <= info.maxHeight &&
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
)
return input
if (!info.autoResize)
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
max_width: info.maxWidth,
max_height: info.maxHeight,
})
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(originalWidth * scale)),
height: Math.max(1, Math.round(originalHeight * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
}, [])) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
const candidate = [
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
]
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
.find((item) => item.bytes <= info.maxBase64Bytes)
resized.free()
if (candidate) {
log.info("using resized image", {
from_mime: input.mime,
to_mime: candidate.mime,
from: `${originalWidth}x${originalHeight}`,
to: `${size.width}x${size.height}`,
})
return {
...input,
mime: candidate.mime,
url: `data:${candidate.mime};base64,${candidate.data}`,
}
}
}
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
max_width: info.maxWidth,
max_height: info.maxHeight,
})
} finally {
decoded.free()
}
})
return Service.of({ normalize })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export * as Image from "./image"
+2 -2
View File
@@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { containsPath } from "@/project/instance-context"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
const log = Log.create({ service: "lsp" })
+4
View File
@@ -0,0 +1,4 @@
declare module "*.md" {
const content: string
export default content
}
+45 -7
View File
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ToolSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
@@ -30,12 +31,21 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod as effectZod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod as effectZod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
})
export const Resource = Schema.Struct({
name: Schema.String,
uri: Schema.String,
@@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) {
log.warn("invalid remote mcp url", { key })
}
function isOutputSchemaValidationError(error: Error) {
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
}
function listTools(key: string, client: MCPClient, timeout: number) {
return Effect.tryPromise({
try: () => client.listTools(undefined, { timeout }),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((error) => {
if (!isOutputSchemaValidationError(error)) return Effect.fail(error)
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
return Effect.tryPromise({
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) =>
result.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
),
)
}),
)
}
// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema
@@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
}
function defs(key: string, client: MCPClient, timeout?: number) {
return Effect.tryPromise({
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined)
+2 -2
View File
@@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { zod } from "@/util/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import * as Log from "@opencode-ai/core/util/log"
import { withStatics } from "@/util/schema"
import { withStatics } from "@opencode-ai/core/schema"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os"
+3 -3
View File
@@ -1,12 +1,12 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { Newtype } from "@opencode-ai/core/schema"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
+4 -1
View File
@@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { Service } from "./bootstrap-service"
import { Reference } from "@/reference/reference"
export { Service } from "./bootstrap-service"
export type { Interface } from "./bootstrap-service"
@@ -29,6 +30,7 @@ export const layer = Layer.effect(
const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service
const project = yield* Project.Service
const reference = yield* Reference.Service
const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service
const vcs = yield* Vcs.Service
@@ -43,7 +45,7 @@ export const layer = Layer.effect(
// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.
yield* Effect.forEach(
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.withSpan("InstanceBootstrap.init"))
@@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
LSP.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
Reference.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
Vcs.defaultLayer,
+2 -2
View File
@@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import { serviceUse } from "@/effect/service-use"
const log = Log.create({ service: "project" })
+2 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
+10 -7
View File
@@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { zod, zodObject } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { zod, zodObject } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
@@ -230,9 +230,12 @@ export type Info = Schema.Schema.Type<typeof Info>
export const FileDiff = Schema.Struct({
file: Schema.String,
patch: Schema.String,
additions: NonNegativeInt,
deletions: NonNegativeInt,
// Mirrors Snapshot.FileDiff (see #26574). The current producer always
// populates patch, but loosening matches the sibling schema so a
// future code path that omits it can't crash /instance/vcs/diff.
patch: Schema.optional(Schema.String),
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
})
.annotate({ identifier: "VcsFileDiff" })
@@ -241,8 +244,8 @@ export type FileDiff = Schema.Schema.Type<typeof FileDiff>
export const FileStatus = Schema.Struct({
file: Schema.String,
additions: NonNegativeInt,
deletions: NonNegativeInt,
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "VcsFileStatus" })
+2 -2
View File
@@ -1,9 +1,9 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { zod } from "@/util/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error"
import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
@@ -0,0 +1,9 @@
import { Schema } from "effect"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type
export * as ProviderModelStatus from "./model-status"
+2 -1
View File
@@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { CatalogModelStatus } from "./model-status"
const Cost = Schema.Struct({
input: Schema.Finite,
@@ -71,7 +72,7 @@ export const Model = Schema.Struct({
),
}),
),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
status: Schema.optional(CatalogModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
+5 -4
View File
@@ -13,7 +13,7 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import { zod } from "@/util/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error"
import { iife } from "@/util/iife"
import { Global } from "@opencode-ai/core/global"
@@ -24,10 +24,11 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ModelStatus } from "./model-status"
const log = Log.create({ service: "provider" })
@@ -897,7 +898,7 @@ export const Model = Schema.Struct({
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
status: ModelStatus,
options: Schema.Record(Schema.String, Schema.Any),
headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String,
@@ -1162,7 +1163,7 @@ const layer: Layer.Layer<
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
provider.models = yield* Effect.promise(async () => {
const next = await models(provider, { auth: pluginAuth })
const next = await models(toPublicInfo(provider), { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
+2 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
+2 -2
View File
@@ -10,8 +10,8 @@ import type { Proc } from "#pty"
import * as Log from "@opencode-ai/core/util/log"
import { PtyID } from "./schema"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "pty" })

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