Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa35180ab | ||
|
|
4fdeb5aac2 |
@@ -9,7 +9,6 @@
|
||||
### 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()`
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"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.46",
|
||||
"version": "1.14.44",
|
||||
"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.46",
|
||||
"version": "1.14.44",
|
||||
"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.46",
|
||||
"version": "1.14.44",
|
||||
"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.46",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"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.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -412,7 +412,6 @@
|
||||
"@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",
|
||||
@@ -496,7 +495,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -534,7 +533,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -549,7 +548,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -584,7 +583,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -633,7 +632,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -678,7 +677,6 @@
|
||||
"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:",
|
||||
@@ -2037,8 +2035,6 @@
|
||||
|
||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||
|
||||
"@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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
|
||||
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
|
||||
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
|
||||
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
|
||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"packageManager": "bun@1.3.13",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts --demo",
|
||||
"dev:run-demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts run --interactive --demo",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
@@ -135,7 +133,6 @@
|
||||
},
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -11,13 +10,6 @@ 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
|
||||
@@ -56,9 +48,6 @@ 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,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.46"
|
||||
version = "1.14.44"
|
||||
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.46/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.46/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.46/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/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.46/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# @opencode-ai/http-recorder
|
||||
|
||||
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
|
||||
exercise real request shapes against deterministic, version-controlled
|
||||
cassettes — no manual mocks, no flakes from upstream drift.
|
||||
|
||||
## Install
|
||||
|
||||
Internal package; depended on as `@opencode-ai/http-recorder` from another
|
||||
workspace package.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
|
||||
runs replay from it.
|
||||
|
||||
```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
|
||||
})
|
||||
|
||||
// Replay (default). Fails if the cassette is missing.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||
|
||||
// Record. Hits the upstream and writes the cassette.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||
```
|
||||
|
||||
Set the mode from the environment in your test setup:
|
||||
|
||||
```ts
|
||||
HttpRecorder.cassetteLayer("users/get-one", {
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
})
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------------- | -------------------------------------------------------------------- |
|
||||
| `replay` | Default. 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", {
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
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",
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
cassette,
|
||||
live: liveExecutor,
|
||||
})
|
||||
// use executor.open(...)
|
||||
})
|
||||
```
|
||||
|
||||
## Inspecting cassettes programmatically
|
||||
|
||||
`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and
|
||||
`scan` (re-running the secret detector over an existing cassette). 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 findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
|
||||
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
|
||||
)
|
||||
return findings.filter((r) => r.findings.length > 0)
|
||||
})
|
||||
```
|
||||
|
||||
## Options reference
|
||||
|
||||
```ts
|
||||
type RecordReplayOptions = {
|
||||
mode?: "record" | "replay" | "passthrough" // default: "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,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
|
||||
import * as path from "node:path"
|
||||
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
|
||||
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
||||
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
||||
|
||||
@@ -37,18 +37,10 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
Effect.gen(function* () {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const directoriesEnsured = new Set<string>()
|
||||
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
|
||||
|
||||
const pathFor = (name: string) => cassettePath(name, directory)
|
||||
|
||||
const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
|
||||
const dir = path.dirname(pathFor(name))
|
||||
if (directoriesEnsured.has(dir)) return
|
||||
yield* fileSystem.makeDirectory(dir, { recursive: true })
|
||||
directoriesEnsured.add(dir)
|
||||
})
|
||||
|
||||
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fileSystem
|
||||
@@ -69,7 +61,7 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
})
|
||||
|
||||
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
|
||||
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
|
||||
})
|
||||
|
||||
@@ -78,12 +70,11 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
entry.interactions.push(interaction)
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
recorded.set(name, entry)
|
||||
const cassette = cassetteFor(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
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 }
|
||||
})
|
||||
@@ -111,3 +102,7 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer()
|
||||
|
||||
export * as Cassette from "./cassette"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Option } from "effect"
|
||||
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
|
||||
import { decodeJson } from "./matching"
|
||||
import { REDACTED, redactUrl, secretFindings } from "./redaction"
|
||||
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
|
||||
if (!text) return String(value)
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (
|
||||
expected &&
|
||||
received &&
|
||||
typeof expected === "object" &&
|
||||
typeof received === "object" &&
|
||||
!Array.isArray(expected) &&
|
||||
!Array.isArray(received)
|
||||
) {
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) =>
|
||||
valueDiffs(
|
||||
(expected as Record<string, unknown>)[key],
|
||||
(received as Record<string, unknown>)[key],
|
||||
`${base}.${key}`,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||
if (expected[key] === received[key]) return []
|
||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||
})
|
||||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
|
||||
const lines = []
|
||||
if (expected.method !== received.method) {
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
const receivedBody = jsonBody(received.body)
|
||||
const body =
|
||||
expectedBody !== undefined && receivedBody !== undefined
|
||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||
: expected.body === received.body
|
||||
? []
|
||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||
if (body.length > 0) lines.push("body:", ...body)
|
||||
return lines
|
||||
}
|
||||
|
||||
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
|
||||
const interactions = httpInteractions(cassette)
|
||||
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
||||
const ranked = interactions
|
||||
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
||||
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
||||
const best = ranked[0]
|
||||
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
||||
}
|
||||
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
@@ -1,21 +1,26 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect, Layer, Option, Ref } 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 } 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"
|
||||
|
||||
@@ -23,15 +28,35 @@ export interface RecordReplayOptions {
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly directory?: string
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly redactor?: Redactor
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly responseHeaders?: ReadonlyArray<string>
|
||||
readonly redactBody?: (body: unknown) => unknown
|
||||
readonly dispatch?: "match" | "sequential"
|
||||
readonly 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) =>
|
||||
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
|
||||
const isBinaryContentType = (contentType: string | undefined) => {
|
||||
if (!contentType) return false
|
||||
const lower = contentType.toLowerCase()
|
||||
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
|
||||
}
|
||||
|
||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||
isBinaryContentType(contentType)
|
||||
@@ -43,19 +68,34 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
|
||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
|
||||
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
||||
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
|
||||
}),
|
||||
})
|
||||
|
||||
const unsafeCassette = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
name: string,
|
||||
findings: ReadonlyArray<SecretFinding>,
|
||||
) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: redactedErrorRequest(request),
|
||||
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
}),
|
||||
})
|
||||
|
||||
export const recordingLayer = (
|
||||
@@ -67,21 +107,61 @@ export const recordingLayer = (
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const cassetteService = yield* CassetteService.Service
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
|
||||
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
|
||||
const match = options.match ?? defaultMatcher
|
||||
const mode = options.mode ?? "replay"
|
||||
const sequential = options.dispatch === "sequential"
|
||||
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||
const replay = yield* Ref.make<Cassette | undefined>(undefined)
|
||||
const cursor = yield* Ref.make(0)
|
||||
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
return redactor.request({
|
||||
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 {
|
||||
method: web.method,
|
||||
url: web.url,
|
||||
headers: Object.fromEntries(web.headers.entries()),
|
||||
body: yield* Effect.promise(() => web.text()),
|
||||
})
|
||||
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
|
||||
headers: redactHeaders(
|
||||
Object.fromEntries(web.headers.entries()),
|
||||
requestHeadersAllow,
|
||||
options.redact?.headers,
|
||||
),
|
||||
body,
|
||||
}
|
||||
})
|
||||
|
||||
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
|
||||
Effect.gen(function* () {
|
||||
const interactions = httpInteractions(cassette)
|
||||
if (sequential) {
|
||||
const index = yield* Ref.get(cursor)
|
||||
const interaction = interactions[index]
|
||||
if (!interaction)
|
||||
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request)) {
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
}
|
||||
yield* Ref.update(cursor, (n) => n + 1)
|
||||
return { interaction, detail: "" }
|
||||
}
|
||||
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
|
||||
})
|
||||
|
||||
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* Ref.get(replay)
|
||||
if (cached) return cached
|
||||
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
|
||||
yield* Ref.set(replay, cassette)
|
||||
return cassette
|
||||
})
|
||||
|
||||
return HttpClient.make((request) => {
|
||||
@@ -89,21 +169,18 @@ export const recordingLayer = (
|
||||
|
||||
if (mode === "record") {
|
||||
return Effect.gen(function* () {
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const currentRequest = yield* snapshotRequest(request)
|
||||
const response = yield* upstream.execute(request)
|
||||
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
|
||||
const captured = yield* captureResponseBody(response, headers["content-type"])
|
||||
const interaction: HttpInteraction = {
|
||||
transport: "http",
|
||||
request: incoming,
|
||||
response: redactor.response({
|
||||
status: response.status,
|
||||
headers: response.headers as Record<string, string>,
|
||||
...captured,
|
||||
}),
|
||||
request: currentRequest,
|
||||
response: { status: response.status, headers, ...captured },
|
||||
}
|
||||
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
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)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
@@ -112,21 +189,14 @@ export const recordingLayer = (
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const cassette = yield* loadReplay(request)
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const interactions = yield* replay.load.pipe(
|
||||
Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)),
|
||||
)
|
||||
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
|
||||
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
|
||||
if (!interaction) return yield* fixtureMismatch(request, name, detail)
|
||||
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
export type {
|
||||
CassetteMetadata,
|
||||
HttpInteraction,
|
||||
Interaction,
|
||||
RequestSnapshot,
|
||||
ResponseSnapshot,
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { hasCassetteSync } from "./storage"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { cassetteSecretFindings, redactHeaders, redactUrl, 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 * from "./schema"
|
||||
export * from "./redaction"
|
||||
export * from "./matching"
|
||||
export * from "./diff"
|
||||
export * from "./storage"
|
||||
export * from "./websocket"
|
||||
export * from "./effect"
|
||||
export * as Cassette from "./cassette"
|
||||
export * as Redactor from "./redactor"
|
||||
|
||||
export * as HttpRecorder from "."
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { REDACTED, secretFindings } from "./redaction"
|
||||
import type { HttpInteraction, RequestSnapshot } from "./schema"
|
||||
import type { RequestSnapshot } from "./schema"
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
@@ -35,90 +34,3 @@ 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: "" }
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Effect, PlatformError, Ref, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Error {
|
||||
readonly _tag = "UnsafeCassetteError"
|
||||
constructor(
|
||||
readonly cassetteName: string,
|
||||
readonly findings: ReadonlyArray<SecretFinding>,
|
||||
) {
|
||||
super(
|
||||
`Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<Cassette, UnsafeCassetteError> =>
|
||||
cassette.append(name, interaction, metadata).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap(({ cassette: result, findings }) =>
|
||||
findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
readonly advance: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const makeReplayState = <T>(
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
project: (cassette: Cassette) => 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) }
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Option } from "effect"
|
||||
import { decodeJson } from "./matching"
|
||||
import { redactHeaders, redactUrl } from "./redaction"
|
||||
import type { RequestSnapshot, ResponseSnapshot } from "./schema"
|
||||
|
||||
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
||||
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export interface Redactor {
|
||||
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
||||
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
||||
}
|
||||
|
||||
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
||||
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
|
||||
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
|
||||
return {
|
||||
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
|
||||
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeaderOptions {
|
||||
readonly allow?: ReadonlyArray<string>
|
||||
readonly redact?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
||||
response: (snapshot) => ({
|
||||
...snapshot,
|
||||
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface UrlOptions {
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly transform?: (url: string) => string
|
||||
}
|
||||
|
||||
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
||||
})
|
||||
|
||||
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
|
||||
request: (snapshot) => ({
|
||||
...snapshot,
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: (parsed) => JSON.stringify(transform(parsed)),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export interface DefaultRedactorOverrides {
|
||||
readonly requestHeaders?: HeaderOptions
|
||||
readonly responseHeaders?: HeaderOptions
|
||||
readonly url?: UrlOptions
|
||||
readonly body?: (parsed: unknown) => unknown
|
||||
}
|
||||
|
||||
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
|
||||
compose(
|
||||
requestHeaders(overrides.requestHeaders),
|
||||
responseHeaders(overrides.responseHeaders),
|
||||
url(overrides.url),
|
||||
...(overrides.body ? [body(overrides.body)] : []),
|
||||
)
|
||||
@@ -7,13 +7,19 @@ export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtu
|
||||
|
||||
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: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||
metadata: metadataFor(name, metadata),
|
||||
interactions,
|
||||
})
|
||||
|
||||
|
||||
@@ -2,9 +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 { appendOrFail, makeReplayState } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
|
||||
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"]
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
@@ -27,32 +28,63 @@ export interface WebSocketRecordReplayOptions<E> {
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly cassette: CassetteService.Interface
|
||||
readonly live: WebSocketExecutor<E>
|
||||
readonly redactor?: Redactor
|
||||
readonly redact?: {
|
||||
readonly headers?: ReadonlyArray<string>
|
||||
readonly query?: ReadonlyArray<string>
|
||||
readonly url?: (url: string) => string
|
||||
}
|
||||
readonly requestHeaders?: ReadonlyArray<string>
|
||||
readonly compareClientMessagesAsJson?: boolean
|
||||
}
|
||||
|
||||
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
|
||||
const headersRecord = (headers: Headers.Headers) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(headers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
Object.entries(headers as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.toSorted(([a], [b]) => a.localeCompare(b)),
|
||||
)
|
||||
|
||||
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||
typeof message === "string"
|
||||
? { kind: "text", body: message }
|
||||
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||
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 decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
|
||||
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
|
||||
|
||||
const frameText = (frame: WebSocketFrame) => {
|
||||
if (frame.kind === "text") return frame.body
|
||||
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||
}
|
||||
|
||||
const frameMessage = (frame: WebSocketFrame) =>
|
||||
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
|
||||
|
||||
const decodeFrameText = (frame: WebSocketFrame) =>
|
||||
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
|
||||
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
|
||||
typeof message === "string"
|
||||
? textFrame(message)
|
||||
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
|
||||
|
||||
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
new Error(
|
||||
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
|
||||
.map((item) => `${item.path} (${item.reason})`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
|
||||
const mismatch = (message: string, actual: unknown, expected: unknown) =>
|
||||
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
|
||||
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
|
||||
Effect.sync(() => {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) return
|
||||
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
|
||||
throw mismatch(message, actual, expected)
|
||||
})
|
||||
|
||||
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
|
||||
@@ -62,7 +94,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
|
||||
return Effect.sync(() => {
|
||||
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
|
||||
})
|
||||
const expectedText = decodeFrameText(expected)
|
||||
const expectedText = frameText(expected)
|
||||
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
|
||||
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
|
||||
}
|
||||
@@ -72,16 +104,6 @@ export const makeWebSocketExecutor = <E>(
|
||||
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const mode = options.mode ?? "replay"
|
||||
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
|
||||
|
||||
@@ -96,21 +118,21 @@ export const makeWebSocketExecutor = <E>(
|
||||
const closeOnce = Effect.gen(function* () {
|
||||
if (yield* Ref.getAndSet(closed, true)) return
|
||||
yield* connection.close
|
||||
yield* appendOrFail(
|
||||
options.cassette,
|
||||
options.name,
|
||||
{ transport: "websocket", open: openSnapshot(request), client, server },
|
||||
options.metadata,
|
||||
).pipe(Effect.orDie)
|
||||
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))
|
||||
})
|
||||
return {
|
||||
sendText: (message) =>
|
||||
connection
|
||||
.sendText(message)
|
||||
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
|
||||
sendText: (message: string) =>
|
||||
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
|
||||
messages: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
server.push(encodeFrame(message))
|
||||
server.push(receivedFrame(message))
|
||||
return message
|
||||
}),
|
||||
),
|
||||
@@ -120,20 +142,44 @@ export const makeWebSocketExecutor = <E>(
|
||||
}
|
||||
}
|
||||
|
||||
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
|
||||
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const cursor = yield* Ref.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* Ref.get(replay)
|
||||
if (!input) return
|
||||
yield* assertEqual(
|
||||
`Unused recorded WebSocket interactions in ${options.name}`,
|
||||
yield* Ref.get(cursor),
|
||||
input.interactions.length,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
|
||||
const cached = yield* Ref.get(replay)
|
||||
if (cached) return cached
|
||||
const input = {
|
||||
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
|
||||
}
|
||||
yield* Ref.set(replay, input)
|
||||
return input
|
||||
})
|
||||
|
||||
return {
|
||||
open: (request) =>
|
||||
Effect.gen(function* () {
|
||||
const interactions = yield* replay.load.pipe(Effect.orDie)
|
||||
const index = yield* replay.cursor
|
||||
const interaction = interactions[index]
|
||||
open: (request) => {
|
||||
return Effect.gen(function* () {
|
||||
const input = yield* loadReplay()
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
const interaction = input.interactions[index]
|
||||
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
|
||||
yield* replay.advance
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
|
||||
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
|
||||
const messageIndex = yield* Ref.make(0)
|
||||
return {
|
||||
sendText: (message) =>
|
||||
sendText: (message: string) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
|
||||
yield* compareClientMessage(
|
||||
@@ -143,7 +189,7 @@ export const makeWebSocketExecutor = <E>(
|
||||
options.compareClientMessagesAsJson === true,
|
||||
)
|
||||
}),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
|
||||
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
|
||||
close: Effect.gen(function* () {
|
||||
yield* assertEqual(
|
||||
`WebSocket client frame count for interaction ${index + 1}`,
|
||||
@@ -152,6 +198,7 @@ export const makeWebSocketExecutor = <E>(
|
||||
)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -6,8 +6,7 @@ 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/effect"
|
||||
import { cassetteFor, formatCassette, parseCassette } from "../src/storage"
|
||||
import { redactedErrorRequest } from "../src/diff"
|
||||
|
||||
const post = (url: string, body: object) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -146,7 +145,7 @@ describe("http-recorder", () => {
|
||||
})
|
||||
|
||||
test("formats websocket cassettes with shared metadata", () => {
|
||||
const cassette = cassetteFor(
|
||||
const cassette = HttpRecorder.cassetteFor(
|
||||
"websocket/basic",
|
||||
[
|
||||
{
|
||||
@@ -160,7 +159,7 @@ describe("http-recorder", () => {
|
||||
)
|
||||
|
||||
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
|
||||
expect(parseCassette(formatCassette(cassette))).toEqual(cassette)
|
||||
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
|
||||
})
|
||||
|
||||
test("replays websocket interactions from the shared cassette service", async () => {
|
||||
@@ -169,7 +168,7 @@ describe("http-recorder", () => {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* cassette.write(
|
||||
"websocket/replay",
|
||||
cassetteFor(
|
||||
HttpRecorder.cassetteFor(
|
||||
"websocket/replay",
|
||||
[
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
@@ -31,7 +30,7 @@ const recorded = recordedTests({
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
})
|
||||
|
||||
describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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"
|
||||
@@ -67,7 +66,7 @@ const redactCloudflareURL = (url: string) =>
|
||||
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
|
||||
|
||||
const cloudflareOptions = {
|
||||
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
|
||||
redact: { url: redactCloudflareURL },
|
||||
}
|
||||
|
||||
describeRecordedGoldenScenarios([
|
||||
@@ -103,7 +102,7 @@ describeRecordedGoldenScenarios([
|
||||
prefix: "anthropic-messages",
|
||||
model: anthropicHaiku,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
scenarios: ["text", "tool-call"],
|
||||
},
|
||||
{
|
||||
@@ -112,7 +111,7 @@ describeRecordedGoldenScenarios([
|
||||
model: anthropicOpus,
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
tags: ["flagship"],
|
||||
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
|
||||
options: { requestHeaders: ["content-type", "anthropic-version"] },
|
||||
scenarios: [{ id: "tool-loop", temperature: false }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.44",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -13,8 +13,6 @@
|
||||
"build": "bun run script/build.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"dev:demo": "bun run --conditions=browser ./src/index.ts --demo",
|
||||
"dev:run-demo": "bun run --conditions=browser ./src/index.ts run --interactive --demo",
|
||||
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
|
||||
"db": "bun drizzle-kit"
|
||||
},
|
||||
@@ -121,7 +119,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
# OpenAPI Translation Cleanup Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
|
||||
|
||||
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
|
||||
|
||||
## Current Culprit
|
||||
|
||||
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
|
||||
|
||||
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
|
||||
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
|
||||
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
|
||||
- Prefer endpoint or schema annotations over post-generation spec surgery.
|
||||
- Remove one category of rewrite at a time, with focused compatibility checks.
|
||||
|
||||
## PR Checklist
|
||||
|
||||
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
|
||||
|
||||
Current combined PR scope:
|
||||
|
||||
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
|
||||
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
|
||||
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
|
||||
|
||||
### PR 1: Add OpenAPI/Runtime Query Drift Tests
|
||||
|
||||
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
|
||||
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
|
||||
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
|
||||
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
|
||||
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
|
||||
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
|
||||
- `[x]` Keep this part test-only.
|
||||
|
||||
Verification:
|
||||
|
||||
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 2: Delete Spec-Only Workspace Query Injection
|
||||
|
||||
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||
- `[x]` Delete `InstanceQueryParameters`.
|
||||
- `[x]` Delete the `isInstanceRoute` constant.
|
||||
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
|
||||
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
|
||||
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
|
||||
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
|
||||
|
||||
Notes:
|
||||
|
||||
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
|
||||
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
|
||||
|
||||
Expected code shape:
|
||||
|
||||
```ts
|
||||
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||
```
|
||||
|
||||
Verification:
|
||||
|
||||
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
|
||||
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
|
||||
|
||||
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
|
||||
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
|
||||
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
|
||||
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
|
||||
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
|
||||
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
|
||||
|
||||
Verification:
|
||||
|
||||
- Focused HTTP tests for changed query fields.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK request param types before deleting each override.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 4: Move Path Parameter Patterns Into ID Schemas
|
||||
|
||||
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `[x]` `sessionID`
|
||||
- `[x]` `messageID`
|
||||
- `[x]` `partID`
|
||||
- `[x]` `permissionID`
|
||||
- `[x]` `ptyID`
|
||||
|
||||
- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated path param types and patterns.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
|
||||
|
||||
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
|
||||
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
|
||||
- Update handlers to fail with the declared API errors at the boundary.
|
||||
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
|
||||
- Do this group by group, starting with one small route group.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `groups/config.ts` `PATCH /config` bad request.
|
||||
- `groups/session.ts` endpoints that already translate domain not-found errors.
|
||||
- `groups/file.ts` if any handler currently relies on built-in error shape.
|
||||
|
||||
Verification:
|
||||
|
||||
- Focused HTTP tests asserting response body shape for changed error paths.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect SDK error union diff.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
|
||||
|
||||
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
|
||||
- Decide whether SDK should expose auth in generated operation metadata.
|
||||
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
|
||||
- If removing it, update SDK generation expectations and docs in the same PR.
|
||||
|
||||
Verification:
|
||||
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated client call signatures and error unions.
|
||||
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
|
||||
|
||||
### PR 7: Tackle Component Shape Rewrites One At A Time
|
||||
|
||||
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
|
||||
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
|
||||
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
|
||||
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
|
||||
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK type-name and optionality diffs.
|
||||
|
||||
## Upstream Middleware Query Support
|
||||
|
||||
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
|
||||
|
||||
Target in `effect-smol`:
|
||||
|
||||
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
|
||||
- Make runtime request decoding include middleware query schemas.
|
||||
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
|
||||
|
||||
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
|
||||
|
||||
## Suggested PR Order
|
||||
|
||||
1. Add drift detection tests only.
|
||||
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
|
||||
3. Convert query type overrides into route/schema-level helpers where possible.
|
||||
4. Convert path parameter overrides into schema annotations or upstream fixes.
|
||||
5. Replace built-in error response rewrites with explicit declared API errors by route group.
|
||||
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
|
||||
|
||||
## Verification Checklist Per PR
|
||||
|
||||
- Focused HTTP tests for changed routes.
|
||||
- OpenAPI drift tests.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK diff for public API churn.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
@@ -26,7 +26,9 @@ import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
@@ -301,70 +303,69 @@ export const layer = Layer.effect(
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
function referencePrompt(reference: Reference.Resolved) {
|
||||
function referencePath(value: string) {
|
||||
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
|
||||
return path.isAbsolute(value)
|
||||
? value
|
||||
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
|
||||
}
|
||||
|
||||
function resolveReference(reference: ReferenceEntry): ResolvedReference {
|
||||
if (typeof reference === "string") {
|
||||
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
|
||||
return { kind: "local", path: referencePath(reference) }
|
||||
}
|
||||
return { kind: "git", repository: reference }
|
||||
}
|
||||
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
|
||||
return { kind: "git", repository: reference.repository, branch: reference.branch }
|
||||
}
|
||||
|
||||
function referencePrompt(name: string, reference: ResolvedReference) {
|
||||
if (reference.kind === "local") {
|
||||
return [
|
||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||
PROMPT_SCOUT,
|
||||
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
|
||||
`Local directory: ${reference.path}`,
|
||||
`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.`,
|
||||
`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.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
return [
|
||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||
PROMPT_SCOUT,
|
||||
`You are Scout reference @${name}. This reference points to a git repository.`,
|
||||
`Repository: ${reference.repository}`,
|
||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||
`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.`,
|
||||
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
|
||||
].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) {
|
||||
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),
|
||||
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}`,
|
||||
permission: Permission.merge(
|
||||
agents.scout.permission,
|
||||
Permission.fromConfig({
|
||||
repo_clone: "deny",
|
||||
...(localPath
|
||||
Permission.fromConfig(
|
||||
localPath
|
||||
? {
|
||||
external_directory: {
|
||||
[localPath]: "allow",
|
||||
[path.join(localPath, "*")]: "allow",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
: {},
|
||||
),
|
||||
),
|
||||
prompt: referencePrompt(resolved),
|
||||
options: { reference: cfg.reference?.[resolved.name], resolved },
|
||||
prompt: referencePrompt(name, resolved),
|
||||
options: { reference },
|
||||
mode: "subagent",
|
||||
native: false,
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Permission } from "../permission"
|
||||
import type { Agent } from "./agent"
|
||||
|
||||
/**
|
||||
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||
* via the task tool. Combines:
|
||||
*
|
||||
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||
* restrictions live on the agent ruleset, not on the session, so a
|
||||
* subagent that only inherited the parent SESSION's permission would
|
||||
* silently bypass them. (#26514)
|
||||
* 2. The parent **session's** deny rules and external_directory rules —
|
||||
* same forwarding the original code already did.
|
||||
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||
* doesn't already permit them.
|
||||
*/
|
||||
export function deriveSubagentSessionPermission(input: {
|
||||
parentSessionPermission: Permission.Ruleset
|
||||
parentAgent: Agent.Info | undefined
|
||||
subagent: Agent.Info
|
||||
}): Permission.Ruleset {
|
||||
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||
return [
|
||||
...parentAgentDenies,
|
||||
...input.parentSessionPermission.filter(
|
||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||
),
|
||||
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
]
|
||||
}
|
||||
Vendored
-5
@@ -2,8 +2,3 @@ declare module "*.wav" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
declare module "*.wasm" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
export const SAMPLE_MARKDOWN = [
|
||||
"# Direct Mode Demo",
|
||||
"",
|
||||
"This is a realistic assistant response for direct-mode formatting checks.",
|
||||
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"- Restored the final markdown flush so the last block is committed on idle.",
|
||||
"- Switched markdown scrollback commits back to top-level block boundaries.",
|
||||
"- Added footer-level regression coverage for split-footer rendering.",
|
||||
"",
|
||||
"## Status",
|
||||
"",
|
||||
"| Area | Before | After | Notes |",
|
||||
"| --- | --- | --- | --- |",
|
||||
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
|
||||
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
|
||||
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
|
||||
"",
|
||||
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
|
||||
"",
|
||||
"```ts",
|
||||
"const result = { markdown: true, tables: 2, stable: true }",
|
||||
"```",
|
||||
"",
|
||||
"## Files",
|
||||
"",
|
||||
"| File | Change |",
|
||||
"| --- | --- |",
|
||||
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
|
||||
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
|
||||
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
|
||||
"",
|
||||
"Next step: run `/fmt table` if you want a tighter table-only sample.",
|
||||
].join("\n")
|
||||
|
||||
export const SAMPLE_TABLE = [
|
||||
"# Table Sample",
|
||||
"",
|
||||
"| Kind | Example | Notes |",
|
||||
"| --- | --- | --- |",
|
||||
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
|
||||
"| Unicode | `漢字` | Wide characters should remain aligned |",
|
||||
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
|
||||
"| Status | done | Final row should still appear after idle |",
|
||||
].join("\n")
|
||||
|
||||
export const MARKDOWN_PATTERNS = {
|
||||
"md-code": [
|
||||
"# Interleaved Code",
|
||||
"",
|
||||
"Start with a short conclusion before any code appears.",
|
||||
"",
|
||||
"```ts",
|
||||
"export function parse(input: string) {",
|
||||
" return input.trim().split(/\\s+/)",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"Then continue with prose immediately after the code block. This should not inherit code styling or indentation.",
|
||||
"",
|
||||
"```tsx",
|
||||
"<Show when={props.enabled}>",
|
||||
" <markdown content={props.text} streaming />",
|
||||
"</Show>",
|
||||
"```",
|
||||
"",
|
||||
"Final paragraph after a second fence with `inline code`, **bold text**, and _emphasis_ mixed together.",
|
||||
].join("\n"),
|
||||
"md-fence": [
|
||||
"# Fence Boundaries",
|
||||
"",
|
||||
"The renderer should recover cleanly around multiple fences and nearby paragraphs.",
|
||||
"",
|
||||
"```bash",
|
||||
"bun run test -- --grep markdown",
|
||||
"```",
|
||||
"Text directly after a fence.",
|
||||
"```json",
|
||||
"{",
|
||||
' "status": "ok",',
|
||||
' "items": ["one", "two"]',
|
||||
"}",
|
||||
"```",
|
||||
"Trailing paragraph after JSON. The next fence intentionally has no language.",
|
||||
"```",
|
||||
"plain fenced text",
|
||||
"with multiple lines",
|
||||
"```",
|
||||
].join("\n"),
|
||||
"md-list": [
|
||||
"# Lists With Code",
|
||||
"",
|
||||
"1. First ordered item with `inline code`.",
|
||||
"2. Second ordered item before a nested list:",
|
||||
" - Nested bullet with a long phrase that should wrap without swallowing the marker or changing indentation.",
|
||||
" - Nested bullet before fenced code:",
|
||||
"",
|
||||
" ```ts",
|
||||
" const nested = true",
|
||||
" ```",
|
||||
"",
|
||||
"3. Third ordered item after the nested fence.",
|
||||
"",
|
||||
"- Top-level bullet after ordered list.",
|
||||
"- Another bullet with a paragraph below.",
|
||||
"",
|
||||
" Continuation paragraph should stay associated with the bullet without becoming code.",
|
||||
].join("\n"),
|
||||
"md-table-code": [
|
||||
"# Tables And Code",
|
||||
"",
|
||||
"| Case | Input | Expected |",
|
||||
"| --- | --- | --- |",
|
||||
"| Inline code | `const x = 1` | stays inline |",
|
||||
"| Escaped pipe | `A\\|B` | one cell |",
|
||||
"| Long token | `LongTokenWithoutNaturalBreaks_1234567890_abcdefghijklmnopqrstuvwxyz` | wraps or scrolls predictably |",
|
||||
"",
|
||||
"A code block follows the table:",
|
||||
"",
|
||||
"```ts",
|
||||
"const rows = [",
|
||||
' { case: "inline code", expected: "stays inline" },',
|
||||
' { case: "escaped pipe", expected: "one cell" },',
|
||||
"]",
|
||||
"```",
|
||||
"",
|
||||
"And then another compact table:",
|
||||
"",
|
||||
"| A | B |",
|
||||
"| - | - |",
|
||||
"| done | yes |",
|
||||
].join("\n"),
|
||||
"md-inline": [
|
||||
"# Inline Markdown",
|
||||
"",
|
||||
"This paragraph mixes [a normal link](https://opencode.ai), `https://example.com/code-link`, `inline code`, **strong**, _emphasis_, and ~~strikethrough~~.",
|
||||
"",
|
||||
"> Blockquote with `inline code` and [a link](https://example.com) should keep quote styling while wrapping.",
|
||||
"",
|
||||
"A horizontal rule follows.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"After the rule, text should resume normal spacing.",
|
||||
].join("\n"),
|
||||
"md-kitchen": [
|
||||
"# Markdown Kitchen Sink",
|
||||
"",
|
||||
"This combines headings, paragraphs, lists, blockquotes, tables, inline code, and multiple code fences.",
|
||||
"",
|
||||
"## Steps",
|
||||
"",
|
||||
"1. Read the response.",
|
||||
"2. Notice `inline code` before a block.",
|
||||
"",
|
||||
"```ts",
|
||||
"type Result = { ok: boolean; reason?: string }",
|
||||
"const result: Result = { ok: true }",
|
||||
"```",
|
||||
"",
|
||||
"3. Continue the list after the block.",
|
||||
"",
|
||||
"> Quoted note after the list. It should not merge into the previous item.",
|
||||
"",
|
||||
"| Feature | Stress |",
|
||||
"| --- | --- |",
|
||||
"| Markdown | prose/code/table interleave |",
|
||||
"| Renderer | wrapping and spacing |",
|
||||
"",
|
||||
"```diff",
|
||||
"- const renderer = oldMarkdown",
|
||||
"+ const renderer = experimentalMarkdown",
|
||||
"```",
|
||||
"",
|
||||
"Final paragraph with [docs](https://opencode.ai/docs) and `https://example.com/from-code`.",
|
||||
].join("\n"),
|
||||
} as const
|
||||
|
||||
export const MARKDOWN_PATTERN_KINDS = Object.keys(MARKDOWN_PATTERNS) as Array<keyof typeof MARKDOWN_PATTERNS>
|
||||
@@ -19,11 +19,9 @@ import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||
import { MARKDOWN_PATTERN_KINDS, MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
|
||||
|
||||
const KINDS = [
|
||||
"markdown",
|
||||
...MARKDOWN_PATTERN_KINDS,
|
||||
"table",
|
||||
"text",
|
||||
"reasoning",
|
||||
@@ -53,9 +51,53 @@ function questionKind(value: string | undefined): QuestionKind | undefined {
|
||||
return QUESTIONS.find((item) => item === next)
|
||||
}
|
||||
|
||||
function markdownPattern(value: string): keyof typeof MARKDOWN_PATTERNS | undefined {
|
||||
if (value in MARKDOWN_PATTERNS) return value as keyof typeof MARKDOWN_PATTERNS
|
||||
}
|
||||
const SAMPLE_MARKDOWN = [
|
||||
"# Direct Mode Demo",
|
||||
"",
|
||||
"This is a realistic assistant response for direct-mode formatting checks.",
|
||||
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"- Restored the final markdown flush so the last block is committed on idle.",
|
||||
"- Switched markdown scrollback commits back to top-level block boundaries.",
|
||||
"- Added footer-level regression coverage for split-footer rendering.",
|
||||
"",
|
||||
"## Status",
|
||||
"",
|
||||
"| Area | Before | After | Notes |",
|
||||
"| --- | --- | --- | --- |",
|
||||
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
|
||||
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
|
||||
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
|
||||
"",
|
||||
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
|
||||
"",
|
||||
"```ts",
|
||||
"const result = { markdown: true, tables: 2, stable: true }",
|
||||
"```",
|
||||
"",
|
||||
"## Files",
|
||||
"",
|
||||
"| File | Change |",
|
||||
"| --- | --- |",
|
||||
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
|
||||
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
|
||||
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
|
||||
"",
|
||||
"Next step: run `/fmt table` if you want a tighter table-only sample.",
|
||||
].join("\n")
|
||||
|
||||
const SAMPLE_TABLE = [
|
||||
"# Table Sample",
|
||||
"",
|
||||
"| Kind | Example | Notes |",
|
||||
"| --- | --- | --- |",
|
||||
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
|
||||
"| Unicode | `漢字` | Wide characters should remain aligned |",
|
||||
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
|
||||
"| Status | done | Final row should still appear after idle |",
|
||||
].join("\n")
|
||||
|
||||
type Ref = {
|
||||
msg: string
|
||||
@@ -989,12 +1031,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
|
||||
return true
|
||||
}
|
||||
|
||||
const pattern = markdownPattern(kind)
|
||||
if (pattern) {
|
||||
await emitText(state, body || MARKDOWN_PATTERNS[pattern], signal)
|
||||
return true
|
||||
}
|
||||
|
||||
if (kind === "table") {
|
||||
await emitText(state, body || SAMPLE_TABLE, signal)
|
||||
return true
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// 4. runs the prompt queue until the footer closes.
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { createRunDemo } from "./demo"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||
@@ -135,10 +135,6 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
||||
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
||||
}
|
||||
|
||||
async function createDemo(input: Parameters<typeof createRunDemo>[0]) {
|
||||
return (await import("./demo")).createRunDemo(input)
|
||||
}
|
||||
|
||||
async function resolveExitTitle(
|
||||
ctx: BootContext,
|
||||
input: RunRuntimeInput,
|
||||
@@ -429,7 +425,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
|
||||
if (input.demo) {
|
||||
await ensureSession()
|
||||
state.demo = await createDemo({
|
||||
state.demo = createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
@@ -552,7 +548,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
state.history = []
|
||||
includeFiles = true
|
||||
state.demo = input.demo
|
||||
? await createDemo({
|
||||
? createRunDemo({
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Aggregate Promise.allSettled results into a single Error that names every
|
||||
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||
* boundaries so a single 4xx doesn't drown its parallel siblings as
|
||||
* unhandled rejections — every failure surfaces in one labeled message.
|
||||
*/
|
||||
export type LabeledSettled = {
|
||||
name: string
|
||||
result: PromiseSettledResult<unknown>
|
||||
}
|
||||
|
||||
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
const failed = labeled.filter(
|
||||
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
|
||||
)
|
||||
if (failed.length === 0) return null
|
||||
|
||||
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
|
||||
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||
const err = new Error(summary)
|
||||
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||
return err
|
||||
}
|
||||
|
||||
function reasonMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message
|
||||
if (typeof reason === "string") return reason
|
||||
if (reason && typeof reason === "object") {
|
||||
const obj = reason as { message?: unknown; name?: unknown }
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.name === "string") return obj.name
|
||||
}
|
||||
return String(reason)
|
||||
}
|
||||
@@ -15,13 +15,17 @@ export function useEvent() {
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
// Workspace-scoped events match on workspace identity. Events without a
|
||||
// workspace label fall through to the directory check — a session with
|
||||
// no workspaceID can be live in the same directory as the TUI even when
|
||||
// the TUI itself is attached to a workspace (#26671).
|
||||
if (event.workspace !== undefined) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import path from "path"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
const context = createContext<{
|
||||
path: () => string
|
||||
format: (input?: string) => string
|
||||
}>()
|
||||
|
||||
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
|
||||
return (
|
||||
<context.Provider
|
||||
value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePathFormatter() {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
function formatPath(input: string | undefined, base: string | undefined) {
|
||||
if (!input) return ""
|
||||
|
||||
const root = base || process.cwd()
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
|
||||
const relative = path.relative(root, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
|
||||
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
|
||||
return absolute.replace(Global.Path.home, "~")
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||
import path from "path"
|
||||
import { useKV } from "./kv"
|
||||
import { aggregateFailures } from "./aggregate-failures"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -392,23 +391,16 @@ 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: { 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 }] : []),
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]
|
||||
|
||||
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
|
||||
})
|
||||
await Promise.all(blockingRequests)
|
||||
.then(async () => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
|
||||
@@ -962,8 +962,6 @@ function getSyntaxRules(theme: Theme) {
|
||||
style: {
|
||||
foreground: theme.markdownHeading,
|
||||
bold: true,
|
||||
italic: true,
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1177,7 +1175,6 @@ function getSyntaxRules(theme: Theme) {
|
||||
scope: ["markup.strikethrough"],
|
||||
style: {
|
||||
foreground: theme.textMuted,
|
||||
strikethrough: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import type {
|
||||
Agent,
|
||||
AssistantMessage,
|
||||
Config,
|
||||
Message,
|
||||
Model,
|
||||
Part,
|
||||
Path,
|
||||
Project,
|
||||
Provider,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
|
||||
|
||||
const sessionID = "demo_tui_markdown"
|
||||
const userMessageID = "demo_tui_user"
|
||||
const assistantMessageID = "demo_tui_assistant"
|
||||
const now = Date.now()
|
||||
|
||||
const markdown = [
|
||||
"# Fullscreen TUI Markdown Demo",
|
||||
"",
|
||||
"This fake assistant response runs through the fullscreen session timeline without calling an LLM.",
|
||||
"Use it to compare spacing, wrapping, code fence boundaries, table behavior, and inline markdown rendering.",
|
||||
"",
|
||||
"## Baseline",
|
||||
"",
|
||||
SAMPLE_MARKDOWN,
|
||||
"",
|
||||
"## Table Baseline",
|
||||
"",
|
||||
SAMPLE_TABLE,
|
||||
"",
|
||||
...Object.entries(MARKDOWN_PATTERNS).flatMap(([name, value]) => ["## " + name, "", value, ""]),
|
||||
].join("\n")
|
||||
|
||||
const model = {
|
||||
id: "demo",
|
||||
providerID: "demo",
|
||||
api: {
|
||||
id: "demo",
|
||||
url: "https://example.com/demo",
|
||||
npm: "demo",
|
||||
},
|
||||
name: "Demo",
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: true,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 128_000,
|
||||
output: 16_000,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
} satisfies Model
|
||||
|
||||
const provider = {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
demo: model,
|
||||
},
|
||||
} satisfies Provider
|
||||
|
||||
const agent = {
|
||||
name: "build",
|
||||
description: "Demo agent",
|
||||
mode: "primary",
|
||||
native: true,
|
||||
permission: [],
|
||||
model: {
|
||||
providerID: "demo",
|
||||
modelID: "demo",
|
||||
},
|
||||
options: {},
|
||||
} satisfies Agent
|
||||
|
||||
function json(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTuiDemo(input: { directory: string }) {
|
||||
const paths = {
|
||||
home: process.env.HOME ?? input.directory,
|
||||
state: input.directory,
|
||||
config: input.directory,
|
||||
worktree: input.directory,
|
||||
directory: input.directory,
|
||||
} satisfies Path
|
||||
|
||||
const project = {
|
||||
id: "demo_project",
|
||||
worktree: input.directory,
|
||||
vcs: "git",
|
||||
name: "Markdown Demo",
|
||||
time: {
|
||||
created: now,
|
||||
updated: now,
|
||||
},
|
||||
sandboxes: [],
|
||||
} satisfies Project
|
||||
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "markdown-demo",
|
||||
projectID: project.id,
|
||||
directory: input.directory,
|
||||
title: "Markdown Rendering Demo",
|
||||
agent: agent.name,
|
||||
model: {
|
||||
id: model.id,
|
||||
providerID: provider.id,
|
||||
},
|
||||
version: "demo",
|
||||
time: {
|
||||
created: now,
|
||||
updated: now + 2,
|
||||
},
|
||||
} satisfies Session
|
||||
|
||||
const user = {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
agent: agent.name,
|
||||
model: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
} satisfies Message
|
||||
|
||||
const assistant = {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now + 1,
|
||||
completed: now + 2,
|
||||
},
|
||||
parentID: userMessageID,
|
||||
modelID: model.id,
|
||||
providerID: provider.id,
|
||||
mode: "demo",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: input.directory,
|
||||
root: input.directory,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 3_200,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
} satisfies AssistantMessage
|
||||
|
||||
const messages = [
|
||||
{
|
||||
info: user,
|
||||
parts: [
|
||||
{
|
||||
id: "demo_tui_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Show me the fullscreen TUI markdown rendering stress cases.",
|
||||
time: {
|
||||
start: now,
|
||||
end: now,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: assistant,
|
||||
parts: [
|
||||
{
|
||||
id: "demo_tui_assistant_text",
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: markdown,
|
||||
time: {
|
||||
start: now + 1,
|
||||
end: now + 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies Array<{ info: Message; parts: Part[] }>
|
||||
|
||||
const fetch = (async (...args: Parameters<typeof globalThis.fetch>) => {
|
||||
const request = new Request(args[0], args[1])
|
||||
const url = new URL(request.url)
|
||||
const pathname = url.pathname
|
||||
|
||||
if (request.method === "GET" && pathname === "/path") return json(paths)
|
||||
if (request.method === "GET" && pathname === "/project/current") return json(project)
|
||||
if (request.method === "GET" && pathname === "/config/providers") {
|
||||
return json({ providers: [provider], default: { build: "demo/demo" } })
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/provider") {
|
||||
return json({ all: [provider], default: { build: "demo/demo" }, connected: [provider.id] })
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/experimental/console") {
|
||||
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/agent") return json([agent])
|
||||
if (request.method === "GET" && pathname === "/config") {
|
||||
return json({ model: "demo/demo", default_agent: agent.name } satisfies Config)
|
||||
}
|
||||
if (request.method === "GET" && pathname === "/session") return json([session])
|
||||
if (request.method === "GET" && pathname === "/command") return json([])
|
||||
if (request.method === "GET" && pathname === "/lsp") return json([])
|
||||
if (request.method === "GET" && pathname === "/mcp") return json({})
|
||||
if (request.method === "GET" && pathname === "/experimental/resource") return json({})
|
||||
if (request.method === "GET" && pathname === "/formatter") return json([])
|
||||
if (request.method === "GET" && pathname === "/session/status") return json({ [sessionID]: { type: "idle" } })
|
||||
if (request.method === "GET" && pathname === "/provider/auth") return json({})
|
||||
if (request.method === "GET" && pathname === "/vcs") return json({ branch: "demo", default_branch: "dev" })
|
||||
if (request.method === "GET" && pathname === "/experimental/workspace") return json([])
|
||||
if (request.method === "GET" && pathname === "/experimental/workspace/status") return json([])
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}`) return json(session)
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/message`) return json(messages)
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/todo`) return json([])
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/diff`) return json([])
|
||||
if (request.method === "GET" && pathname === `/session/${sessionID}/children`) return json([])
|
||||
|
||||
return json({ message: `Unhandled demo endpoint: ${request.method} ${pathname}` }, 404)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
const events = {
|
||||
subscribe: async () => () => {},
|
||||
} satisfies EventSource
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
fetch,
|
||||
events,
|
||||
}
|
||||
}
|
||||
@@ -21,20 +21,7 @@ import { useEvent } from "@tui/context/event"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import {
|
||||
BoxRenderable,
|
||||
ScrollBoxRenderable,
|
||||
addDefaultParsers,
|
||||
TextAttributes,
|
||||
RGBA,
|
||||
type MarkdownOptions,
|
||||
type MarkdownRenderable,
|
||||
CodeRenderable,
|
||||
TextTableRenderable,
|
||||
TextRenderable,
|
||||
StyledText,
|
||||
type TextChunk,
|
||||
} from "@opentui/core"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
@@ -88,6 +75,7 @@ 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"
|
||||
@@ -102,7 +90,6 @@ 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)
|
||||
|
||||
@@ -1091,201 +1078,199 @@ export function Session() {
|
||||
createEffect(on(() => route.sessionID, toBottom))
|
||||
|
||||
return (
|
||||
<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()}
|
||||
>
|
||||
<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()
|
||||
<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>
|
||||
const handleUnrevert = async () => {
|
||||
const confirmed = await DialogConfirm.show(
|
||||
dialog,
|
||||
"Confirm Redo",
|
||||
"Are you sure you want to restore the reverted messages?",
|
||||
)
|
||||
})()}
|
||||
</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)}
|
||||
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}
|
||||
>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<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>
|
||||
</context.Provider>
|
||||
</PathFormatterProvider>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1538,180 +1523,15 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
|
||||
const ctx = use()
|
||||
const { theme, syntax } = useTheme()
|
||||
const text = createMemo(() => props.part.text.trim())
|
||||
const diffCache = new Map<string, TextChunk[]>()
|
||||
const colorDiffChunks = (text: string) => {
|
||||
const key = `${theme.diffAdded.toString()}:${theme.diffRemoved.toString()}:${text}`
|
||||
const cached = diffCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
let line: "added" | "removed" | undefined
|
||||
let start = true
|
||||
const chunks = (text.match(/[^\n]+|\n/g) ?? [text]).map((part): TextChunk => {
|
||||
if (start && part !== "\n") {
|
||||
line = part.startsWith("+") ? "added" : part.startsWith("-") ? "removed" : undefined
|
||||
start = false
|
||||
}
|
||||
const next = {
|
||||
__isChunk: true,
|
||||
text: part,
|
||||
...(line === "added" ? { fg: theme.diffAdded } : {}),
|
||||
...(line === "removed" ? { fg: theme.diffRemoved } : {}),
|
||||
} satisfies TextChunk
|
||||
if (part === "\n") {
|
||||
line = undefined
|
||||
start = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
diffCache.set(key, chunks)
|
||||
if (diffCache.size > 20) diffCache.delete(diffCache.keys().next().value!)
|
||||
return chunks
|
||||
}
|
||||
const renderBlockquoteBar = (chunks: TextChunk[]) => {
|
||||
let lineStart = true
|
||||
let spaces = 0
|
||||
let replaced = false
|
||||
let skipWhitespace = false
|
||||
return chunks.flatMap((chunk) => {
|
||||
const result: TextChunk[] = []
|
||||
let next = ""
|
||||
const flush = () => {
|
||||
if (!next) return
|
||||
result.push(next === chunk.text ? chunk : { ...chunk, text: next })
|
||||
next = ""
|
||||
}
|
||||
for (const char of chunk.text) {
|
||||
if (skipWhitespace && (char === " " || char === "\t")) {
|
||||
skipWhitespace = false
|
||||
continue
|
||||
}
|
||||
skipWhitespace = false
|
||||
if (lineStart && !replaced && char === " " && spaces < 3) {
|
||||
spaces++
|
||||
next += char
|
||||
continue
|
||||
}
|
||||
if (lineStart && !replaced && char === ">") {
|
||||
flush()
|
||||
result.push({ __isChunk: true, text: "│ ", fg: theme.textMuted, attributes: TextAttributes.NONE })
|
||||
replaced = true
|
||||
skipWhitespace = true
|
||||
continue
|
||||
}
|
||||
next += char
|
||||
if (char === "\n") {
|
||||
lineStart = true
|
||||
spaces = 0
|
||||
replaced = false
|
||||
skipWhitespace = false
|
||||
continue
|
||||
}
|
||||
lineStart = false
|
||||
}
|
||||
flush()
|
||||
return result
|
||||
})
|
||||
}
|
||||
const trimCodeIndent = (value: string) => {
|
||||
const lines = value.split("\n")
|
||||
const indents = lines.filter((line) => line.trim()).map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0)
|
||||
const indent = Math.min(...indents)
|
||||
if (!Number.isFinite(indent) || indent === 0) return value
|
||||
return lines.map((line) => (line.trim() ? line.slice(indent) : line)).join("\n")
|
||||
}
|
||||
const padTableCells = (renderable: TextTableRenderable) => {
|
||||
renderable.content = renderable.content.map((row) =>
|
||||
row.map((cell) => {
|
||||
const content = cell ?? []
|
||||
return [{ __isChunk: true, text: " " }, ...content, { __isChunk: true, text: " " }] satisfies TextChunk[]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const configureMarkdown = (node: MarkdownRenderable | undefined) => {
|
||||
if (!node) return
|
||||
const renderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
|
||||
const content = text()
|
||||
const firstBlock = content.startsWith(token.raw.trimStart())
|
||||
|
||||
if (token.type === "hr") {
|
||||
return new BoxRenderable(node.ctx, {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
border: ["top"],
|
||||
borderColor: theme.border,
|
||||
flexShrink: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (token.type === "blockquote") {
|
||||
const renderable = context.defaultRender()
|
||||
if (renderable instanceof CodeRenderable) {
|
||||
const code = renderable
|
||||
const onChunks = code.onChunks
|
||||
code.onChunks = (chunks, context) => {
|
||||
const result = onChunks?.call(code, chunks, context)
|
||||
if (result instanceof Promise) return result.then((next) => renderBlockquoteBar(next ?? chunks))
|
||||
return renderBlockquoteBar(result ?? chunks)
|
||||
}
|
||||
}
|
||||
if (!firstBlock && renderable) {
|
||||
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
|
||||
}
|
||||
return renderable
|
||||
}
|
||||
|
||||
const needsCodeTopGap = token.type === "code" && !firstBlock
|
||||
if (token.type === "code" && /^[ \t]{4,}(```|~~~)/.test(token.raw)) {
|
||||
token.text = trimCodeIndent(token.text)
|
||||
}
|
||||
|
||||
if (token.type === "table") {
|
||||
const renderable = context.defaultRender()
|
||||
if (renderable instanceof TextTableRenderable) padTableCells(renderable)
|
||||
return renderable
|
||||
}
|
||||
|
||||
if (token.type === "code" && token.lang?.trim().toLowerCase() === "diff") {
|
||||
const renderable = new TextRenderable(node.ctx, {
|
||||
content: new StyledText(colorDiffChunks(token.text)),
|
||||
width: "100%",
|
||||
flexShrink: 0,
|
||||
})
|
||||
if (needsCodeTopGap) renderable.marginTop = 1
|
||||
return renderable
|
||||
}
|
||||
|
||||
const renderable = context.defaultRender()
|
||||
if (token.type === "heading" && token.depth === 1 && !firstBlock && renderable) {
|
||||
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 2) : 2
|
||||
}
|
||||
if (needsCodeTopGap && renderable) {
|
||||
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
|
||||
}
|
||||
return renderable
|
||||
}
|
||||
|
||||
// OpenTUI Solid constructs elements with only `{ id }`, so constructor-only
|
||||
// MarkdownOptions need to be installed on the renderable directly.
|
||||
const target = node as unknown as {
|
||||
_internalBlockMode: "top-level"
|
||||
_renderNode: typeof renderNode
|
||||
}
|
||||
target._internalBlockMode = "top-level"
|
||||
target._renderNode = renderNode
|
||||
}
|
||||
return (
|
||||
<Show when={text()}>
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
streaming={true}
|
||||
ref={configureMarkdown}
|
||||
tableOptions={{ style: "grid", widthMode: "content" }}
|
||||
content={text()}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
@@ -1723,7 +1543,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={text()}
|
||||
content={props.part.text.trim()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.text}
|
||||
/>
|
||||
@@ -2007,7 +1827,7 @@ function BlockTool(props: {
|
||||
|
||||
function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
const { theme } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const sync = useSync()
|
||||
const isRunning = createMemo(() => props.part.state.status === "running")
|
||||
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
@@ -2021,7 +1841,18 @@ function Shell(props: ToolProps<typeof ShellTool>) {
|
||||
const workdirDisplay = createMemo(() => {
|
||||
const workdir = props.input.workdir
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
return pathFormatter.format(workdir)
|
||||
|
||||
const base = sync.path.directory
|
||||
if (!base) return undefined
|
||||
|
||||
const absolute = path.resolve(base, workdir)
|
||||
if (absolute === base) return undefined
|
||||
|
||||
const home = Global.Path.home
|
||||
if (!home) return absolute
|
||||
|
||||
const match = absolute === home || absolute.startsWith(home + path.sep)
|
||||
return match ? absolute.replace(home, "~") : absolute
|
||||
})
|
||||
|
||||
const title = createMemo(() => {
|
||||
@@ -2063,7 +1894,6 @@ 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
|
||||
@@ -2072,7 +1902,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
@@ -2087,7 +1917,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
|
||||
Write {pathFormatter.format(props.input.filePath)}
|
||||
Write {normalizePath(props.input.filePath!)}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -2095,10 +1925,9 @@ 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 {pathFormatter.format(props.input.path)} </Show>
|
||||
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.count}>
|
||||
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
@@ -2108,7 +1937,6 @@ 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 []
|
||||
@@ -2126,13 +1954,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
||||
spinner={isRunning()}
|
||||
part={props.part}
|
||||
>
|
||||
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {pathFormatter.format(filepath)}
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -2142,10 +1970,9 @@ 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 {pathFormatter.format(props.input.path)} </Show>
|
||||
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
|
||||
<Show when={props.metadata.matches}>
|
||||
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
|
||||
</Show>
|
||||
@@ -2244,7 +2071,6 @@ 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
|
||||
@@ -2260,7 +2086,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diff !== undefined}>
|
||||
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
|
||||
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={diffContent()}
|
||||
@@ -2287,7 +2113,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
|
||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -2297,7 +2123,6 @@ 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 ?? [])
|
||||
|
||||
@@ -2336,7 +2161,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 " + pathFormatter.format(file.filePath) + " → " + file.relativePath
|
||||
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
|
||||
return "← Patched " + file.relativePath
|
||||
}
|
||||
|
||||
@@ -2456,6 +2281,20 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(input?: string) {
|
||||
if (!input) return ""
|
||||
|
||||
const cwd = process.cwd()
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const relative = path.relative(cwd, absolute)
|
||||
|
||||
if (!relative) return "."
|
||||
if (!relative.startsWith("..")) return relative
|
||||
|
||||
// outside cwd - use absolute
|
||||
return absolute
|
||||
}
|
||||
|
||||
function input(input: Record<string, any>, omit?: string[]): string {
|
||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||
if (omit?.includes(key)) return false
|
||||
|
||||
@@ -11,16 +11,34 @@ 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)
|
||||
@@ -119,7 +137,6 @@ 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))
|
||||
|
||||
@@ -203,7 +220,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const filepath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Edit ${pathFormatter.format(filepath)}`,
|
||||
title: `Edit ${normalizePath(filepath)}`,
|
||||
body: <EditBody request={props.request} />,
|
||||
}
|
||||
}
|
||||
@@ -213,11 +230,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const filePath = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `Read ${pathFormatter.format(filePath)}`,
|
||||
title: `Read ${normalizePath(filePath)}`,
|
||||
body: (
|
||||
<Show when={filePath}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
|
||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
@@ -259,11 +276,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
const dir = typeof raw === "string" ? raw : ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${pathFormatter.format(dir)}`,
|
||||
title: `List ${normalizePath(dir)}`,
|
||||
body: (
|
||||
<Show when={dir}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
|
||||
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
@@ -342,7 +359,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||
|
||||
const raw = parent ?? filepath ?? derived
|
||||
const dir = pathFormatter.format(raw)
|
||||
const dir = normalizePath(raw)
|
||||
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||
|
||||
return {
|
||||
|
||||
@@ -111,10 +111,6 @@ export const TuiThreadCommand = cmd({
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("demo", {
|
||||
type: "boolean",
|
||||
describe: "open a fake fullscreen TUI session for renderer debugging",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||
@@ -134,6 +130,7 @@ export const TuiThreadCommand = cmd({
|
||||
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||
// chdir so the thread and worker share the same directory key.
|
||||
const next = resolveThreadDirectory(args.project)
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(next)
|
||||
} catch {
|
||||
@@ -141,32 +138,6 @@ export const TuiThreadCommand = cmd({
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
const config = TuiConfig.get()
|
||||
config.catch(() => {})
|
||||
|
||||
if (args.demo) {
|
||||
const { createTuiDemo } = await import("./demo")
|
||||
const { tui } = await import("./app")
|
||||
const demo = createTuiDemo({ directory: cwd })
|
||||
await tui({
|
||||
url: "http://opencode.demo",
|
||||
config: await config,
|
||||
directory: cwd,
|
||||
fetch: demo.fetch,
|
||||
events: demo.events,
|
||||
args: {
|
||||
continue: false,
|
||||
sessionID: demo.sessionID,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt: await input(args.prompt),
|
||||
fork: false,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const file = await target()
|
||||
const env = sanitizedProcessEnv({
|
||||
[OPENCODE_PROCESS_ROLE]: "worker",
|
||||
[OPENCODE_RUN_ID]: ensureRunID(),
|
||||
@@ -216,6 +187,8 @@ export const TuiThreadCommand = cmd({
|
||||
}
|
||||
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
@@ -263,7 +236,7 @@ export const TuiThreadCommand = cmd({
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config: await config,
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export * as ConfigAttachment from "./attachment"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Image = Schema.Struct({
|
||||
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
|
||||
}),
|
||||
max_width: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_height: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||
}),
|
||||
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
|
||||
}),
|
||||
})
|
||||
.annotate({ identifier: "ImageAttachmentConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Image = Schema.Schema.Type<typeof Image>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
|
||||
})
|
||||
.annotate({ identifier: "AttachmentConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -25,7 +25,6 @@ import { containsPath } from "../project/instance-context"
|
||||
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"
|
||||
@@ -242,9 +241,6 @@ 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" }),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
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),
|
||||
@@ -50,7 +49,7 @@ export const Model = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
status: Schema.optional(ModelStatus),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
|
||||
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
|
||||
Schema.brand("WorkspaceID"),
|
||||
)
|
||||
|
||||
export type WorkspaceID = typeof workspaceIdSchema.Type
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import { SessionProcessor } from "@/session/processor"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { SessionRevert } from "@/session/revert"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { SessionTimeline } from "@/session/timeline"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { LLM } from "@/session/llm"
|
||||
@@ -44,7 +43,6 @@ 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"
|
||||
@@ -86,7 +84,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SessionTimeline.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
@@ -99,7 +96,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Format.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Pty.defaultLayer,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import z from "zod"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
const prefixes = {
|
||||
@@ -6,12 +7,19 @@ 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
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
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"
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
declare module "*.md" {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
@@ -37,15 +36,6 @@ 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,
|
||||
@@ -129,38 +119,6 @@ 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
|
||||
@@ -193,7 +151,11 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
|
||||
}
|
||||
|
||||
function defs(key: string, client: MCPClient, timeout?: number) {
|
||||
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
|
||||
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),
|
||||
Effect.catch((err) => {
|
||||
log.error("failed to get tools from client", { key, error: err })
|
||||
return Effect.succeed(undefined)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { zod, ZodOverride } 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")),
|
||||
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
||||
) {
|
||||
static ascending(id?: string): PermissionID {
|
||||
return this.make(Identifier.ascending("permission", id))
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -30,7 +29,6 @@ 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
|
||||
@@ -45,7 +43,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(
|
||||
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
|
||||
[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"))
|
||||
@@ -65,7 +63,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
LSP.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
|
||||
@@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({
|
||||
// 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,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
})
|
||||
.annotate({ identifier: "VcsFileDiff" })
|
||||
@@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type<typeof FileDiff>
|
||||
|
||||
export const FileStatus = Schema.Struct({
|
||||
file: Schema.String,
|
||||
additions: Schema.Finite,
|
||||
deletions: Schema.Finite,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
})
|
||||
.annotate({ identifier: "VcsFileStatus" })
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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"
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
@@ -72,7 +71,7 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
}),
|
||||
),
|
||||
status: Schema.optional(CatalogModelStatus),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
|
||||
@@ -28,7 +28,6 @@ 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" })
|
||||
|
||||
@@ -898,7 +897,7 @@ export const Model = Schema.Struct({
|
||||
capabilities: ProviderCapabilities,
|
||||
cost: ProviderCost,
|
||||
limit: ProviderLimit,
|
||||
status: ModelStatus,
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
options: Schema.Record(Schema.String, Schema.Any),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
release_date: Schema.String,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
|
||||
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
|
||||
|
||||
export type PtyID = typeof ptyIdSchema.Type
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
|
||||
export class QuestionID extends Newtype<QuestionID>()(
|
||||
"QuestionID",
|
||||
Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }),
|
||||
) {
|
||||
static ascending(id?: string): QuestionID {
|
||||
return this.make(Identifier.ascending("question", id))
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import path from "path"
|
||||
import { Effect, Context, Layer, Scope } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Git } from "@/git"
|
||||
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||
|
||||
export type Resolved =
|
||||
| {
|
||||
name: string
|
||||
kind: "local"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "git"
|
||||
repository: string
|
||||
reference: RepositoryReference
|
||||
path: string
|
||||
branch?: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "invalid"
|
||||
repository: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
references: Resolved[]
|
||||
materializeAll: Effect.Effect<void>
|
||||
materializeByPath: { path: string; run: Effect.Effect<void> }[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly ensure: (target?: string) => Effect.Effect<void>
|
||||
readonly contains: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
|
||||
|
||||
export function referencePath(input: { directory: string; worktree: string; value: string }) {
|
||||
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
|
||||
return path.isAbsolute(input.value)
|
||||
? input.value
|
||||
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
|
||||
}
|
||||
|
||||
function resolveGit(
|
||||
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
|
||||
): Resolved {
|
||||
const parsed = parseRepositoryReference(input.repository)
|
||||
if (!parsed || parsed.protocol === "file:") {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.repository,
|
||||
reference: parsed,
|
||||
path: repositoryCachePath(parsed),
|
||||
...("branch" in input ? { branch: input.branch } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function branchLabel(branch: string | undefined) {
|
||||
return branch ?? "default branch"
|
||||
}
|
||||
|
||||
function normalizedTarget(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function containsReferencePath(referencePath: string, target: string) {
|
||||
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||
}
|
||||
|
||||
export function resolve(input: {
|
||||
name: string
|
||||
reference: ReferenceEntry
|
||||
directory: string
|
||||
worktree: string
|
||||
}): Resolved {
|
||||
if (typeof input.reference === "string") {
|
||||
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
|
||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
|
||||
}
|
||||
return resolveGit({ name: input.name, repository: input.reference })
|
||||
}
|
||||
|
||||
if ("path" in input.reference) {
|
||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
||||
}
|
||||
|
||||
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
||||
}
|
||||
|
||||
export function resolveAll(input: {
|
||||
references: NonNullable<Config.Info["reference"]>
|
||||
directory: string
|
||||
worktree: string
|
||||
}) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]) => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
|
||||
return {
|
||||
name,
|
||||
kind: "invalid" as const,
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Reference.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const references = resolveAll({
|
||||
references: cfg.reference ?? {},
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
})
|
||||
const seenPath = new Set<string>()
|
||||
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git") return false
|
||||
if (seenPath.has(reference.path)) return false
|
||||
seenPath.add(reference.path)
|
||||
return true
|
||||
})
|
||||
const materializeByPath = yield* Effect.forEach(
|
||||
gitReferences,
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
const run = yield* Effect.cached(
|
||||
RepositoryCache.ensure(
|
||||
{ reference: reference.reference, branch: reference.branch, refresh: true },
|
||||
{ fs, git },
|
||||
).pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
|
||||
),
|
||||
),
|
||||
)
|
||||
return { path: reference.path, run }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const materializeAll = yield* Effect.cached(
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
materializeByPath,
|
||||
Effect.fnUntraced(function* (item) {
|
||||
yield* item.run
|
||||
}),
|
||||
{ concurrency: 4, discard: true },
|
||||
)
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
return { references, materializeAll, materializeByPath }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Reference.init")(function* () {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
}),
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.references)
|
||||
}),
|
||||
get: Effect.fn("Reference.get")(function* (name: string) {
|
||||
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
|
||||
}),
|
||||
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||
return yield* InstanceState.useEffect(
|
||||
state,
|
||||
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
|
||||
)
|
||||
}),
|
||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return false
|
||||
return yield* InstanceState.use(state, (s) =>
|
||||
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Reference from "./reference"
|
||||
@@ -1,147 +0,0 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import {
|
||||
repositoryCachePath,
|
||||
sameRepositoryReference,
|
||||
parseRepositoryReference,
|
||||
validateRepositoryBranch,
|
||||
type Reference as RepositoryReference,
|
||||
} from "@/util/repository"
|
||||
|
||||
export type Result = {
|
||||
repository: string
|
||||
host: string
|
||||
remote: string
|
||||
localPath: string
|
||||
status: "cached" | "cloned" | "refreshed"
|
||||
head?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false) return "refreshed" as const
|
||||
if (input.refresh) return "refreshed" as const
|
||||
return "cached" as const
|
||||
}
|
||||
|
||||
function resetTarget(input: {
|
||||
requestedBranch?: string
|
||||
remoteHead: { code: number; stdout: string }
|
||||
branch: { code: number; stdout: string }
|
||||
}) {
|
||||
if (input.requestedBranch) return `origin/${input.requestedBranch}`
|
||||
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||
}
|
||||
if (input.branch.code === 0 && input.branch.stdout) {
|
||||
return `origin/${input.branch.stdout}`
|
||||
}
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
|
||||
input: {
|
||||
reference: RepositoryReference
|
||||
refresh?: boolean
|
||||
branch?: string
|
||||
},
|
||||
services: {
|
||||
fs: AppFileSystem.Interface
|
||||
git: Git.Interface
|
||||
},
|
||||
) {
|
||||
if (input.branch) validateRepositoryBranch(input.branch)
|
||||
|
||||
const repository = input.reference.label
|
||||
const remote = input.reference.remote
|
||||
const localPath = repositoryCachePath(input.reference)
|
||||
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
|
||||
|
||||
const exists = yield* services.fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir
|
||||
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||
: undefined
|
||||
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: input.refresh,
|
||||
branchMatches: input.branch ? currentBranch === input.branch : undefined,
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const clone = yield* services.git.run(
|
||||
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
|
||||
{ cwd: path.dirname(localPath) },
|
||||
)
|
||||
if (clone.exitCode !== 0) {
|
||||
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
|
||||
}
|
||||
|
||||
if (input.branch) {
|
||||
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
|
||||
cwd: localPath,
|
||||
})
|
||||
if (checkout.exitCode !== 0) {
|
||||
throw new Error(
|
||||
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||
const target = resetTarget({
|
||||
requestedBranch: input.branch,
|
||||
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||
})
|
||||
|
||||
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
||||
if (reset.exitCode !== 0) {
|
||||
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
|
||||
}
|
||||
}
|
||||
|
||||
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||
const branch = yield* services.git.branch(localPath)
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote,
|
||||
localPath,
|
||||
status,
|
||||
head: headText,
|
||||
branch,
|
||||
} satisfies Result
|
||||
}),
|
||||
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
export * as RepositoryCache from "./repository-cache"
|
||||
@@ -5,7 +5,6 @@ import * as Stream from "effect/Stream"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -17,7 +16,6 @@ export const EventApi = HttpApi.make("event").add(
|
||||
HttpApiGroup.make("event")
|
||||
.add(
|
||||
HttpApiEndpoint.get("subscribe", EventPaths.event, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/config"
|
||||
@@ -13,7 +13,6 @@ export const ConfigApi = HttpApi.make("config")
|
||||
HttpApiGroup.make("config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Config.Info, "Get config info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -23,7 +22,6 @@ export const ConfigApi = HttpApi.make("config")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Config.Info,
|
||||
success: described(Config.Info, "Successfully updated config"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -35,7 +33,6 @@ export const ConfigApi = HttpApi.make("config")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Provider.ConfigProvidersResult, "List of providers"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -4,17 +4,12 @@ import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
@@ -53,6 +48,12 @@ export const ToolListQuery = Schema.Struct({
|
||||
model: ModelID,
|
||||
})
|
||||
|
||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
export const SessionListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
@@ -81,7 +82,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
HttpApiGroup.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
@@ -92,7 +92,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
@@ -103,7 +102,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ConsoleSwitchPayload,
|
||||
success: described(Schema.Boolean, "Switch success"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -127,7 +125,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ToolIDs, "Tool IDs"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -139,7 +136,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(WorktreeList, "List of worktree directories"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -149,7 +145,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.optional(Worktree.CreateInput),
|
||||
success: described(Worktree.Info, "Worktree created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -161,7 +156,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Worktree.RemoveInput,
|
||||
success: described(Schema.Boolean, "Worktree removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -173,7 +167,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Worktree.ResetInput,
|
||||
success: described(Schema.Boolean, "Worktree reset"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -196,7 +189,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -5,11 +5,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const FileQuery = Schema.Struct({
|
||||
@@ -101,7 +97,6 @@ export const FileApi = HttpApi.make("file")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(File.Info), "File status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -8,11 +8,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const PathInfo = Schema.Struct({
|
||||
@@ -59,7 +55,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
HttpApiGroup.make("instance")
|
||||
.add(
|
||||
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Instance disposed"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -69,7 +64,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("path", InstancePaths.path, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: PathInfo,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -80,7 +74,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Vcs.Info, "VCS info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -91,7 +84,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -111,7 +103,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(
|
||||
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
|
||||
"Raw VCS diff",
|
||||
@@ -124,7 +115,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Vcs.ApplyInput,
|
||||
success: described(Vcs.ApplyResult, "VCS patch applied"),
|
||||
error: ApiVcsApplyError,
|
||||
@@ -136,7 +126,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -146,7 +135,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Agent.Info), "List of agents"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -156,7 +144,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Skill.Info), "List of skills"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -166,7 +153,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(LSP.Status), "LSP server status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -176,7 +162,6 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Format.Status), "Formatter status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const AddPayload = Schema.Struct({
|
||||
@@ -42,7 +42,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
HttpApiGroup.make("mcp")
|
||||
.add(
|
||||
HttpApiEndpoint.get("status", McpPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -52,7 +51,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("add", McpPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: AddPayload,
|
||||
success: described(StatusMap, "MCP server added successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -65,7 +63,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(AuthStartResponse, "OAuth flow started"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -77,7 +74,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: AuthCallbackPayload,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -91,7 +87,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -103,7 +98,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
@@ -115,7 +109,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -125,7 +118,6 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/permission"
|
||||
@@ -18,7 +18,6 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
HttpApiGroup.make("permission")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -29,7 +28,6 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: PermissionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/project"
|
||||
@@ -19,7 +19,6 @@ export const ProjectApi = HttpApi.make("project")
|
||||
HttpApiGroup.make("project")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Project.Info), "List of projects"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -29,7 +28,6 @@ export const ProjectApi = HttpApi.make("project")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("current", `${root}/current`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Project.Info, "Current project information"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -39,7 +37,6 @@ export const ProjectApi = HttpApi.make("project")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Project.Info, "Project information after git initialization"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -50,7 +47,6 @@ export const ProjectApi = HttpApi.make("project")
|
||||
),
|
||||
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
||||
params: { projectID: ProjectID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: UpdatePayload,
|
||||
success: described(Project.Info, "Updated project information"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/provider"
|
||||
@@ -15,7 +15,6 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
HttpApiGroup.make("provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Provider.ListResult, "List of providers"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -25,7 +24,6 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("auth", `${root}/auth`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ProviderAuth.Methods, "Provider auth methods"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -36,7 +34,6 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
),
|
||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||
params: { providerID: ProviderID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ProviderAuth.AuthorizeInput,
|
||||
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -49,7 +46,6 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
),
|
||||
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
|
||||
params: { providerID: ProviderID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ProviderAuth.CallbackInput,
|
||||
success: described(Schema.Boolean, "OAuth callback processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
|
||||
@@ -5,11 +5,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
@@ -41,7 +37,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
HttpApiGroup.make("pty")
|
||||
.add(
|
||||
HttpApiEndpoint.get("shells", PtyPaths.shells, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(ShellItem), "List of shells"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -51,7 +46,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", PtyPaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Pty.Info), "List of sessions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -61,7 +55,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Pty.CreateInput,
|
||||
success: described(Pty.Info, "Created session"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -74,7 +67,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.get("get", PtyPaths.get, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Pty.Info, "Session info"),
|
||||
error: ApiNotFoundError,
|
||||
}).annotateMerge(
|
||||
@@ -86,7 +78,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.put("update", PtyPaths.update, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Pty.UpdateInput,
|
||||
success: described(Pty.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -99,7 +90,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Session removed"),
|
||||
error: ApiNotFoundError,
|
||||
}).annotateMerge(
|
||||
@@ -111,7 +101,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
||||
error: [HttpApiError.Forbidden, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -140,7 +129,6 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
.add(
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
export const QueryBooleanOpenApi = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/question"
|
||||
@@ -19,7 +19,6 @@ export const QuestionApi = HttpApi.make("question")
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Question.Request), "List of pending questions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -30,7 +29,6 @@ export const QuestionApi = HttpApi.make("question")
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Question answered successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -43,7 +41,6 @@ export const QuestionApi = HttpApi.make("question")
|
||||
),
|
||||
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
||||
params: { requestID: QuestionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Question rejected successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -10,20 +10,21 @@ import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
|
||||
const root = "/session"
|
||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
export const ListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
scope: Schema.optional(Schema.Literals(["project"])),
|
||||
@@ -115,7 +116,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(StatusMap, "Get session status"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -127,7 +127,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Get session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -139,7 +138,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Session.Info), "List of children"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -151,7 +149,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Todo.Info), "Todo list"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -186,7 +183,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("message", SessionPaths.message, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(MessageV2.WithParts, "Message"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -197,7 +193,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: [HttpApiSchema.NoContent, Session.CreateInput],
|
||||
success: described(Session.Info, "Successfully created session"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -210,7 +205,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Successfully deleted session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -222,7 +216,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: UpdatePayload,
|
||||
success: described(Session.Info, "Successfully updated session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -235,7 +228,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ForkPayload,
|
||||
success: described(Session.Info, "200"),
|
||||
error: ApiNotFoundError,
|
||||
@@ -248,7 +240,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Aborted session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -260,7 +251,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("init", SessionPaths.init, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: InitPayload,
|
||||
success: described(Schema.Boolean, "200"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -274,7 +264,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Successfully shared session"),
|
||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -286,7 +275,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Successfully unshared session"),
|
||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -298,7 +286,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: SummarizePayload,
|
||||
success: described(Schema.Boolean, "Summarized session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -311,7 +298,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PromptPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -324,7 +310,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PromptPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -338,7 +323,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("command", SessionPaths.command, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CommandPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -351,7 +335,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ShellPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -364,7 +347,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RevertPayload,
|
||||
success: described(Session.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -378,7 +360,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -390,7 +371,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
||||
params: { sessionID: SessionID, permissionID: PermissionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PermissionResponsePayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -404,7 +384,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Successfully deleted message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -417,7 +396,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Successfully deleted part"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -428,7 +406,6 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: MessageV2.Part,
|
||||
success: described(MessageV2.Part, "Successfully updated part"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/sync"
|
||||
@@ -46,7 +46,6 @@ export const SyncApi = HttpApi.make("sync")
|
||||
HttpApiGroup.make("sync")
|
||||
.add(
|
||||
HttpApiEndpoint.post("start", SyncPaths.start, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Workspace sync started"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -56,7 +55,6 @@ export const SyncApi = HttpApi.make("sync")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplayPayload,
|
||||
success: described(ReplayResponse, "Replayed sync events"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -68,7 +66,6 @@ export const SyncApi = HttpApi.make("sync")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("steal", SyncPaths.steal, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: SessionPayload,
|
||||
success: described(SessionPayload, "Session stolen into workspace"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -80,7 +77,6 @@ export const SyncApi = HttpApi.make("sync")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: HistoryPayload,
|
||||
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
||||
error: HttpApiError.BadRequest,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
@@ -54,7 +54,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
HttpApiGroup.make("tui")
|
||||
.add(
|
||||
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.PromptAppend.properties,
|
||||
success: described(Schema.Boolean, "Prompt processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -66,7 +65,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Help dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -76,7 +74,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Session dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -86,7 +83,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Theme dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -96,7 +92,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openModels", TuiPaths.openModels, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Model dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -106,7 +101,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Prompt submitted successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -116,7 +110,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Prompt cleared successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -126,7 +119,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CommandPayload,
|
||||
success: described(Schema.Boolean, "Command executed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -138,7 +130,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.ToastShow.properties,
|
||||
success: described(Schema.Boolean, "Toast notification shown successfully"),
|
||||
}).annotateMerge(
|
||||
@@ -149,7 +140,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("publish", TuiPaths.publish, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiPublishPayload,
|
||||
success: described(Schema.Boolean, "Event published successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -161,7 +151,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.SessionSelect.properties,
|
||||
success: described(Schema.Boolean, "Session selected successfully"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -173,7 +162,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(TuiRequestPayload, "Next TUI request"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -183,7 +171,6 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Unknown,
|
||||
success: described(Schema.Boolean, "Response submitted successfully"),
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -3,31 +3,46 @@ import { SessionMessage } from "@/v2/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
|
||||
export const MessagesQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
||||
).annotate({
|
||||
description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
cursor: Schema.optional(
|
||||
Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2SessionMessagesQuery" })
|
||||
|
||||
export const MessageGroup = HttpApiGroup.make("v2.message")
|
||||
.add(
|
||||
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
|
||||
params: { sessionID: SessionID },
|
||||
query: MessagesQuery,
|
||||
query: Schema.Union([
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
cursor: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
cursor: Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Never),
|
||||
}),
|
||||
]).annotate({ identifier: "V2SessionMessagesQuery" }),
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionMessage.Message),
|
||||
cursor: Schema.Struct({
|
||||
|
||||
@@ -1,39 +1,67 @@
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { Prompt } from "@/v2/session-prompt"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Schema } from "effect"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
import { QueryBoolean } from "../query"
|
||||
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
path: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
search: Schema.optional(Schema.String),
|
||||
cursor: Schema.optional(
|
||||
Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2SessionsQuery" })
|
||||
|
||||
export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessions", "/api/session", {
|
||||
query: SessionsQuery,
|
||||
query: Schema.Union([
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
directory: Schema.String.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
workspace: WorkspaceID.pipe(Schema.optional),
|
||||
roots: Schema.Literals(["true", "false"])
|
||||
.pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
.pipe(Schema.optional),
|
||||
start: Schema.NumberFromString.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
cursor: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
cursor: Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Never),
|
||||
directory: Schema.optional(Schema.Never),
|
||||
path: Schema.optional(Schema.Never),
|
||||
workspace: Schema.optional(Schema.Never),
|
||||
roots: Schema.optional(Schema.Never),
|
||||
start: Schema.optional(Schema.Never),
|
||||
search: Schema.optional(Schema.Never),
|
||||
}),
|
||||
]).annotate({ identifier: "V2SessionsQuery" }),
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionV2.Info),
|
||||
cursor: Schema.Struct({
|
||||
@@ -54,7 +82,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Struct({
|
||||
prompt: Prompt,
|
||||
delivery: SessionV2.Delivery.pipe(Schema.optional),
|
||||
@@ -71,7 +98,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -84,7 +110,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -97,7 +122,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Array(SessionMessage.Message),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
|
||||
import { ApiVcsApplyError } from "./instance"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
@@ -40,7 +40,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
HttpApiGroup.make("workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -50,7 +49,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Workspace.Info), "Workspaces"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -60,7 +58,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CreatePayload,
|
||||
success: described(Workspace.Info, "Workspace created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -72,7 +69,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -82,7 +78,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -93,7 +88,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -104,7 +98,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: WarpPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
|
||||
|
||||
@@ -203,15 +203,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof InitPayload.Type
|
||||
}) {
|
||||
yield* promptSvc
|
||||
.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.payload.messageID,
|
||||
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
yield* promptSvc.command({
|
||||
sessionID: ctx.params.sessionID,
|
||||
messageID: ctx.payload.messageID,
|
||||
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -260,19 +258,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
const message = yield* promptSvc
|
||||
.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.provideService(InstanceRef, instance),
|
||||
Effect.provideService(WorkspaceRef, workspace),
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {
|
||||
contentType: "application/json",
|
||||
})
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
promptSvc
|
||||
.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
})
|
||||
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
|
||||
).pipe(
|
||||
Stream.map((message) => JSON.stringify(message)),
|
||||
Stream.encodeText,
|
||||
),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
})
|
||||
|
||||
const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
|
||||
@@ -298,9 +297,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
return yield* promptSvc
|
||||
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
|
||||
})
|
||||
|
||||
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
|
||||
|
||||
@@ -34,7 +34,6 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
|
||||
return handlers.handle(
|
||||
"messages",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({})
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
|
||||
@@ -22,31 +22,6 @@ type SessionCursor = typeof SessionCursor.Type
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
|
||||
|
||||
function hasCursorFilter(query: {
|
||||
readonly order?: unknown
|
||||
readonly path?: unknown
|
||||
readonly roots?: unknown
|
||||
readonly start?: unknown
|
||||
readonly search?: unknown
|
||||
}) {
|
||||
return (
|
||||
query.order !== undefined ||
|
||||
query.path !== undefined ||
|
||||
query.roots !== undefined ||
|
||||
query.start !== undefined ||
|
||||
query.search !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function hasCursorRoutingMismatch(
|
||||
query: { readonly directory?: string; readonly workspace?: string },
|
||||
decoded: SessionCursor | undefined,
|
||||
) {
|
||||
if (!decoded) return false
|
||||
if (query.directory !== undefined && query.directory !== decoded.directory) return true
|
||||
return query.workspace !== undefined && query.workspace !== decoded.workspaceID
|
||||
}
|
||||
|
||||
const sessionCursor = {
|
||||
encode(
|
||||
session: SessionV2.Info,
|
||||
@@ -71,12 +46,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
.handle(
|
||||
"sessions",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({})
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({})
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const filters = decoded ?? {
|
||||
directory: ctx.query.directory,
|
||||
|
||||
@@ -24,8 +24,6 @@ export const WorkspaceRoutingQueryFields = {
|
||||
workspace: Schema.optional(Schema.String),
|
||||
}
|
||||
|
||||
export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
|
||||
|
||||
type RemoteTarget = Extract<Target, { type: "remote" }>
|
||||
|
||||
type RequestPlan = Data.TaggedEnum<{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { OpenCodeHttpApi } from "./api"
|
||||
import { QueryBooleanOpenApi } from "./groups/query"
|
||||
|
||||
type OpenApiParameter = {
|
||||
name: string
|
||||
@@ -52,31 +51,47 @@ type OpenApiResponse = {
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
}
|
||||
|
||||
// Instance routes use middleware for directory/workspace resolution, but HttpApi
|
||||
// doesn't surface middleware query params in the spec. Inject them explicitly.
|
||||
const InstanceQueryParameters = [
|
||||
{
|
||||
name: "directory",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
{
|
||||
name: "workspace",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
] satisfies OpenApiParameter[]
|
||||
|
||||
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
||||
// public call shape. These keep SDK callers passing numbers/booleans while the
|
||||
// server still decodes string query params at runtime.
|
||||
const QueryParameterSchemas: Record<string, OpenApiSchema> = {
|
||||
"GET /experimental/session start": { type: "number" },
|
||||
"GET /experimental/session roots": QueryBooleanOpenApi,
|
||||
"GET /experimental/session archived": QueryBooleanOpenApi,
|
||||
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||
const QueryParameterSchemas = {
|
||||
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
||||
"GET /experimental/session cursor": { type: "number" },
|
||||
"GET /experimental/session limit": { type: "number" },
|
||||
"GET /session start": { type: "number" },
|
||||
"GET /session roots": QueryBooleanOpenApi,
|
||||
"GET /session limit": { type: "number" },
|
||||
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
|
||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
"GET /api/session limit": { type: "number" },
|
||||
"GET /api/session start": { type: "number" },
|
||||
"GET /api/session roots": QueryBooleanOpenApi,
|
||||
"GET /api/session/{sessionID}/message limit": { type: "number" },
|
||||
}
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
|
||||
const LegacyComponentDescriptions: Record<string, string> = {
|
||||
const PathParameterSchemas = {
|
||||
sessionID: { type: "string", pattern: "^ses.*" },
|
||||
messageID: { type: "string", pattern: "^msg.*" },
|
||||
partID: { type: "string", pattern: "^prt.*" },
|
||||
permissionID: { type: "string", pattern: "^per.*" },
|
||||
ptyID: { type: "string", pattern: "^pty.*" },
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
|
||||
const LegacyComponentDescriptions = {
|
||||
LogLevel: "Log level",
|
||||
ServerConfig: "Server configuration for opencode serve and web commands",
|
||||
LayoutConfig: "@deprecated Always uses stretch layout.",
|
||||
}
|
||||
} satisfies Record<string, string>
|
||||
|
||||
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
const spec = input as OpenApiSpec
|
||||
@@ -107,6 +122,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
delete spec.components?.securitySchemes
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
if (!operation) continue
|
||||
@@ -167,8 +183,14 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
},
|
||||
}
|
||||
}
|
||||
const route = `${method.toUpperCase()} ${path}`
|
||||
for (const param of operation.parameters ?? []) normalizeParameter(param, route)
|
||||
if (!isInstanceRoute) continue
|
||||
operation.parameters = [
|
||||
...InstanceQueryParameters,
|
||||
...(operation.parameters ?? []).filter(
|
||||
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
|
||||
),
|
||||
]
|
||||
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||
}
|
||||
}
|
||||
return input
|
||||
@@ -270,7 +292,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
|
||||
|
||||
function normalizeComponentDescriptions(spec: OpenApiSpec) {
|
||||
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
||||
const description = LegacyComponentDescriptions[name]
|
||||
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
|
||||
if (description) {
|
||||
schema.description = description
|
||||
continue
|
||||
@@ -416,7 +438,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
||||
}
|
||||
}
|
||||
// Simplest fix: generate the raw spec (without transform) to get correct schemas
|
||||
const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi)
|
||||
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
|
||||
const rawSchemas = raw.components?.schemas
|
||||
if (!rawSchemas) return
|
||||
for (const name of selfRefs) {
|
||||
@@ -481,19 +503,38 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
|
||||
function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
if (!param.schema || typeof param.schema !== "object") return
|
||||
if (param.in === "path") {
|
||||
param.schema = stripOptionalNull(param.schema)
|
||||
param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
|
||||
return
|
||||
}
|
||||
if (param.in === "query") {
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}`]
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
||||
if (override) {
|
||||
param.schema = override
|
||||
return
|
||||
}
|
||||
if (QueryNumberParameters.has(param.name)) {
|
||||
param.schema = { type: "number" }
|
||||
return
|
||||
}
|
||||
if (QueryBooleanParameters.has(param.name)) {
|
||||
param.schema = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
param.schema = stripOptionalNull(param.schema)
|
||||
}
|
||||
|
||||
function pathParameterSchema(route: string, name: string) {
|
||||
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
|
||||
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
|
||||
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PublicApi = OpenCodeHttpApi.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode",
|
||||
|
||||
@@ -41,7 +41,6 @@ import { SessionRevert } from "@/session/revert"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { SessionTimeline } from "@/session/timeline"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
@@ -211,7 +210,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
SessionRunState.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SessionTimeline.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
|
||||
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
|
||||
const filePartSourceBase = {
|
||||
text: Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Finite,
|
||||
end: Schema.Finite,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}).annotate({ identifier: "FilePartSourceText" }),
|
||||
}
|
||||
|
||||
@@ -270,13 +270,13 @@ export const StepFinishPart = Schema.Struct({
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.optional(Schema.Finite),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
total: Schema.optional(NonNegativeInt),
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.optional(Schema.Finite),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
total: Schema.optional(NonNegativeInt),
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
structured: Schema.optional(Schema.Any),
|
||||
|
||||
@@ -172,12 +172,12 @@ export const Info = Schema.Struct({
|
||||
cost: Schema.Finite,
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -9,7 +9,6 @@ import { Snapshot } from "@/snapshot"
|
||||
import * as Session from "./session"
|
||||
import { LLM } from "./llm"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Image } from "@/image/image"
|
||||
import { isOverflow } from "./overflow"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID } from "./schema"
|
||||
@@ -93,7 +92,6 @@ export const layer: Layer.Layer<
|
||||
| LLM.Service
|
||||
| Permission.Service
|
||||
| Plugin.Service
|
||||
| Image.Service
|
||||
| SessionSummary.Service
|
||||
| SessionStatus.Service
|
||||
> = Layer.effect(
|
||||
@@ -110,7 +108,6 @@ export const layer: Layer.Layer<
|
||||
const summary = yield* SessionSummary.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const status = yield* SessionStatus.Service
|
||||
const image = yield* Image.Service
|
||||
|
||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||
// Pre-capture snapshot before the LLM stream starts. The AI SDK
|
||||
@@ -380,41 +377,17 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
const toolAttachments: MessageV2.FilePart[] = (
|
||||
Array.isArray(value.output.attachments) ? value.output.attachments : []
|
||||
).filter(
|
||||
(attachment: unknown): attachment is MessageV2.FilePart =>
|
||||
isRecord(attachment) &&
|
||||
attachment.type === "file" &&
|
||||
typeof attachment.mime === "string" &&
|
||||
typeof attachment.url === "string",
|
||||
)
|
||||
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
|
||||
attachment.mime.startsWith("image/")
|
||||
? image.normalize(attachment).pipe(Effect.exit)
|
||||
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
|
||||
)
|
||||
const omitted = normalized.filter(Exit.isFailure).length
|
||||
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||
const output = {
|
||||
...value.output,
|
||||
output:
|
||||
omitted === 0
|
||||
? value.output.output
|
||||
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: output.metadata,
|
||||
structured: value.output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: output.output,
|
||||
text: value.output.output,
|
||||
},
|
||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
@@ -426,7 +399,7 @@ export const layer: Layer.Layer<
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
yield* completeToolCall(value.toolCallId, output)
|
||||
yield* completeToolCall(value.toolCallId, value.output)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -785,7 +758,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { SessionTimeline } from "./timeline"
|
||||
import { SessionRevert } from "./revert"
|
||||
import * as Session from "./session"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
@@ -43,7 +43,6 @@ import { Shell } from "@/shell/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Image } from "@/image/image"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
|
||||
@@ -81,10 +80,10 @@ const elog = EffectLogger.create({ service: "session.prompt" })
|
||||
|
||||
export interface Interface {
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
||||
}
|
||||
|
||||
@@ -109,12 +108,11 @@ export const layer = Layer.effect(
|
||||
const lsp = yield* LSP.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const image = yield* Image.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
const instruction = yield* Instruction.Service
|
||||
const state = yield* SessionRunState.Service
|
||||
const timeline = yield* SessionTimeline.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
@@ -125,7 +123,7 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
|
||||
prompt: (input: PromptInput) => prompt(input),
|
||||
} satisfies TaskPromptOps
|
||||
})
|
||||
|
||||
@@ -747,7 +745,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const { msg, part, cwd } = yield* Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
if (session.revert) yield* timeline.commitPending({ sessionID: session.id })
|
||||
if (session.revert) {
|
||||
yield* revert.cleanup(session)
|
||||
}
|
||||
const agent = yield* agents.get(input.agent)
|
||||
if (!agent) {
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
@@ -1259,7 +1259,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
|
||||
})
|
||||
|
||||
const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||
const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||
Effect.map((x) => x.flat().map(assign)),
|
||||
)
|
||||
|
||||
@@ -1272,11 +1272,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
messageID: input.messageID,
|
||||
variant: input.variant,
|
||||
},
|
||||
{ message: info, parts: resolvedParts },
|
||||
)
|
||||
|
||||
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
||||
part.type === "file" && part.mime.startsWith("image/") ? image.normalize(part) : Effect.succeed(part),
|
||||
{ message: info, parts },
|
||||
)
|
||||
|
||||
const parsed = MessageV2.Info.zod.safeParse(info)
|
||||
@@ -1372,26 +1368,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(
|
||||
"SessionPrompt.prompt",
|
||||
)(function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
yield* timeline.commitPending({ sessionID: session.id })
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
||||
function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
})
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
},
|
||||
)
|
||||
|
||||
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
|
||||
@@ -1790,9 +1786,8 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionTimeline.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Bus } from "../bus"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "../sync"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as Session from "./session"
|
||||
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
|
||||
import { SessionTimeline } from "./timeline"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { SessionSummary } from "./summary"
|
||||
|
||||
const log = Log.create({ service: "session.revert" })
|
||||
|
||||
@@ -12,7 +18,6 @@ export const RevertInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
partID: Schema.optional(PartID),
|
||||
files: Schema.optional(RewindFilePolicy),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type RevertInput = Schema.Schema.Type<typeof RevertInput>
|
||||
|
||||
@@ -27,25 +32,133 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const timeline = yield* SessionTimeline.Service
|
||||
const sessions = yield* Session.Service
|
||||
const snap = yield* Snapshot.Service
|
||||
const storage = yield* Storage.Service
|
||||
const bus = yield* Bus.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const state = yield* SessionRunState.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
|
||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||
return yield* timeline.rewind(input)
|
||||
yield* state.assertNotBusy(input.sessionID)
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||
let lastUser: MessageV2.User | undefined
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
|
||||
let rev: Session.Info["revert"]
|
||||
const patches: Snapshot.Patch[] = []
|
||||
for (const msg of all) {
|
||||
if (msg.info.role === "user") lastUser = msg.info
|
||||
const remaining = []
|
||||
for (const part of msg.parts) {
|
||||
if (rev) {
|
||||
if (part.type === "patch") patches.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!rev) {
|
||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||
rev = {
|
||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||
partID,
|
||||
}
|
||||
}
|
||||
remaining.push(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rev) return session
|
||||
|
||||
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
||||
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
|
||||
yield* snap.revert(patches)
|
||||
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
|
||||
const range = all.filter((msg) => msg.info.id >= rev.messageID)
|
||||
const diffs = yield* summary.computeDiff({ messages: range })
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
yield* sessions.setRevert({
|
||||
sessionID: input.sessionID,
|
||||
revert: rev,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||
files: diffs.length,
|
||||
},
|
||||
})
|
||||
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
||||
log.info("unreverting", input)
|
||||
return yield* timeline.restore(input)
|
||||
yield* state.assertNotBusy(input.sessionID)
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
if (!session.revert) return session
|
||||
if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
|
||||
yield* sessions.clearRevert(input.sessionID)
|
||||
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
||||
yield* timeline.commitPending({ sessionID: session.id })
|
||||
if (!session.revert) return
|
||||
const sessionID = session.id
|
||||
const msgs = yield* sessions.messages({ sessionID })
|
||||
const messageID = session.revert.messageID
|
||||
const remove = [] as MessageV2.WithParts[]
|
||||
let target: MessageV2.WithParts | undefined
|
||||
for (const msg of msgs) {
|
||||
if (msg.info.id < messageID) continue
|
||||
if (msg.info.id > messageID) {
|
||||
remove.push(msg)
|
||||
continue
|
||||
}
|
||||
if (session.revert.partID) {
|
||||
target = msg
|
||||
continue
|
||||
}
|
||||
remove.push(msg)
|
||||
}
|
||||
for (const msg of remove) {
|
||||
yield* sync.run(MessageV2.Event.Removed, {
|
||||
sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
}
|
||||
if (session.revert.partID && target) {
|
||||
const partID = session.revert.partID
|
||||
const idx = target.parts.findIndex((part) => part.id === partID)
|
||||
if (idx >= 0) {
|
||||
const removeParts = target.parts.slice(idx)
|
||||
target.parts = target.parts.slice(0, idx)
|
||||
for (const part of removeParts) {
|
||||
yield* sync.run(MessageV2.Event.PartRemoved, {
|
||||
sessionID,
|
||||
messageID: target.info.id,
|
||||
partID: part.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* sessions.clearRevert(sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ revert, unrevert, cleanup })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(SessionTimeline.defaultLayer)))
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
export * as SessionRevert from "./revert"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((s) => ({
|
||||
descending: (id?: string) => s.make(Identifier.descending("session", id)),
|
||||
@@ -14,7 +14,7 @@ export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
|
||||
export type SessionID = Schema.Schema.Type<typeof SessionID>
|
||||
|
||||
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe(
|
||||
Schema.brand("MessageID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
|
||||
@@ -24,7 +24,7 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
|
||||
export type MessageID = Schema.Schema.Type<typeof MessageID>
|
||||
|
||||
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
|
||||
export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe(
|
||||
Schema.brand("PartID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
|
||||
@@ -33,6 +33,3 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
|
||||
)
|
||||
|
||||
export type PartID = Schema.Schema.Type<typeof PartID>
|
||||
|
||||
export const RewindFilePolicy = Schema.Union([Schema.Literal("revert"), Schema.Literal("keep")])
|
||||
export type RewindFilePolicy = Schema.Schema.Type<typeof RewindFilePolicy>
|
||||
|
||||
@@ -30,7 +30,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
@@ -142,9 +142,9 @@ function sessionPath(worktree: string, cwd: string) {
|
||||
}
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
additions: Schema.Finite,
|
||||
deletions: Schema.Finite,
|
||||
files: Schema.Finite,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
files: NonNegativeInt,
|
||||
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
||||
})
|
||||
|
||||
@@ -166,7 +166,6 @@ const Time = Schema.Struct({
|
||||
const Revert = Schema.Struct({
|
||||
messageID: MessageID,
|
||||
partID: optionalOmitUndefined(PartID),
|
||||
files: optionalOmitUndefined(RewindFilePolicy),
|
||||
snapshot: optionalOmitUndefined(Schema.String),
|
||||
diff: optionalOmitUndefined(Schema.String),
|
||||
})
|
||||
@@ -354,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
|
||||
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
|
||||
const safe = (value: number) => {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, value)
|
||||
return value
|
||||
}
|
||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { SessionSummary } from "./summary"
|
||||
|
||||
export const RewindInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
partID: Schema.optional(PartID),
|
||||
files: Schema.optional(RewindFilePolicy),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type RewindInput = Schema.Schema.Type<typeof RewindInput>
|
||||
|
||||
export interface Interface {
|
||||
readonly rewind: (input: RewindInput) => Effect.Effect<Session.Info>
|
||||
readonly restore: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
|
||||
readonly commitPending: (input: { sessionID: SessionID }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTimeline") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const snap = yield* Snapshot.Service
|
||||
const storage = yield* Storage.Service
|
||||
const bus = yield* Bus.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const state = yield* SessionRunState.Service
|
||||
|
||||
const rewind = Effect.fn("SessionTimeline.rewind")(function* (input: RewindInput) {
|
||||
yield* state.assertNotBusy(input.sessionID)
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
const files = input.files ?? "revert"
|
||||
let lastUser: MessageV2.User | undefined
|
||||
let rev: Session.Info["revert"]
|
||||
const patches: Snapshot.Patch[] = []
|
||||
const range: MessageV2.WithParts[] = []
|
||||
|
||||
for (const msg of all) {
|
||||
if (msg.info.role === "user") lastUser = msg.info
|
||||
const remaining = []
|
||||
for (const part of msg.parts) {
|
||||
if (rev) {
|
||||
if (files === "revert" && part.type === "patch") patches.push(part)
|
||||
continue
|
||||
}
|
||||
|
||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||
rev = {
|
||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||
partID,
|
||||
...(files === "keep" && { files }),
|
||||
}
|
||||
}
|
||||
remaining.push(part)
|
||||
}
|
||||
if (rev) range.push(msg)
|
||||
}
|
||||
|
||||
if (!rev) return session
|
||||
|
||||
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
|
||||
if (files === "revert") {
|
||||
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
||||
yield* snap.revert(patches)
|
||||
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
|
||||
}
|
||||
const diffs = yield* summary.computeDiff({ messages: range })
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
yield* sessions.setRevert({
|
||||
sessionID: input.sessionID,
|
||||
revert: rev,
|
||||
summary: {
|
||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||
files: diffs.length,
|
||||
},
|
||||
})
|
||||
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const restore = Effect.fn("SessionTimeline.restore")(function* (input: { sessionID: SessionID }) {
|
||||
yield* state.assertNotBusy(input.sessionID)
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
if (!session.revert) return session
|
||||
if (session.revert.files !== "keep" && session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
|
||||
yield* sessions.clearRevert(input.sessionID)
|
||||
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const commitPending = Effect.fn("SessionTimeline.commitPending")(function* (input: { sessionID: SessionID }) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
if (!session.revert) return
|
||||
const sessionID = session.id
|
||||
const msgs = yield* sessions.messages({ sessionID })
|
||||
const messageID = session.revert.messageID
|
||||
const remove = [] as MessageV2.WithParts[]
|
||||
let target: MessageV2.WithParts | undefined
|
||||
for (const msg of msgs) {
|
||||
if (msg.info.id < messageID) continue
|
||||
if (msg.info.id > messageID) {
|
||||
remove.push(msg)
|
||||
continue
|
||||
}
|
||||
if (session.revert.partID) {
|
||||
target = msg
|
||||
continue
|
||||
}
|
||||
remove.push(msg)
|
||||
}
|
||||
for (const msg of remove) {
|
||||
yield* sessions.removeMessage({
|
||||
sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
}
|
||||
if (session.revert.partID && target) {
|
||||
const idx = target.parts.findIndex((part) => part.id === session.revert?.partID)
|
||||
if (idx >= 0) {
|
||||
for (const part of target.parts.slice(idx)) {
|
||||
yield* sessions.removePart({
|
||||
sessionID,
|
||||
messageID: target.info.id,
|
||||
partID: part.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* sessions.clearRevert(sessionID)
|
||||
})
|
||||
|
||||
return Service.of({ rewind, restore, commitPending })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
export * as SessionTimeline from "./timeline"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user