Compare commits

..

5 Commits

Author SHA1 Message Date
Kit Langton 3d2d43e8c4 perf(tui): tighten markdown demo rendering 2026-05-10 14:01:38 -04:00
Kit Langton 9c354995a5 style(tui): polish markdown tables and quotes 2026-05-10 13:51:00 -04:00
Kit Langton 88db6d22f8 style(tui): refine markdown block rendering 2026-05-10 13:40:48 -04:00
Kit Langton 13b2917f74 feat(tui): add fullscreen markdown demo 2026-05-10 13:28:00 -04:00
Kit Langton 5469155eed refactor(session): introduce timeline rewind service 2026-05-10 12:20:46 -04:00
94 changed files with 2309 additions and 1835 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -171,7 +171,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -195,7 +195,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.47",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -329,7 +329,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.47",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -534,7 +534,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -549,7 +549,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -584,7 +584,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -633,7 +633,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+2
View File
@@ -7,6 +7,8 @@
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.47",
"version": "1.14.46",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.47",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
@@ -67,7 +67,6 @@
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
background: transparent;
border: none;
@@ -80,7 +79,6 @@
}
svg {
flex-shrink: 0;
width: 16px;
height: 16px;
}
@@ -53,7 +53,7 @@ export function UsageSection() {
}
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.outputTokens
return u.outputTokens + (u.reasoningTokens ?? 0)
}
const goPrev = async () => {
@@ -889,6 +889,10 @@ export async function handler(
const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined
@@ -905,11 +909,17 @@ export async function handler(
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})()
const totalCostInCent =
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
return {
totalCostInCent,
inputCost,
outputCost,
reasoningCost,
cacheReadCost,
cacheWrite5mCost,
cacheWrite1hCost,
@@ -931,7 +941,8 @@ export async function handler(
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
usageInfo
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
costInfo
logger.metric({
"tokens.input": inputTokens,
@@ -942,12 +953,14 @@ export async function handler(
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input.microcents": centsToMicroCents(inputCost),
"cost.output.microcents": centsToMicroCents(outputCost),
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
"cost.total.microcents": centsToMicroCents(totalCostInCent),
// deprecated - remove after May 20, 2026
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,
outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.47",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.47",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.47",
"version": "1.14.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.47",
"version": "1.14.46",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.47",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.47",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.47"
version = "1.14.46"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.47",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+26 -28
View File
@@ -16,9 +16,8 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
## Quickstart
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
By default the layer records on first run and replays on subsequent runs —
no env-var ternary at the call site, and `CI=true` forces strict replay so
missing cassettes fail loudly in CI rather than silently re-recording.
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
runs replay from it.
```ts
import { Effect } from "effect"
@@ -31,22 +30,28 @@ const program = Effect.gen(function* () {
return yield* response.json
})
// Records if the cassette is missing, replays if it exists.
// In CI (CI=true) always replays — fails loudly on missing fixtures.
// Replay (default). Fails if the cassette is missing.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
// Force a refresh — always hits upstream and overwrites.
// 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 |
| ------------- | ----------------------------------------------------------------------------------- |
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
| `replay` | Strict — match the request to a recorded interaction; error if none. |
| `record` | Execute upstream, append the interaction, write the cassette. |
| `passthrough` | Bypass the recorder entirely — just call upstream. |
| 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
@@ -96,6 +101,7 @@ secrets escape. Redaction is configured by composing a `Redactor`:
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}") },
@@ -151,6 +157,7 @@ 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,
})
@@ -160,9 +167,9 @@ const program = Effect.gen(function* () {
## Inspecting cassettes programmatically
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
returns the recorded interactions for a name; the file format is hidden
behind the seam. Useful for CI checks:
`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"
@@ -170,27 +177,18 @@ import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const entries = yield* cassettes.list()
const issues = yield* Effect.forEach(entries, (entry) =>
cassettes
.read(entry.name)
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
)
return issues.filter((i) => i.findings.length > 0)
return findings.filter((r) => r.findings.length > 0)
})
```
`cassetteLayer` is the batteries-included entry point — it provides
`Cassette.fileSystem({ directory })` automatically. If you want to provide
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
`Cassette.memory` yourself.
## Options reference
```ts
type RecordReplayOptions = {
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
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()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.47",
"version": "1.14.46",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+82 -119
View File
@@ -1,76 +1,62 @@
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import * as fs from "node:fs"
import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
import * as path from "node:path"
import { secretFindings, type SecretFinding } from "./redaction"
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
cassetteName: Schema.String,
}) {
override get message() {
return `Cassette "${this.cassetteName}" not found`
}
}
export interface AppendResult {
readonly findings: ReadonlyArray<SecretFinding>
export interface Entry {
readonly name: string
readonly path: string
}
export interface Interface {
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<string>>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
const buildCassette = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
interactions,
})
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const fileSystem = (
options: { readonly directory?: string } = {},
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
export const layer = (options: { readonly directory?: string } = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
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 cassettePath = (name: string) => path.join(directory, `${name}.json`)
const pathFor = (name: string) => cassettePath(name, directory)
const ensureDirectory = (name: string) =>
Effect.gen(function* () {
const dir = path.dirname(cassettePath(name))
if (directoriesEnsured.has(dir)) return
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
directoriesEnsured.add(dir)
})
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 = (current: string): Effect.Effect<ReadonlyArray<string>> =>
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(current, entry)
return fs.stat(full).pipe(
const full = path.join(directory, entry)
return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
@@ -78,73 +64,50 @@ export const fileSystem = (
return nested.flat()
})
return Service.of({
read: (name) =>
fs.readFileString(cassettePath(name)).pipe(
Effect.map((raw) => parseCassette(raw).interactions),
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
),
append: (name, interaction, metadata) =>
Effect.gen(function* () {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
if (!recorded.has(name)) recorded.set(name, entry)
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
const cassette = buildCassette(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
if (findings.length === 0) {
yield* ensureDirectory(name)
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
}
return { findings }
}),
exists: (name) =>
fs.access(cassettePath(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
),
list: () =>
walk(directory).pipe(
Effect.map((files) =>
files
.filter((file) => file.endsWith(".json"))
.map((file) =>
path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
)
.toSorted((a, b) => a.localeCompare(b)),
),
),
const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* ensureDirectory(name)
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: 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 ?? {})]
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
)
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
Layer.sync(Service, () => {
const stored = new Map<string, Interaction[]>(
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
)
const accumulatedFindings = new Map<string, SecretFinding[]>()
return Service.of({
read: (name) =>
stored.has(name)
? Effect.succeed(stored.get(name) ?? [])
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
append: (name, interaction, metadata) =>
Effect.sync(() => {
const existing = stored.get(name)
if (existing) existing.push(interaction)
else stored.set(name, [interaction])
const findings = accumulatedFindings.get(name)
if (findings) findings.push(...secretFindings(interaction))
else accumulatedFindings.set(name, [...secretFindings(interaction)])
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
return { findings: accumulatedFindings.get(name) ?? [] }
}),
exists: (name) => Effect.sync(() => stored.has(name)),
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
})
})
+5 -8
View File
@@ -12,12 +12,12 @@ import {
} from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
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 type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
export type RecordReplayMode = "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
@@ -69,8 +69,7 @@ export const recordingLayer = (
const cassetteService = yield* CassetteService.Service
const redactor = options.redactor ?? defaults()
const match = options.match ?? defaultMatcher
const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
const mode = options.mode ?? "replay"
const sequential = options.dispatch === "sequential"
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
@@ -115,9 +114,7 @@ export const recordingLayer = (
return Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const interactions = yield* replay.load.pipe(
Effect.mapError(() =>
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
),
Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)),
)
const result = sequential
? selectSequential(interactions, incoming, match, yield* replay.cursor)
@@ -138,7 +135,7 @@ export const recordingLayer = (
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
+2 -2
View File
@@ -7,9 +7,9 @@ export type {
WebSocketFrame,
WebSocketInteraction,
} from "./schema"
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
export { hasCassetteSync } from "./storage"
export { defaultMatcher, type RequestMatcher } from "./matching"
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction"
export { UnsafeCassetteError } from "./recorder"
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
export {
+23 -35
View File
@@ -1,49 +1,37 @@
import { Effect, Ref, Schema, Scope } from "effect"
import { Effect, PlatformError, Ref, Scope } from "effect"
import type * as CassetteService from "./cassette"
import type { CassetteNotFoundError } from "./cassette"
import { SecretFindingSchema } from "./redaction"
import type { CassetteMetadata, Interaction } from "./schema"
import type { SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
cassetteName: Schema.String,
findings: Schema.Array(SecretFindingSchema),
}) {
override get message() {
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`
export 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 type ResolvedMode = "record" | "replay" | "passthrough"
const isCI = () => {
const value = process.env.CI
return value !== undefined && value !== "" && value !== "false" && value !== "0"
}
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
Effect.gen(function* () {
if (isCI()) return "replay"
return (yield* cassette.exists(name)) ? "replay" : "record"
})
export const appendOrFail = (
cassette: CassetteService.Interface,
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
): Effect.Effect<void, UnsafeCassetteError> =>
cassette
.append(name, interaction, metadata)
.pipe(
Effect.flatMap(({ findings }) =>
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
),
)
): 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>, CassetteNotFoundError>
readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError>
readonly cursor: Effect.Effect<number>
readonly advance: Effect.Effect<void>
}
@@ -51,7 +39,7 @@ export interface ReplayState<T> {
export const makeReplayState = <T>(
cassette: CassetteService.Interface,
name: string,
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
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)))
+8 -7
View File
@@ -1,3 +1,5 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
@@ -95,13 +97,10 @@ export const redactHeaders = (
)
}
import { Schema } from "effect"
export const SecretFindingSchema = Schema.Struct({
path: Schema.String,
reason: Schema.String,
})
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
export type SecretFinding = {
readonly path: string
readonly reason: string
}
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [
@@ -113,3 +112,5 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
.filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
+2 -3
View File
@@ -52,10 +52,9 @@ export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
interactions.filter(isWebSocketInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
+28
View File
@@ -0,0 +1,28 @@
import { Option } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
interactions,
})
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
const file = cassettePath(name, options.directory)
if (!fs.existsSync(file)) return false
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
}
+3 -5
View File
@@ -2,8 +2,7 @@ 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, resolveAutoMode } from "./recorder"
import type { RecordReplayMode } from "./effect"
import { appendOrFail, makeReplayState } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
@@ -24,7 +23,7 @@ export interface WebSocketExecutor<E> {
export interface WebSocketRecordReplayOptions<E> {
readonly name: string
readonly mode?: RecordReplayMode
readonly mode?: "record" | "replay" | "passthrough"
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
@@ -72,8 +71,7 @@ export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const requested = options.mode ?? "auto"
const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested
const mode = options.mode ?? "replay"
const redactor = options.redactor ?? defaults()
const openSnapshot = (request: WebSocketRequest) => {
const redacted = redactor.request({
@@ -7,15 +7,7 @@ import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/effect"
import type { Interaction } from "../src/schema"
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
Effect.runPromise(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
)
import { cassetteFor, formatCassette, parseCassette } from "../src/storage"
const post = (url: string, body: object) =>
Effect.gen(function* () {
@@ -42,7 +34,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
Effect.scoped(
effect.pipe(
Effect.provide(
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
),
Effect.provide(NodeFileSystem.layer),
),
@@ -117,7 +109,7 @@ describe("http-recorder", () => {
test("detects secret-looking values without returning the secret", () => {
expect(
HttpRecorder.secretFindings({
HttpRecorder.cassetteSecretFindings({
version: 1,
interactions: [
{
@@ -145,7 +137,7 @@ describe("http-recorder", () => {
test("detects secret-looking values inside metadata", () => {
expect(
HttpRecorder.secretFindings({
HttpRecorder.cassetteSecretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
@@ -153,42 +145,60 @@ describe("http-recorder", () => {
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
test("formats websocket cassettes with shared metadata", () => {
const cassette = cassetteFor(
"websocket/basic",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}).pipe(
Effect.provide(
HttpRecorder.Cassette.memory({
"websocket/replay": [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
}),
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(parseCassette(formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
),
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
)
})
@@ -218,14 +228,17 @@ describe("http-recorder", () => {
yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject([
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
])
expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
})
}),
)
})
@@ -288,49 +301,6 @@ describe("http-recorder", () => {
)
})
test("auto mode replays when the cassette exists", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
await seedCassetteDirectory(directory, "auto-replay", [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/echo",
headers: { "content-type": "application/json" },
body: JSON.stringify({ step: 1 }),
},
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
},
])
const result = await runWith(
"auto-replay",
{ directory, mode: "auto" },
post("https://example.test/echo", { step: 1 }),
)
expect(result).toBe('{"reply":"hi"}')
})
test("auto mode forces replay when CI=true even if cassette is missing", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-"))
const previous = process.env.CI
process.env.CI = "true"
try {
const exit = await Effect.runPromise(
Effect.exit(
post("https://example.test/echo", { step: 1 }).pipe(
Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
} finally {
if (previous === undefined) delete process.env.CI
else process.env.CI = previous
}
})
test("mismatch diagnostics show closest redacted request differences", async () => {
await run(
Effect.gen(function* () {
+1 -1
View File
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
stream: {
event: Schema.String,
initial: () => undefined,
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
onHalt: () => [{ type: "request-finish", reason: "stop" }],
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.47",
"version": "1.14.46",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type CacheHint,
type FinishReason,
type LLMEvent,
type LLMRequest,
type ProviderMetadata,
type ToolCallPart,
@@ -415,13 +415,14 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
? String((block.content as Record<string, unknown>).type)
: ""
const isError = errorPayload.endsWith("_tool_result_error")
return LLMEvent.toolResult({
return {
type: "tool-result",
id: block.tool_use_id ?? "",
name: SERVER_TOOL_RESULT_NAMES[block.type],
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
providerExecuted: true,
providerMetadata: anthropicMetadata({ blockType: block.type }),
})
}
}
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -452,17 +453,18 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
}
if (block.type === "text" && block.text) {
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]]
return [state, [{ type: "text-delta", text: block.text }]]
}
if (block.type === "thinking" && block.thinking) {
return [
state,
[
LLMEvent.reasoningDelta({
id: `reasoning-${event.index ?? 0}`,
{
type: "reasoning-delta",
text: block.thinking,
}),
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
},
],
]
}
@@ -478,25 +480,17 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult
return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
}
if (delta?.type === "thinking_delta" && delta.thinking) {
return [
state,
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
] satisfies StepResult
return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
}
if (delta?.type === "signature_delta" && delta.signature) {
return [
state,
[
LLMEvent.reasoningEnd({
id: `reasoning-${event.index ?? 0}`,
providerMetadata: anthropicMetadata({ signature: delta.signature }),
}),
],
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
] satisfies StepResult
}
@@ -530,20 +524,21 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
return [
{ ...state, usage },
[
LLMEvent.requestFinish({
{
type: "request-finish",
reason: mapFinishReason(event.delta?.stop_reason),
usage,
providerMetadata: event.delta?.stop_sequence
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
: undefined,
}),
...(event.delta?.stop_sequence
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
: {}),
},
],
]
}
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
[{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
]
const step = (state: ParserState, event: AnthropicEvent) => {
+9 -19
View File
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type CacheHint,
type FinishReason,
type LLMEvent,
type LLMRequest,
type ToolCallPart,
type ToolDefinition,
@@ -400,26 +400,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.contentBlockDelta?.delta?.text) {
return [
state,
[
LLMEvent.textDelta({
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
text: event.contentBlockDelta.delta.text,
}),
],
] as const
return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
}
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
return [
state,
[
LLMEvent.reasoningDelta({
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
text: event.contentBlockDelta.delta.reasoningContent.text,
}),
],
[{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
] as const
}
@@ -462,13 +449,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
event.modelStreamErrorException?.message ??
event.serviceUnavailableException?.message ??
"Bedrock Converse stream error"
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const
return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
}
if (event.validationException || event.throttlingException) {
const message =
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
return [
state,
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
] as const
}
return [state, []] as const
@@ -478,7 +468,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.pendingFinish
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })]
? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
: []
// =============================================================================
+4 -8
View File
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type FinishReason,
type LLMEvent,
type LLMRequest,
type MediaPart,
type TextPart,
@@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.finishReason || state.usage
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
: []
const step = (state: ParserState, event: GeminiEvent) => {
@@ -332,18 +332,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
for (const part of candidate.content.parts) {
if ("text" in part && part.text.length > 0) {
events.push(
part.thought
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
)
events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
continue
}
if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
events.push({ type: "tool-call", id, name: part.functionCall.name, input })
hasToolCalls = true
}
}
+6 -3
View File
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type FinishReason,
type LLMEvent,
type LLMRequest,
type TextPart,
type ToolCallPart,
@@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const toolDeltas = delta?.tool_calls ?? []
let tools = state.tools
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content }))
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart(
@@ -348,7 +348,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const hasToolCalls = state.toolCallEvents.length > 0
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
return [
...state.toolCallEvents,
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
]
}
// =============================================================================
+29 -15
View File
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
import { HttpTransport, WebSocketTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
LLMEvent,
Usage,
type FinishReason,
type LLMEvent,
type LLMRequest,
type ProviderMetadata,
type TextPart,
@@ -348,20 +348,22 @@ const hostedToolEvents = (
const tool = HOSTED_TOOLS[item.type]
const providerMetadata = openaiMetadata({ itemId: item.id })
return [
LLMEvent.toolCall({
{
type: "tool-call",
id: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata,
}),
LLMEvent.toolResult({
},
{
type: "tool-result",
id: item.id,
name: tool.name,
result: hostedToolResult(item),
providerExecuted: true,
providerMetadata,
}),
},
]
}
@@ -377,7 +379,17 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
return [
state,
[
{
type: "text-delta",
id: event.item_id,
text: event.delta,
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
},
],
]
}
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -446,28 +458,30 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[
LLMEvent.requestFinish({
{
type: "request-finish",
reason: mapFinishReason(event, state.hasFunctionCall),
usage: mapUsage(event.response?.usage),
providerMetadata:
event.response?.id || event.response?.service_tier
? openaiMetadata({
...(event.response?.id || event.response?.service_tier
? {
providerMetadata: openaiMetadata({
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
}),
}),
}
: {}),
},
],
]
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
+24 -14
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -49,24 +49,34 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
return next
}
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
})
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
type: "tool-input-delta",
id: tool.id,
name: tool.name,
text,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
})
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
tool.providerExecuted
? {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
providerExecuted: true,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
}
: {
type: "tool-call",
id: tool.id,
name: tool.name,
input,
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
},
),
)
+26 -89
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
import { ModelRef } from "./options"
import { ToolResultValue } from "./messages"
@@ -14,87 +14,60 @@ export class Usage extends Schema.Class<Usage>("LLM.Usage")({
}) {}
export const RequestStart = Schema.Struct({
type: Schema.tag("request-start"),
id: ResponseID,
type: Schema.Literal("request-start"),
id: Schema.String,
model: ModelRef,
}).annotate({ identifier: "LLM.Event.RequestStart" })
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
type: Schema.Literal("step-start"),
index: Schema.Number,
}).annotate({ identifier: "LLM.Event.StepStart" })
export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({
type: Schema.tag("text-start"),
id: ContentBlockID,
type: Schema.Literal("text-start"),
id: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart>
export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
type: Schema.Literal("text-delta"),
id: Schema.optional(Schema.String),
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
type: Schema.Literal("text-end"),
id: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"),
id: ContentBlockID,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
type: Schema.Literal("reasoning-delta"),
id: Schema.optional(Schema.String),
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
type: Schema.Literal("tool-input-delta"),
id: Schema.String,
name: Schema.String,
text: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
type: Schema.Literal("tool-call"),
id: Schema.String,
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -103,8 +76,8 @@ export const ToolCall = Schema.Struct({
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
type: Schema.Literal("tool-result"),
id: Schema.String,
name: Schema.String,
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -113,8 +86,8 @@ export const ToolResult = Schema.Struct({
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
type: Schema.Literal("tool-error"),
id: Schema.String,
name: Schema.String,
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
@@ -122,7 +95,7 @@ export const ToolError = Schema.Struct({
export type ToolError = Schema.Schema.Type<typeof ToolError>
export const StepFinish = Schema.Struct({
type: Schema.tag("step-finish"),
type: Schema.Literal("step-finish"),
index: Schema.Number,
reason: FinishReason,
usage: Schema.optional(Usage),
@@ -131,7 +104,7 @@ export const StepFinish = Schema.Struct({
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
export const RequestFinish = Schema.Struct({
type: Schema.tag("request-finish"),
type: Schema.Literal("request-finish"),
reason: FinishReason,
usage: Schema.optional(Usage),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -139,7 +112,7 @@ export const RequestFinish = Schema.Struct({
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
type: Schema.Literal("provider-error"),
message: Schema.String,
retryable: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -152,12 +125,8 @@ const llmEventTagged = Schema.Union([
TextStart,
TextDelta,
TextEnd,
ReasoningStart,
ReasoningDelta,
ReasoningEnd,
ToolInputStart,
ToolInputDelta,
ToolInputEnd,
ToolCall,
ToolResult,
ToolError,
@@ -166,52 +135,20 @@ const llmEventTagged = Schema.Union([
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
const responseID = (value: ResponseID | string) => ResponseID.make(value)
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
/**
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
stepFinish: StepFinish.make,
requestFinish: RequestFinish.make,
providerError: ProviderErrorEvent.make,
is: {
requestStart: llmEventTagged.guards["request-start"],
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
textEnd: llmEventTagged.guards["text-end"],
reasoningStart: llmEventTagged.guards["reasoning-start"],
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
reasoningEnd: llmEventTagged.guards["reasoning-end"],
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
toolCall: llmEventTagged.guards["tool-call"],
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
-9
View File
@@ -14,15 +14,6 @@ export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
+6 -14
View File
@@ -4,7 +4,7 @@ import {
type ContentPart,
type FinishReason,
type LLMError,
LLMEvent,
type LLMEvent,
LLMRequest,
Message,
type ProviderMetadata,
@@ -115,19 +115,11 @@ interface StepState {
const accumulate = (state: StepState, event: LLMEvent) => {
if (event.type === "text-delta") {
appendStreamingText(state, "text", event.text, undefined)
appendStreamingText(state, "text", event.text, event.providerMetadata)
return
}
if (event.type === "reasoning-delta") {
appendStreamingText(state, "reasoning", event.text, undefined)
return
}
if (event.type === "reasoning-end") {
appendStreamingText(state, "reasoning", "", event.providerMetadata)
return
}
if (event.type === "text-end") {
appendStreamingText(state, "text", "", event.providerMetadata)
appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
return
}
if (event.type === "tool-call") {
@@ -227,10 +219,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
{ type: "tool-result", id: call.id, name: call.name, result },
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
: [{ type: "tool-result", id: call.id, name: call.name, result }]
const followUpRequest = (
request: LLMRequest,
+1 -3
View File
@@ -50,9 +50,7 @@ const request = LLM.request({
})
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
event.type === "finish"
? { type: "request-finish", reason: event.reason }
: { type: "text-delta", id: "text-0", text: event.text }
event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
id: "fake",
+1 -1
View File
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
expect(
LLMResponse.text({
events: [
{ type: "text-delta", id: "text-0", text: "hi" },
{ type: "text-delta", text: "hi" },
{ type: "request-finish", reason: "stop" },
],
}),
@@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => {
cacheReadInputTokens: 1,
totalTokens: 7,
})
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
})
expect(response.events.at(-1)).toMatchObject({
+3 -3
View File
@@ -204,9 +204,9 @@ describe("Gemini route", () => {
totalTokens: 7,
})
expect(response.events).toEqual([
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "reasoning-delta", text: "thinking" },
{ type: "text-delta", text: "Hello" },
{ type: "text-delta", text: "!" },
{
type: "request-finish",
reason: "stop",
@@ -225,8 +225,8 @@ describe("OpenAI Chat route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: "!" },
{ type: "text-delta", text: "Hello" },
{ type: "text-delta", text: "!" },
{
type: "request-finish",
reason: "stop",
@@ -336,8 +336,8 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "request-finish",
reason: "stop",
@@ -394,12 +394,14 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query"',
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
+1 -1
View File
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer),
)
const requestExecutor = RequestExecutor.layer.pipe(
+3 -2
View File
@@ -1,13 +1,14 @@
import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder"
import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder"
import { Effect, Layer } from "effect"
import { WebSocketExecutor } from "../src/route"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
const liveWebSocket = WebSocketExecutor.open
type Mode = "record" | "replay" | "passthrough"
export const webSocketCassetteLayer = (
cassette: string,
input: { readonly metadata?: Record<string, unknown>; readonly mode: RecordReplayMode },
input: { readonly metadata?: Record<string, unknown>; readonly mode: Mode },
): Layer.Layer<WebSocketExecutorService, never, Cassette.Service> =>
Layer.effect(
WebSocketExecutor.Service,
-7
View File
@@ -9,13 +9,6 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
## Development server
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
# Module shape
Do not use `export namespace Foo { ... }` for module organization. It is not
+3 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.47",
"version": "1.14.46",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -13,6 +13,8 @@
"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"
},
@@ -0,0 +1,181 @@
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>
+11 -47
View File
@@ -19,9 +19,11 @@ 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",
@@ -51,53 +53,9 @@ function questionKind(value: string | undefined): QuestionKind | undefined {
return QUESTIONS.find((item) => item === next)
}
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")
function markdownPattern(value: string): keyof typeof MARKDOWN_PATTERNS | undefined {
if (value in MARKDOWN_PATTERNS) return value as keyof typeof MARKDOWN_PATTERNS
}
type Ref = {
msg: string
@@ -1031,6 +989,12 @@ 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
+7 -3
View File
@@ -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 { createRunDemo } from "./demo"
import type { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
@@ -135,6 +135,10 @@ 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,
@@ -425,7 +429,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
if (input.demo) {
await ensureSession()
state.demo = createRunDemo({
state.demo = await createDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
@@ -548,7 +552,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.history = []
includeFiles = true
state.demo = input.demo
? createRunDemo({
? await createDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
+6 -11
View File
@@ -93,6 +93,7 @@ const appBindingCommands = [
"theme.mode.lock",
"help.show",
"docs.open",
"app.exit",
"app.debug",
"app.console",
"app.heap_snapshot",
@@ -647,6 +648,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
run: () => exit(),
category: "System",
},
@@ -779,17 +785,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
enabled: () => {
const ok = command.matcher.get()
if (!ok) return false
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
}))
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.run(evt.properties.command)
})
@@ -712,6 +712,7 @@ export function Prompt(props: PromptProps) {
...input.traits,
...computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
}),
}
@@ -4,6 +4,7 @@ export type PromptMode = "normal" | "shell"
export interface PromptTraitsInput {
mode: PromptMode
disabled: boolean
autocompleteVisible: boolean
}
@@ -15,9 +16,10 @@ export type PromptTraits = EditorTraits & {
/**
* Compute the textarea editor traits for the prompt.
*
* The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits
* only expose capture/status metadata so focus changes cannot unsuspend the
* keymap-managed editor mappings.
* `traits.suspend` gates the textarea's keybinding actions (backspace,
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
* editing mode — only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor.
*/
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
const capture =
@@ -28,6 +30,7 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
: undefined
return {
capture,
suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined,
owner: "opencode",
role: "prompt",
@@ -962,6 +962,8 @@ function getSyntaxRules(theme: Theme) {
style: {
foreground: theme.markdownHeading,
bold: true,
italic: true,
underline: true,
},
},
{
@@ -1175,6 +1177,7 @@ function getSyntaxRules(theme: Theme) {
scope: ["markup.strikethrough"],
style: {
foreground: theme.textMuted,
strikethrough: true,
},
},
{
+285
View File
@@ -0,0 +1,285 @@
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,
}
}
+7 -31
View File
@@ -8,9 +8,9 @@ import {
import {
KeymapProvider,
reactiveMatcherFromSignal,
useBindings,
useKeymap,
useKeymapSelector,
useBindings,
} from "@opentui/keymap/solid"
import type { Accessor } from "solid-js"
import type { TuiConfig } from "./config/tui"
@@ -26,28 +26,6 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
const KEY_ALIASES = {
enter: "return",
esc: "escape",
} as const
function expandKeyAliases(input: string) {
const result = Object.entries(KEY_ALIASES).reduce(
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
input,
)
if (result === input) return
return result
}
function registerKeyAliases(keymap: OpenTuiKeymap) {
return keymap.appendBindingExpander((ctx) => {
const key = expandKeyAliases(ctx.input)
if (!key) return
return [{ key, displays: ctx.displays }]
})
}
const inputCommands = [
"input.move.left",
"input.move.right",
@@ -120,13 +98,8 @@ export function formatKeyBindings(
return formatCommandBindingsExtra(bindings, formatOptions(config))
}
export function registerOpencodeKeymap(
keymap: OpenTuiKeymap,
renderer: CliRenderer,
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
) {
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
const offCommaBindings = addons.registerCommaBindings(keymap)
const offAliasExpander = registerKeyAliases(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
const offLeader = addons.registerTimedLeader(keymap, {
trigger: config.keybinds.get(LEADER_TOKEN),
@@ -135,17 +108,20 @@ export function registerOpencodeKeymap(
})
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
const offInputBindings = keymap.registerLayer({
enabled: () => renderer.currentFocusedEditor !== null,
bindings: config.keybinds.gather("input", inputCommands),
})
return () => {
offInputBindings()
offInputSuspension()
offInputCommands()
offBackspace()
offEscape()
offLeader()
offAliasExpander()
offBaseLayout()
offCommaBindings()
}
@@ -21,7 +21,20 @@ 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 } from "@opentui/core"
import {
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
TextAttributes,
RGBA,
type MarkdownOptions,
type MarkdownRenderable,
CodeRenderable,
TextTableRenderable,
TextRenderable,
StyledText,
type TextChunk,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
AssistantMessage,
@@ -1525,15 +1538,180 @@ 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={props.part.text.trim()}>
<Show when={text()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={syntax()}
streaming={true}
content={props.part.text.trim()}
ref={configureMarkdown}
tableOptions={{ style: "grid", widthMode: "content" }}
content={text()}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
@@ -1545,7 +1723,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={props.part.text.trim()}
content={text()}
conceal={ctx.conceal()}
fg={theme.text}
/>
+31 -4
View File
@@ -111,6 +111,10 @@ 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.
@@ -130,7 +134,6 @@ 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 {
@@ -138,6 +141,32 @@ 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(),
@@ -187,8 +216,6 @@ 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") ||
@@ -236,7 +263,7 @@ export const TuiThreadCommand = cmd({
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
config: await config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,
@@ -1,8 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
export async function validateSession(input: {
url: string
@@ -13,11 +10,9 @@ export async function validateSession(input: {
}) {
if (!input.sessionID) return
let sessionID: SessionID
try {
sessionID = decodeSessionID(input.sessionID)
} catch (error) {
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
const result = SessionID.zod.safeParse(input.sessionID)
if (!result.success) {
throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
}
await createOpencodeClient({
@@ -25,5 +20,5 @@ export async function validateSession(input: {
directory: input.directory,
fetch: input.fetch,
headers: input.headers,
}).session.get({ sessionID }, { throwOnError: true })
}).session.get({ sessionID: result.data }, { throwOnError: true })
}
@@ -1,6 +1,7 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } 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"))
@@ -10,5 +11,6 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
zod: zod(schema),
})),
)
@@ -30,6 +30,7 @@ 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"
@@ -85,6 +86,7 @@ export const AppLayer = Layer.mergeAll(
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionTimeline.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
@@ -21,7 +21,6 @@ import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
// SSE event schemas built from the BusEvent/SyncEvent registries.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
@@ -30,7 +29,6 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
.middleware(SchemaErrorMiddleware)
.middleware(Authorization)
export const InstanceHttpApi = HttpApi.make("opencode-instance")
@@ -49,7 +47,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(V2Api)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
.middleware(SchemaErrorMiddleware)
export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
@@ -1,30 +0,0 @@
import { Effect } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server" })
// Effect's Issue formatter recursively dumps the rejected `actual` value with
// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep
// 4xx responses small and avoid mirroring entire request payloads (which may
// contain secrets) into the response body and log file.
const REASON_LIMIT = 1024
function truncateReason(reason: string) {
if (reason.length <= REASON_LIMIT) return reason
return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)`
}
// Default Respondable returns an empty 400 body. Match the NamedError shape
// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`.
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
"@opencode/HttpApiSchemaError",
) {}
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
const reason = truncateReason(error.cause.message)
log.warn("schema rejection", { kind: error.kind, reason })
return Effect.succeed(
HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }),
)
})
@@ -178,20 +178,17 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) {
if (!spec.components?.schemas) return
spec.components.schemas.BadRequestError = {
type: "object",
required: ["name", "data"],
required: ["data", "errors", "success"],
properties: {
name: { type: "string", enum: ["BadRequest"] },
data: {
type: "object",
required: ["message"],
properties: {
message: { type: "string" },
kind: {
type: "string",
enum: ["Params", "Headers", "Query", "Body", "Payload"],
},
data: {},
errors: {
type: "array",
items: {
type: "object",
additionalProperties: {},
},
},
success: { type: "boolean", enum: [false] },
},
}
spec.components.schemas.NotFoundError = {
@@ -41,6 +41,7 @@ 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"
@@ -84,7 +85,6 @@ import { compressionLayer } from "./middleware/compression"
import { corsVaryFix } from "./middleware/cors-vary"
import { errorLayer } from "./middleware/error"
import { fenceLayer } from "./middleware/fence"
import { schemaErrorLayer } from "./middleware/schema-error"
export const context = Context.makeUnsafe<unknown>(new Map())
@@ -115,7 +115,6 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
const instanceRouterLayer = authorizationRouterMiddleware
@@ -152,7 +151,6 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
httpApiAuthLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
schemaErrorLayer,
]),
)
@@ -213,6 +211,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
SessionTimeline.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
+13 -3
View File
@@ -4,6 +4,7 @@ import * as Session from "./session"
import { SessionID, MessageID, PartID } from "./schema"
import { Provider } from "@/provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
import { Token } from "@/util/token"
import * as Log from "@opencode-ai/core/util/log"
import { SessionProcessor } from "./processor"
@@ -17,7 +18,7 @@ import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { fn } from "@/util/fn"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
@@ -207,8 +208,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<
Service,
never,
@@ -639,4 +638,15 @@ export async function prune(input: { sessionID: SessionID }) {
return runPromise((svc) => svc.prune(input))
}
export const create = fn(
z.object({
sessionID: SessionID.zod,
agent: z.string(),
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
auto: z.boolean(),
overflow: z.boolean().optional(),
}),
(input) => runPromise((svc) => svc.create(input)),
)
export * as SessionCompaction from "./compaction"
+31 -51
View File
@@ -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 { SessionRevert } from "./revert"
import { SessionTimeline } from "./timeline"
import * as Session from "./session"
import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider"
@@ -114,7 +114,7 @@ export const layer = Layer.effect(
const scope = yield* Scope.Scope
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service
const timeline = yield* SessionTimeline.Service
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
@@ -747,9 +747,7 @@ 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* revert.cleanup(session)
}
if (session.revert) yield* timeline.commitPending({ sessionID: session.id })
const agent = yield* agents.get(input.agent)
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
@@ -758,7 +756,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID))
const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
const userMsg: MessageV2.User = {
id: input.messageID ?? MessageID.ascending(),
sessionID: input.sessionID,
@@ -916,17 +914,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return yield* Effect.failCause(exit.cause)
})
const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
const current = Database.use((db) =>
db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(),
)
if (current?.model) {
return {
providerID: ProviderID.make(current.model.providerID),
modelID: ModelID.make(current.model.id),
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
}
}
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
return yield* provider.defaultModel()
@@ -943,14 +931,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
throw error
}
const current = Database.use((db) =>
db
.select({ agent: SessionTable.agent, model: SessionTable.model })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get(),
)
const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
const full =
!input.variant && ag.variant && same
@@ -974,35 +955,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
format: input.format,
}
const current = Database.use((db) =>
db
.select({ agent: SessionTable.agent, model: SessionTable.model })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get(),
)
if (current?.agent !== info.agent) {
EventV2.run(
SessionEvent.AgentSwitched.Sync,
{
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
agent: info.agent,
},
{ bypassExperimentalEventSystem: true },
)
EventV2.run(SessionEvent.AgentSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
agent: info.agent,
})
}
if (
current?.model?.providerID !== info.model.providerID ||
current.model.id !== info.model.modelID ||
(current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant
current.model.variant !== info.model.variant
) {
EventV2.run(
SessionEvent.ModelSwitched.Sync,
{
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
model: {
id: Modelv2.ID.make(info.model.modelID),
providerID: Modelv2.ProviderID.make(info.model.providerID),
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
},
EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
model: {
id: Modelv2.ID.make(info.model.modelID),
providerID: Modelv2.ProviderID.make(info.model.providerID),
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
},
{ bypassExperimentalEventSystem: true },
)
})
}
yield* Effect.addFinalizer(() => instruction.clear(info.id))
@@ -1396,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
"SessionPrompt.prompt",
)(function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
yield* timeline.commitPending({ sessionID: session.id })
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
@@ -1722,7 +1702,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (cmdAgent?.model) return cmdAgent.model
}
if (input.model) return Provider.parseModel(input.model)
return yield* currentModel(input.sessionID)
return yield* lastModel(input.sessionID)
})
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
@@ -1755,7 +1735,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const userModel = isSubtask
? input.model
? Provider.parseModel(input.model)
: yield* currentModel(input.sessionID)
: yield* lastModel(input.sessionID)
: taskModel
yield* plugin.trigger(
@@ -1810,7 +1790,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(
+8 -121
View File
@@ -1,16 +1,10 @@
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 { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary"
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
import { SessionTimeline } from "./timeline"
const log = Log.create({ service: "session.revert" })
@@ -18,6 +12,7 @@ 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>
@@ -32,133 +27,25 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
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 sync = yield* SyncEvent.Service
const timeline = yield* SessionTimeline.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
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)
return yield* timeline.rewind(input)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", 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)
return yield* timeline.restore(input)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
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)
yield* timeline.commitPending({ sessionID: session.id })
})
return Service.of({ revert, unrevert, cleanup })
}),
)
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 const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(SessionTimeline.defaultLayer)))
export * as SessionRevert from "./revert"
+7
View File
@@ -1,12 +1,14 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)),
zod: zod(s),
})),
)
@@ -16,6 +18,7 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
zod: zod(s),
})),
)
@@ -25,7 +28,11 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
Schema.brand("PartID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
zod: zod(s),
})),
)
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>
+2 -1
View File
@@ -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 } from "./schema"
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -166,6 +166,7 @@ const Time = Schema.Struct({
const Revert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
files: optionalOmitUndefined(RewindFilePolicy),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
+159
View File
@@ -0,0 +1,159 @@
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"
+2 -3
View File
@@ -44,10 +44,9 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
export function run<Def extends SyncEvent.Definition>(
def: Def,
data: SyncEvent.Event<Def>["data"],
// Temporary escape hatch while the full v2 event system remains experimental.
options?: { publish?: boolean; bypassExperimentalEventSystem?: boolean },
options?: { publish?: boolean },
) {
if (!options?.bypassExperimentalEventSystem && !Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return
if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return
SyncEvent.run(def, data, options)
}
+10 -18
View File
@@ -269,26 +269,18 @@ export const layer = Layer.effect(
shell: Effect.fn("V2Session.shell")(function* (_input) {}),
skill: Effect.fn("V2Session.skill")(function* (_input) {}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
EventV2.run(
SessionEvent.AgentSwitched.Sync,
{
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
agent: input.agent,
},
{ bypassExperimentalEventSystem: true },
)
EventV2.run(SessionEvent.AgentSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
EventV2.run(
SessionEvent.ModelSwitched.Sync,
{
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
model: input.model,
},
{ bypassExperimentalEventSystem: true },
)
EventV2.run(SessionEvent.ModelSwitched.Sync, {
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
model: input.model,
})
}),
subagent: Effect.fn("V2Session.subagent")(function* (input) {
const parent = yield* result.get(input.parentID)
@@ -3,27 +3,36 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp
describe("computePromptTraits", () => {
test("normal mode without autocomplete only captures tab", () => {
const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false })
const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false })
expect(traits.capture).toEqual(["tab"])
expect(traits.suspend).toBeUndefined()
expect(traits.suspend).toBe(false)
expect(traits.status).toBeUndefined()
})
test("normal mode with autocomplete captures navigation keys", () => {
const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true })
const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true })
expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"])
expect(traits.suspend).toBeUndefined()
expect(traits.suspend).toBe(false)
expect(traits.status).toBeUndefined()
})
test("shell mode does not write the keymap-owned suspend trait", () => {
const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
expect(traits.suspend).toBeUndefined()
test("shell mode does not suspend the textarea", () => {
// Suspending the textarea would gate every keybinding action
// (backspace, delete-word-backward, arrow movement, etc.) — see
// @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is
// an active editing mode, so suspend must stay off.
const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
expect(traits.suspend).toBe(false)
})
test("shell mode disables capture and labels the prompt", () => {
const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false })
const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false })
expect(traits.capture).toBeUndefined()
expect(traits.status).toBe("SHELL")
})
test("disabled suspends regardless of mode", () => {
expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true)
})
})
@@ -1,162 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { eq } from "drizzle-orm"
import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const withTmp = <A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(fn))
async function seedCorruptStepFinishPart(directory: string) {
return WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Schema.Finite still rejects NaN at encode — exact mirror of the
// corrupt row that broke the user's session in the OMO/Windows bug.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
)
return info.id
}).pipe(Effect.provide(Session.defaultLayer)),
),
})
}
describe("schema-rejection wire shape", () => {
it.live(
"Payload schema rejection returns NamedError-shaped JSON, not empty",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
)
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
expect(res.headers.get("content-type") ?? "").toContain("application/json")
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({
name: "BadRequest",
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
})
expect(parsed.data.message).toEqual(expect.any(String))
expect(parsed.data.message.length).toBeGreaterThan(0)
}),
),
)
it.live(
"Query schema rejection returns NamedError-shaped JSON",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
// /find/file?limit=999999 violates the limit constraint check.
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
}),
),
)
it.live(
"rejected request body never echoes back unbounded — message is capped",
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const huge = "X".repeat(50_000)
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ aggregate: huge }),
}),
)
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
expect(body.length).toBeLessThan(2 * 1024)
const parsed = JSON.parse(body)
expect(parsed.data.message).not.toContain(huge)
}),
),
)
it.live(
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
expect(res.headers.get("content-type") ?? "").toContain("application/json")
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
// Field path in data.message — what made this PR worth shipping.
expect(parsed.data.message).toMatch(/output/)
}),
),
)
})
@@ -52,33 +52,23 @@ describe("v2 SDK error shape", () => {
})
})
test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
// Canary for the #26631 wire shape. Asserts the contract end-to-end:
// server emits {name:"BadRequest", data:{message, kind}}, SDK's
// wrapClientError extracts .data.message into Error.message. If either
// side regresses (#26457 reverted because both layers were missing),
// this test fails before users see (empty response body).
test("400 with empty body throws a real Error naming the status", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
// POST /sync/history with `aggregate: -1` triggers schema validation
// that returns an empty 400 body (verified via plan-mode probe).
await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { body?: any; status?: number }
const cause = err.cause as { status?: number }
expect(err.message.length).toBeGreaterThan(0)
expect(cause.status).toBe(400)
expect(cause.body).toMatchObject({
name: "BadRequest",
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
})
expect(typeof cause.body.data.message).toBe("string")
expect(cause.body.data.message.length).toBeGreaterThan(0)
// Whatever the server put in data.message must be what the user sees.
expect(err.message).toBe(cause.body.data.message)
})
})
@@ -1,60 +0,0 @@
// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints
// against the current server. v1 generation has been frozen since #5216
// (2025-12-07) so types may be stale, but runtime calls should still work
// for endpoints the v1 SDK was generated against.
import { afterEach, describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../src/server/server"
import { tmpdir, disposeAllInstances } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import * as Log from "@opencode-ai/core/util/log"
void Log.init({ print: false })
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function client(directory: string) {
return createOpencodeClient({
baseUrl: "http://test",
directory,
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
})
}
describe("v1 SDK runtime smoke", () => {
test("session.list reaches the server and returns 200", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.session.list()
expect(result.error).toBeUndefined()
expect(Array.isArray(result.data)).toBe(true)
})
test("path.get reaches the server and returns 200", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.path.get()
expect(result.error).toBeUndefined()
expect(result.data).toBeDefined()
})
test("config.get reaches the server and returns 200", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.config.get()
expect(result.error).toBeUndefined()
expect(result.data).toBeDefined()
})
test("session 404: result-tuple path returns the error body", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.session.get({ path: { id: "ses_no_such" } as never })
expect(result.error).toBeDefined()
// wire body for 404 is NamedError-shaped
expect(result.error).toMatchObject({ name: "NotFoundError" })
})
})
File diff suppressed because it is too large Load Diff
@@ -30,8 +30,8 @@ import { SessionSummary } from "../../src/session/summary"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionRunState } from "../../src/session/run-state"
import { SessionTimeline } from "../../src/session/timeline"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionV2 } from "../../src/v2/session"
@@ -199,7 +199,7 @@ function makeHttp() {
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert"
import { SessionTimeline } from "../../src/session/timeline"
import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot"
import * as Log from "@opencode-ai/core/util/log"
@@ -18,6 +19,7 @@ void Log.init({ print: false })
const env = Layer.mergeAll(
Session.defaultLayer,
SessionRevert.defaultLayer,
SessionTimeline.defaultLayer,
Snapshot.defaultLayer,
CrossSpawnSpawner.defaultLayer,
)
@@ -96,6 +98,51 @@ const tokens = {
cache: { read: 0, write: 0 },
}
const fileTurn = Effect.fn("test.fileTurn")(function* (input: {
sessionID: SessionID
dir: string
file: string
content: string
}) {
const session = yield* Session.Service
const snapshot = yield* Snapshot.Service
const userMsg = yield* user(input.sessionID)
yield* text(input.sessionID, userMsg.id, `${input.file}:${input.content}`)
const assistantMsg = yield* assistant(input.sessionID, userMsg.id, input.dir)
const before = yield* snapshot.track()
if (!before) throw new Error("expected snapshot")
yield* write(path.join(input.dir, input.file), input.content)
const after = yield* snapshot.track()
if (!after) throw new Error("expected snapshot")
const patch = yield* snapshot.patch(before)
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID: input.sessionID,
type: "step-start",
snapshot: before,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID: input.sessionID,
type: "step-finish",
reason: "stop",
snapshot: after,
cost: 0,
tokens,
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: assistantMsg.id,
sessionID: input.sessionID,
type: "patch",
hash: patch.hash,
files: patch.files,
})
return { user: userMsg, assistant: assistantMsg }
})
describe("revert + compact workflow", () => {
it.live(
"should properly handle compact command after revert",
@@ -636,4 +683,58 @@ describe("revert + compact workflow", () => {
{ git: true },
),
)
it.live(
"timeline rewind can keep file changes while hiding future messages",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const timeline = yield* SessionTimeline.Service
yield* write(path.join(dir, "a.txt"), "a0")
const info = yield* session.create({})
const sid = info.id
const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" })
const rewound = yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" })
expect(rewound.revert?.messageID).toBe(turn.user.id)
expect(rewound.revert?.files).toBe("keep")
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
yield* timeline.commitPending({ sessionID: rewound.id })
expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([])
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
}),
{ git: true },
),
)
it.live(
"timeline restore keeps files for keep-file rewinds",
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const session = yield* Session.Service
const timeline = yield* SessionTimeline.Service
yield* write(path.join(dir, "a.txt"), "a0")
const info = yield* session.create({})
const sid = info.id
const turn = yield* fileTurn({ sessionID: sid, dir, file: "a.txt", content: "a1" })
yield* timeline.rewind({ sessionID: sid, messageID: turn.user.id, files: "keep" })
const restored = yield* timeline.restore({ sessionID: sid })
expect(restored.revert).toBeUndefined()
expect((yield* session.messages({ sessionID: sid })).map((msg) => msg.info.id)).toEqual([
turn.user.id,
turn.assistant.id,
])
expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
}),
{ git: true },
),
)
})
@@ -4,6 +4,7 @@ import { Schema } from "effect"
import { Session } from "@/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionTimeline } from "../../src/session/timeline"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { Todo } from "../../src/session/todo"
@@ -15,20 +16,20 @@ import { WorkspaceID } from "../../src/control-plane/schema"
// schema we assert:
// 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input.
// 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the
// same shape for schemas that still expose Zod statics.
// 3. Clearly-invalid input is rejected by both paths where both exist.
// same shape.
// 3. Clearly-invalid input is rejected by both paths.
//
// The point is to lock down the Schema <-> Zod bridge so a future edit to
// any input schema can't silently drop or widen a field on one side.
// Representative valid IDs — the branded schemas require the right prefix
// (see src/id/id.ts).
const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K")
const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L")
const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M")
const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N")
const projectID = ProjectID.zod.parse("proj-alpha")
const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary")
const workspaceID = WorkspaceID.zod.parse("wrk-primary")
function decodeUnknown<S extends Schema.Top>(schema: S) {
const decode = Schema.decodeUnknownSync(schema as any)
@@ -218,8 +219,8 @@ describe("Session input schemas", () => {
describe("SessionRevert.RevertInput", () => {
const decode = decodeUnknown(SessionRevert.RevertInput)
test("messageID is required, partID is optional", () => {
const withPart = { sessionID, messageID, partID }
test("messageID is required, partID and file policy are optional", () => {
const withPart = { sessionID, messageID, partID, files: "keep" as const }
expect(decode(withPart)).toEqual(withPart)
expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
@@ -232,6 +233,20 @@ describe("SessionRevert.RevertInput", () => {
})
})
describe("SessionTimeline.RewindInput", () => {
const decode = decodeUnknown(SessionTimeline.RewindInput)
test("accepts optional file policy", () => {
const keep = { sessionID, messageID, files: "keep" as const }
expect(decode(keep)).toEqual(keep)
expect(SessionTimeline.RewindInput.zod.parse(keep)).toEqual(keep)
const revert = { sessionID, messageID, partID, files: "revert" as const }
expect(decode(revert)).toEqual(revert)
expect(SessionTimeline.RewindInput.zod.parse(revert)).toEqual(revert)
})
})
describe("SessionSummary.DiffInput", () => {
const decode = decodeUnknown(SessionSummary.DiffInput)
@@ -63,13 +63,14 @@ describe("Session schema", () => {
revert: {
messageID: MessageID.ascending(),
partID: undefined,
files: undefined,
snapshot: undefined,
diff: undefined,
},
}) as Record<string, unknown>
expect(Object.hasOwn(encoded.summary as Record<string, unknown>, "diffs")).toBe(false)
for (const key of ["partID", "snapshot", "diff"]) {
for (const key of ["partID", "files", "snapshot", "diff"]) {
expect(Object.hasOwn(encoded.revert as Record<string, unknown>, key)).toBe(false)
}
})
@@ -19,8 +19,8 @@ import path from "path"
import { Session } from "@/session/session"
import { LLM } from "../../src/session/llm"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionRevert } from "../../src/session/revert"
import { SessionSummary } from "../../src/session/summary"
import { SessionTimeline } from "../../src/session/timeline"
import { MessageV2 } from "../../src/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { provideTmpdirServer } from "../fixture/fixture"
@@ -150,7 +150,7 @@ function makeHttp() {
TestLLMServer.layer,
SessionSummary.defaultLayer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provideMerge(run),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.47",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.47",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
+5 -5
View File
@@ -752,11 +752,11 @@ export type Project = {
}
export type BadRequestError = {
name: "BadRequest"
data: {
message: string
kind?: "Params" | "Headers" | "Query" | "Body" | "Payload"
}
data: unknown
errors: Array<{
[key: string]: unknown
}>
success: false
}
export type NotFoundError = {
+2
View File
@@ -3844,6 +3844,7 @@ export class Session2 extends HeyApiClient {
workspace?: string
messageID?: string
partID?: string
files?: "revert" | "keep"
},
options?: Options<never, ThrowOnError>,
) {
@@ -3857,6 +3858,7 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
{ in: "body", key: "partID" },
{ in: "body", key: "files" },
],
},
],
+92 -88
View File
@@ -5,6 +5,12 @@ export type ClientOptions = {
}
export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@@ -24,10 +30,6 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@@ -75,8 +77,6 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
export type OAuth = {
type: "oauth"
@@ -103,6 +103,61 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type PermissionRequest = {
id: string
sessionID: string
@@ -280,61 +335,6 @@ export type SessionStatus =
type: "busy"
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type Project = {
id: string
worktree: string
@@ -762,6 +762,7 @@ export type Session = {
revert?: {
messageID: string
partID?: string
files?: "revert" | "keep"
snapshot?: string
diff?: string
}
@@ -778,6 +779,12 @@ export type GlobalEvent = {
project?: string
workspace?: string
payload:
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventServerConnected
| EventGlobalDisposed
| EventServerInstanceDisposed
| EventFileEdited
| EventFileWatcherUpdated
@@ -797,10 +804,6 @@ export type GlobalEvent = {
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
@@ -848,8 +851,6 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventServerConnected
| EventGlobalDisposed
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@@ -1450,6 +1451,7 @@ export type GlobalSession = {
revert?: {
messageID: string
partID?: string
files?: "revert" | "keep"
snapshot?: string
diff?: string
}
@@ -1913,6 +1915,7 @@ export type SyncEventSessionUpdated = {
revert?: {
messageID: string
partID?: string
files?: "revert" | "keep"
snapshot?: string
diff?: string
} | null
@@ -2330,6 +2333,22 @@ export type SyncEventSessionNextCompactionEnded = {
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
id: string
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
@@ -3044,22 +3063,6 @@ export type EventSessionNextCompactionEnded = {
}
}
export type EventServerConnected = {
id: string
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
id: string
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type SessionInfo = {
id: string
parentID?: string
@@ -3296,11 +3299,11 @@ export type EventTuiToastShow1 = {
}
export type BadRequestError = {
name: "BadRequest"
data: {
message: string
kind?: "Params" | "Headers" | "Query" | "Body" | "Payload"
}
data: unknown
errors: Array<{
[key: string]: unknown
}>
success: false
}
export type AuthRemoveData = {
@@ -5935,6 +5938,7 @@ export type SessionRevertData = {
body?: {
messageID: string
partID?: string
files?: "revert" | "keep"
}
path: {
sessionID: string
+11 -16
View File
@@ -18725,24 +18725,19 @@
},
"BadRequestError": {
"type": "object",
"required": ["name", "data"],
"required": ["data", "errors", "success"],
"properties": {
"name": {
"type": "string",
"enum": ["BadRequest"]
},
"data": {
"type": "object",
"required": ["message"],
"properties": {
"message": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["Params", "Headers", "Query", "Body", "Payload"]
}
"data": {},
"errors": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {}
}
},
"success": {
"type": "boolean",
"enum": [false]
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.47",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.47",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.47",
"version": "1.14.46",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.47",
"version": "1.14.46",
"publisher": "sst-dev",
"repository": {
"type": "git",