mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4217091d02 | |||
| f778c685f5 | |||
| 353532b1c1 | |||
| 9df7c78ebe | |||
| eb7555d3c6 | |||
| 2cd89d64e9 | |||
| 0517ab4695 | |||
| bbf67d0fff |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -117,7 +117,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -225,7 +225,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -269,7 +269,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@opencode-ai/shared": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -298,7 +298,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -314,7 +314,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -459,7 +459,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -494,7 +494,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@opencode-ai/shared",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -568,7 +568,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -617,7 +617,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.21"
|
||||
version = "1.14.22"
|
||||
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.21/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/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.21/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.21/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/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.21/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/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.21/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.22/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -159,6 +159,7 @@ Schema at source.
|
||||
These are the highest-priority next targets. Each is a small, self-contained
|
||||
schema module with a clear domain.
|
||||
|
||||
- [x] `src/account/schema.ts`
|
||||
- [x] `src/control-plane/schema.ts`
|
||||
- [x] `src/permission/schema.ts`
|
||||
- [x] `src/project/schema.ts`
|
||||
@@ -166,8 +167,10 @@ schema module with a clear domain.
|
||||
- [x] `src/pty/schema.ts`
|
||||
- [x] `src/question/schema.ts`
|
||||
- [x] `src/session/schema.ts`
|
||||
- [x] `src/storage/schema.ts`
|
||||
- [x] `src/sync/schema.ts`
|
||||
- [x] `src/tool/schema.ts`
|
||||
- [x] `src/util/schema.ts`
|
||||
|
||||
### Session domain
|
||||
|
||||
@@ -248,15 +251,15 @@ Possible later tightening after the Schema-first migration is stable:
|
||||
- promote repeated opaque strings and timestamp numbers into branded/newtype
|
||||
leaf schemas where that adds domain value without changing the wire format
|
||||
|
||||
- [ ] `src/session/compaction.ts`
|
||||
- [ ] `src/session/message-v2.ts`
|
||||
- [ ] `src/session/message.ts`
|
||||
- [ ] `src/session/prompt.ts`
|
||||
- [ ] `src/session/revert.ts`
|
||||
- [ ] `src/session/session.ts`
|
||||
- [ ] `src/session/status.ts`
|
||||
- [ ] `src/session/summary.ts`
|
||||
- [ ] `src/session/todo.ts`
|
||||
- [x] `src/session/compaction.ts`
|
||||
- [x] `src/session/message-v2.ts`
|
||||
- [x] `src/session/message.ts`
|
||||
- [x] `src/session/prompt.ts`
|
||||
- [x] `src/session/revert.ts`
|
||||
- [x] `src/session/session.ts`
|
||||
- [x] `src/session/status.ts`
|
||||
- [x] `src/session/summary.ts`
|
||||
- [x] `src/session/todo.ts`
|
||||
|
||||
### Provider domain
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
@@ -901,7 +901,7 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ShareNext } from "../../share"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Schema } from "effect"
|
||||
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
|
||||
export type ShareData =
|
||||
@@ -154,10 +155,10 @@ export const ImportCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const info = Session.Info.parse({
|
||||
const info = Schema.decodeUnknownSync(Session.Info)({
|
||||
...exportData.info,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
}) as Session.Info
|
||||
const row = Session.toRow(info)
|
||||
Database.use((db) =>
|
||||
db
|
||||
|
||||
@@ -1250,7 +1250,17 @@ function UserMessage(props: {
|
||||
}) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
|
||||
const text = createMemo(() => {
|
||||
const texts = props.parts
|
||||
.map((x) => {
|
||||
if (x.type === "text" && !x.synthetic) {
|
||||
return x.text
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
return texts.join("\n\n")
|
||||
})
|
||||
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
|
||||
const { theme } = useTheme()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
@@ -1285,7 +1295,7 @@ function UserMessage(props: {
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text}>{text()?.text}</text>
|
||||
<text fg={theme.text}>{text()}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
|
||||
@@ -132,6 +132,17 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
||||
)
|
||||
|
||||
const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) {
|
||||
const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"]
|
||||
const result = yield* run([method, ...args])
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return yield* new UpgradeFailedError({
|
||||
stderr: result.stderr || result.stdout || `Failed to resolve ${spec}`,
|
||||
})
|
||||
}
|
||||
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout)
|
||||
})
|
||||
|
||||
const getBrewFormula = Effect.fnUntraced(function* () {
|
||||
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||
@@ -217,15 +228,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
||||
}
|
||||
|
||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
||||
const reg = r || "https://registry.npmjs.org"
|
||||
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||
const channel = InstallationChannel
|
||||
const response = yield* httpOk.execute(
|
||||
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
|
||||
return data.version
|
||||
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
|
||||
}
|
||||
|
||||
if (detectedMethod === "choco") {
|
||||
|
||||
@@ -6,12 +6,14 @@ import npa from "npm-package-arg"
|
||||
import semver from "semver"
|
||||
import Config from "@npmcli/config"
|
||||
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Global } from "@opencode-ai/shared/global"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
|
||||
import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner"
|
||||
import { makeRuntime } from "../effect/runtime"
|
||||
|
||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||
@@ -106,7 +108,33 @@ export const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const flock = yield* EffectFlock.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
||||
const runView = Effect.fnUntraced(function* (cmd: string[]) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||
extendEnv: true,
|
||||
}),
|
||||
)
|
||||
const [stdout, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
if (code !== 0 || !stdout.trim()) {
|
||||
return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`)
|
||||
}
|
||||
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout)
|
||||
}, Effect.scoped)
|
||||
const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) {
|
||||
return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
|
||||
Effect.catch(() =>
|
||||
runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
|
||||
Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
const reify = (input: { dir: string; add?: string[] }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||
@@ -143,29 +171,15 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
|
||||
if (Option.isNone(latestVersion)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion.value, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
return semver.lt(cachedVersion, latestVersion.value)
|
||||
})
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||
@@ -304,6 +318,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -335,7 +335,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.array()),
|
||||
schema: resolver(Session.GlobalInfo.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
@@ -42,7 +43,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info.array()),
|
||||
schema: resolver(Session.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -87,7 +88,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Get session status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), SessionStatus.Info)),
|
||||
schema: resolver(z.record(z.string(), SessionStatus.Info.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -112,7 +113,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Get session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -122,7 +123,7 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.GetInput,
|
||||
sessionID: Session.GetInput.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -145,7 +146,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "List of children",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info.array()),
|
||||
schema: resolver(Session.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -155,7 +156,7 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.ChildrenInput,
|
||||
sessionID: Session.ChildrenInput.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -177,7 +178,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Todo list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Todo.Info.array()),
|
||||
schema: resolver(Todo.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -210,13 +211,13 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Successfully created session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", Session.CreateInput),
|
||||
validator("json", Session.CreateInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.create", c, function* () {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
@@ -245,7 +246,7 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.RemoveInput,
|
||||
sessionID: Session.RemoveInput.zod,
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
@@ -267,7 +268,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Successfully updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -375,7 +376,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "200",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -384,14 +385,14 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.ForkInput.shape.sessionID,
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Session.ForkInput.omit({ sessionID: true })),
|
||||
validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })),
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.fork", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const body = c.req.valid("json") as { messageID?: MessageID }
|
||||
const svc = yield* Session.Service
|
||||
return yield* svc.fork({ ...body, sessionID })
|
||||
}),
|
||||
@@ -438,7 +439,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Successfully shared session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -480,18 +481,13 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: SessionSummary.DiffInput.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
messageID: SessionSummary.DiffInput.shape.messageID,
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })),
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.diff", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const query = c.req.valid("query") as Omit<SessionSummary.DiffInput, "sessionID">
|
||||
const params = c.req.valid("param")
|
||||
const summary = yield* SessionSummary.Service
|
||||
return yield* summary.diff({
|
||||
@@ -511,7 +507,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Successfully unshared session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -872,7 +868,7 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
|
||||
validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
c.status(200)
|
||||
c.header("Content-Type", "application/json")
|
||||
@@ -910,7 +906,7 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
|
||||
validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
@@ -960,11 +956,11 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
|
||||
validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })),
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.command", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const body = c.req.valid("json") as Omit<SessionPrompt.CommandInput, "sessionID">
|
||||
const svc = yield* SessionPrompt.Service
|
||||
return yield* svc.command({ ...body, sessionID })
|
||||
}),
|
||||
@@ -993,11 +989,11 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
|
||||
validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })),
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.shell", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const body = c.req.valid("json") as Omit<SessionPrompt.ShellInput, "sessionID">
|
||||
const svc = yield* SessionPrompt.Service
|
||||
return yield* svc.shell({ ...body, sessionID })
|
||||
}),
|
||||
@@ -1013,7 +1009,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1026,16 +1022,14 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
|
||||
validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("revert", c.req.valid("json"))
|
||||
const body = c.req.valid("json") as Omit<SessionRevert.RevertInput, "sessionID">
|
||||
log.info("revert", body)
|
||||
return jsonRequest("SessionRoutes.revert", c, function* () {
|
||||
const svc = yield* SessionRevert.Service
|
||||
return yield* svc.revert({
|
||||
sessionID,
|
||||
...c.req.valid("json"),
|
||||
})
|
||||
return yield* svc.revert({ sessionID, ...body })
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1050,7 +1044,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Updated session",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.Info),
|
||||
schema: resolver(Session.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,191 +1,192 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { SessionID } from "./schema"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
export const AuthError = NamedError.create(
|
||||
"ProviderAuthError",
|
||||
z.object({
|
||||
providerID: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
state: z.literal("call"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ToolCall",
|
||||
})
|
||||
export type ToolCall = z.infer<typeof ToolCall>
|
||||
|
||||
export const ToolPartialCall = z
|
||||
.object({
|
||||
state: z.literal("partial-call"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ToolPartialCall",
|
||||
})
|
||||
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
|
||||
|
||||
export const ToolResult = z
|
||||
.object({
|
||||
state: z.literal("result"),
|
||||
step: z.number().optional(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.custom<Required<unknown>>(),
|
||||
result: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ToolResult",
|
||||
})
|
||||
export type ToolResult = z.infer<typeof ToolResult>
|
||||
|
||||
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
|
||||
ref: "ToolInvocation",
|
||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||
export const AuthError = namedSchemaError("ProviderAuthError", {
|
||||
providerID: Schema.String,
|
||||
message: Schema.String,
|
||||
})
|
||||
export type ToolInvocation = z.infer<typeof ToolInvocation>
|
||||
|
||||
export const TextPart = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "TextPart",
|
||||
})
|
||||
export type TextPart = z.infer<typeof TextPart>
|
||||
const AuthErrorEffect = Schema.Struct({
|
||||
name: Schema.Literal("ProviderAuthError"),
|
||||
data: Schema.Struct({
|
||||
providerID: Schema.String,
|
||||
message: Schema.String,
|
||||
}),
|
||||
})
|
||||
|
||||
export const ReasoningPart = z
|
||||
.object({
|
||||
type: z.literal("reasoning"),
|
||||
text: z.string(),
|
||||
providerMetadata: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ReasoningPart",
|
||||
})
|
||||
export type ReasoningPart = z.infer<typeof ReasoningPart>
|
||||
const OutputLengthErrorEffect = Schema.Struct({
|
||||
name: Schema.Literal("MessageOutputLengthError"),
|
||||
data: Schema.Struct({}),
|
||||
})
|
||||
|
||||
export const ToolInvocationPart = z
|
||||
.object({
|
||||
type: z.literal("tool-invocation"),
|
||||
toolInvocation: ToolInvocation,
|
||||
})
|
||||
.meta({
|
||||
ref: "ToolInvocationPart",
|
||||
})
|
||||
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
|
||||
const UnknownErrorEffect = Schema.Struct({
|
||||
name: Schema.Literal("UnknownError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
})
|
||||
|
||||
export const SourceUrlPart = z
|
||||
.object({
|
||||
type: z.literal("source-url"),
|
||||
sourceId: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string().optional(),
|
||||
providerMetadata: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "SourceUrlPart",
|
||||
})
|
||||
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
|
||||
export const ToolCall = Schema.Struct({
|
||||
state: Schema.Literal("call"),
|
||||
step: Schema.optional(Schema.Number),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
})
|
||||
.annotate({ identifier: "ToolCall" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
|
||||
export const FilePart = z
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
mediaType: z.string(),
|
||||
filename: z.string().optional(),
|
||||
url: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FilePart",
|
||||
})
|
||||
export type FilePart = z.infer<typeof FilePart>
|
||||
export const ToolPartialCall = Schema.Struct({
|
||||
state: Schema.Literal("partial-call"),
|
||||
step: Schema.optional(Schema.Number),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
})
|
||||
.annotate({ identifier: "ToolPartialCall" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
|
||||
|
||||
export const StepStartPart = z
|
||||
.object({
|
||||
type: z.literal("step-start"),
|
||||
})
|
||||
.meta({
|
||||
ref: "StepStartPart",
|
||||
})
|
||||
export type StepStartPart = z.infer<typeof StepStartPart>
|
||||
export const ToolResult = Schema.Struct({
|
||||
state: Schema.Literal("result"),
|
||||
step: Schema.optional(Schema.Number),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
result: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "ToolResult" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
|
||||
export const MessagePart = z
|
||||
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
|
||||
.meta({
|
||||
ref: "MessagePart",
|
||||
})
|
||||
export type MessagePart = z.infer<typeof MessagePart>
|
||||
export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult])
|
||||
.annotate({ identifier: "ToolInvocation", discriminator: "state" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ToolInvocation = Schema.Schema.Type<typeof ToolInvocation>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
parts: z.array(MessagePart),
|
||||
metadata: z
|
||||
.object({
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
completed: z.number().optional(),
|
||||
export const TextPart = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "TextPart" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type TextPart = Schema.Schema.Type<typeof TextPart>
|
||||
|
||||
export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
})
|
||||
.annotate({ identifier: "ReasoningPart" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
|
||||
|
||||
export const ToolInvocationPart = Schema.Struct({
|
||||
type: Schema.Literal("tool-invocation"),
|
||||
toolInvocation: ToolInvocation,
|
||||
})
|
||||
.annotate({ identifier: "ToolInvocationPart" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ToolInvocationPart = Schema.Schema.Type<typeof ToolInvocationPart>
|
||||
|
||||
export const SourceUrlPart = Schema.Struct({
|
||||
type: Schema.Literal("source-url"),
|
||||
sourceId: Schema.String,
|
||||
url: Schema.String,
|
||||
title: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
})
|
||||
.annotate({ identifier: "SourceUrlPart" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type SourceUrlPart = Schema.Schema.Type<typeof SourceUrlPart>
|
||||
|
||||
export const FilePart = Schema.Struct({
|
||||
type: Schema.Literal("file"),
|
||||
mediaType: Schema.String,
|
||||
filename: Schema.optional(Schema.String),
|
||||
url: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "FilePart" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type FilePart = Schema.Schema.Type<typeof FilePart>
|
||||
|
||||
export const StepStartPart = Schema.Struct({
|
||||
type: Schema.Literal("step-start"),
|
||||
})
|
||||
.annotate({ identifier: "StepStartPart" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type StepStartPart = Schema.Schema.Type<typeof StepStartPart>
|
||||
|
||||
export const MessagePart = Schema.Union([
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolInvocationPart,
|
||||
SourceUrlPart,
|
||||
FilePart,
|
||||
StepStartPart,
|
||||
])
|
||||
.annotate({ identifier: "MessagePart", discriminator: "type" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type MessagePart = Schema.Schema.Type<typeof MessagePart>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.String,
|
||||
role: Schema.Literals(["user", "assistant"]),
|
||||
parts: Schema.Array(MessagePart),
|
||||
metadata: Schema.Struct({
|
||||
time: Schema.Struct({
|
||||
created: Schema.Number,
|
||||
completed: Schema.optional(Schema.Number),
|
||||
}),
|
||||
error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
|
||||
sessionID: SessionID,
|
||||
tool: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
title: Schema.String,
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
error: z
|
||||
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
|
||||
.optional(),
|
||||
sessionID: SessionID.zod,
|
||||
tool: z.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
title: z.string(),
|
||||
snapshot: z.string().optional(),
|
||||
time: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
})
|
||||
.catchall(z.any()),
|
||||
),
|
||||
assistant: z
|
||||
.object({
|
||||
system: z.string().array(),
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
path: z.object({
|
||||
cwd: z.string(),
|
||||
root: z.string(),
|
||||
}),
|
||||
cost: z.number(),
|
||||
summary: z.boolean().optional(),
|
||||
tokens: z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
reasoning: z.number(),
|
||||
cache: z.object({
|
||||
read: z.number(),
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
snapshot: z.string().optional(),
|
||||
})
|
||||
.meta({ ref: "MessageMetadata" }),
|
||||
})
|
||||
.meta({
|
||||
ref: "Message",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
assistant: Schema.optional(
|
||||
Schema.Struct({
|
||||
system: Schema.Array(Schema.String),
|
||||
modelID: ModelID,
|
||||
providerID: ProviderID,
|
||||
path: Schema.Struct({
|
||||
cwd: Schema.String,
|
||||
root: Schema.String,
|
||||
}),
|
||||
cost: Schema.Number,
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "MessageMetadata" }),
|
||||
})
|
||||
.annotate({ identifier: "Message" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export * as Message from "./message"
|
||||
|
||||
@@ -62,7 +62,9 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
|
||||
|
||||
export default [
|
||||
SyncEvent.project(Session.Event.Created, (db, data) => {
|
||||
db.insert(SessionTable).values(Session.toRow(data.info)).run()
|
||||
db.insert(SessionTable)
|
||||
.values(Session.toRow(data.info as Session.Info))
|
||||
.run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(Session.Event.Updated, (db, data) => {
|
||||
|
||||
@@ -43,7 +43,9 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Truncate } from "@/tool"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, Context, Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { EffectLogger } from "@/effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
@@ -69,7 +71,7 @@ const elog = EffectLogger.create({ service: "session.prompt" })
|
||||
export interface Interface {
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
||||
@@ -1532,9 +1534,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
},
|
||||
)
|
||||
|
||||
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
||||
"SessionPrompt.loop",
|
||||
)(function* (input: z.infer<typeof LoopInput>) {
|
||||
const loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.loop")(function* (
|
||||
input: LoopInput,
|
||||
) {
|
||||
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
|
||||
})
|
||||
|
||||
@@ -1701,91 +1703,88 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
),
|
||||
)
|
||||
export const PromptInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
model: z
|
||||
.object({
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
})
|
||||
.optional(),
|
||||
agent: z.string().optional(),
|
||||
noReply: z.boolean().optional(),
|
||||
tools: z
|
||||
.record(z.string(), z.boolean())
|
||||
.optional()
|
||||
.describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"),
|
||||
format: MessageV2.Format.zod.optional(),
|
||||
system: z.string().optional(),
|
||||
variant: z.string().optional(),
|
||||
parts: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
MessageV2.TextPartInput.zod as unknown as z.ZodObject<any>,
|
||||
MessageV2.FilePartInput.zod as unknown as z.ZodObject<any>,
|
||||
MessageV2.AgentPartInput.zod as unknown as z.ZodObject<any>,
|
||||
MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject<any>,
|
||||
]),
|
||||
),
|
||||
const ModelRef = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
})
|
||||
|
||||
export const PromptInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: Schema.optional(MessageID),
|
||||
model: Schema.optional(ModelRef),
|
||||
agent: Schema.optional(Schema.String),
|
||||
noReply: Schema.optional(Schema.Boolean),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description:
|
||||
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
|
||||
}),
|
||||
format: Schema.optional(MessageV2.Format),
|
||||
system: Schema.optional(Schema.String),
|
||||
variant: Schema.optional(Schema.String),
|
||||
parts: Schema.Array(
|
||||
Schema.Union([
|
||||
MessageV2.TextPartInput,
|
||||
MessageV2.FilePartInput,
|
||||
MessageV2.AgentPartInput,
|
||||
MessageV2.SubtaskPartInput,
|
||||
]).annotate({ discriminator: "type" }),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
// `z.discriminatedUnion` erases the discriminated members' shapes back to
|
||||
// `{}` because the derived `.zod` on each input is typed as an opaque
|
||||
// `z.ZodType`. Restore the precise `parts` type from the exported Schema
|
||||
// input types so callers see a proper tagged union.
|
||||
// `{}` when walked from the generic `z.ZodType` input. Restore the precise
|
||||
// `parts` type from the exported Schema input types so callers see a proper
|
||||
// tagged union.
|
||||
type PartInputUnion =
|
||||
| MessageV2.TextPartInput
|
||||
| MessageV2.FilePartInput
|
||||
| MessageV2.AgentPartInput
|
||||
| MessageV2.SubtaskPartInput
|
||||
export type PromptInput = Omit<z.infer<typeof PromptInput>, "parts"> & {
|
||||
export type PromptInput = Omit<Schema.Schema.Type<typeof PromptInput>, "parts"> & {
|
||||
parts: PartInputUnion[]
|
||||
}
|
||||
|
||||
export const LoopInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
export class LoopInput extends Schema.Class<LoopInput>("SessionPrompt.LoopInput")({
|
||||
sessionID: SessionID,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const ShellInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
agent: z.string(),
|
||||
model: z
|
||||
.object({
|
||||
providerID: ProviderID.zod,
|
||||
modelID: ModelID.zod,
|
||||
})
|
||||
.optional(),
|
||||
command: z.string(),
|
||||
})
|
||||
export type ShellInput = z.infer<typeof ShellInput>
|
||||
export const ShellInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: Schema.optional(MessageID),
|
||||
agent: Schema.String,
|
||||
model: Schema.optional(ModelRef),
|
||||
command: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ShellInput = Schema.Schema.Type<typeof ShellInput>
|
||||
|
||||
export const CommandInput = z.object({
|
||||
messageID: MessageID.zod.optional(),
|
||||
sessionID: SessionID.zod,
|
||||
agent: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
arguments: z.string(),
|
||||
command: z.string(),
|
||||
variant: z.string().optional(),
|
||||
// Inlined (no `.meta({ ref })`) to keep the original SDK output — the
|
||||
export const CommandInput = Schema.Struct({
|
||||
messageID: Schema.optional(MessageID),
|
||||
sessionID: SessionID,
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
arguments: Schema.String,
|
||||
command: Schema.String,
|
||||
variant: Schema.optional(Schema.String),
|
||||
// Inlined (no identifier annotation) to keep the original SDK output — the
|
||||
// PromptInput call site below references FilePartInput by ref via the
|
||||
// Schema export in message-v2.ts.
|
||||
parts: z
|
||||
.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
id: PartID.zod.optional(),
|
||||
type: z.literal("file"),
|
||||
mime: z.string(),
|
||||
filename: z.string().optional(),
|
||||
url: z.string(),
|
||||
source: MessageV2.FilePartSource.zod.optional(),
|
||||
parts: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.optional(PartID),
|
||||
type: Schema.Literal("file"),
|
||||
mime: Schema.String,
|
||||
filename: Schema.optional(Schema.String),
|
||||
url: Schema.String,
|
||||
source: Schema.optional(MessageV2.FilePartSource),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
export type CommandInput = z.infer<typeof CommandInput>
|
||||
]).annotate({ discriminator: "type" }),
|
||||
),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type CommandInput = Schema.Schema.Type<typeof CommandInput>
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function createStructuredOutputTool(input: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export type Err = ReturnType<NamedError["toObject"]>
|
||||
|
||||
@@ -63,43 +62,19 @@ export function retryable(error: Err) {
|
||||
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
|
||||
}
|
||||
|
||||
// Check for rate limit patterns in plain text error messages
|
||||
const msg = error.data?.message
|
||||
if (typeof msg === "string") {
|
||||
const lower = msg.toLowerCase()
|
||||
if (
|
||||
lower.includes("rate increased too quickly") ||
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests")
|
||||
/rate.?limit|too.?many.?requests|overloaded|exhausted|unavailable|service.?unavailable|429|5\d\d|internal.?error|network.?error|connection.?(?:error|refused|lost|reset)|fetch.?failed|upstream|upstream.?connect|reset before headers|socket.?hang.?up|other.?side.?closed|ended without|timed? out|timeout|terminated|retry delay|provider.?returned.?error|server.?error/i.test(
|
||||
lower,
|
||||
)
|
||||
) {
|
||||
if (lower.includes("overloaded") || lower.includes("exhausted") || lower.includes("unavailable")) return "Provider is overloaded"
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
const json = iife(() => {
|
||||
try {
|
||||
if (typeof error.data?.message === "string") {
|
||||
const parsed = JSON.parse(error.data.message)
|
||||
return parsed
|
||||
}
|
||||
|
||||
return JSON.parse(error.data.message)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
if (!json || typeof json !== "object") return undefined
|
||||
const code = typeof json.code === "string" ? json.code : ""
|
||||
|
||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
||||
return "Too Many Requests"
|
||||
}
|
||||
if (code.includes("exhausted") || code.includes("unavailable")) {
|
||||
return "Provider is overloaded"
|
||||
}
|
||||
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
|
||||
return "Rate Limited"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Bus } from "../bus"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "@/storage"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Log } from "../util"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
@@ -13,12 +14,12 @@ import { SessionSummary } from "./summary"
|
||||
|
||||
const log = Log.create({ service: "session.revert" })
|
||||
|
||||
export const RevertInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
partID: PartID.zod.optional(),
|
||||
})
|
||||
export type RevertInput = z.infer<typeof RevertInput>
|
||||
export const RevertInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
partID: Schema.optional(PartID),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type RevertInput = Schema.Schema.Type<typeof RevertInput>
|
||||
|
||||
export interface Interface {
|
||||
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
|
||||
|
||||
@@ -27,7 +27,9 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
import type { Provider } from "@/provider"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import { Effect, Layer, Option, Context } from "effect"
|
||||
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
||||
import { zod, zodObject } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
@@ -114,91 +116,104 @@ function getForkedTitle(title: string): string {
|
||||
return `${title} (fork #1)`
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: SessionID.zod,
|
||||
slug: z.string(),
|
||||
projectID: ProjectID.zod,
|
||||
workspaceID: WorkspaceID.zod.optional(),
|
||||
directory: z.string(),
|
||||
parentID: SessionID.zod.optional(),
|
||||
summary: z
|
||||
.object({
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
files: z.number(),
|
||||
diffs: Snapshot.FileDiff.zod.array().optional(),
|
||||
})
|
||||
.optional(),
|
||||
share: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
title: z.string(),
|
||||
version: z.string(),
|
||||
time: z.object({
|
||||
created: z.number(),
|
||||
updated: z.number(),
|
||||
compacting: z.number().optional(),
|
||||
archived: z.number().optional(),
|
||||
}),
|
||||
permission: Permission.Ruleset.zod.optional(),
|
||||
revert: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
partID: PartID.zod.optional(),
|
||||
snapshot: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Session",
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const ProjectInfo = z
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
name: z.string().optional(),
|
||||
worktree: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProjectSummary",
|
||||
})
|
||||
export type ProjectInfo = z.output<typeof ProjectInfo>
|
||||
|
||||
export const GlobalInfo = Info.extend({
|
||||
project: ProjectInfo.nullable(),
|
||||
}).meta({
|
||||
ref: "GlobalSession",
|
||||
const Summary = Schema.Struct({
|
||||
additions: Schema.Number,
|
||||
deletions: Schema.Number,
|
||||
files: Schema.Number,
|
||||
diffs: Schema.optional(Schema.Array(Snapshot.FileDiff)),
|
||||
})
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const CreateInput = z
|
||||
.object({
|
||||
parentID: SessionID.zod.optional(),
|
||||
title: z.string().optional(),
|
||||
permission: Info.shape.permission,
|
||||
workspaceID: WorkspaceID.zod.optional(),
|
||||
})
|
||||
.optional()
|
||||
export type CreateInput = z.output<typeof CreateInput>
|
||||
|
||||
export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
|
||||
export const GetInput = SessionID.zod
|
||||
export const ChildrenInput = SessionID.zod
|
||||
export const RemoveInput = SessionID.zod
|
||||
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
|
||||
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
|
||||
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod })
|
||||
export const SetRevertInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
revert: Info.shape.revert,
|
||||
summary: Info.shape.summary,
|
||||
const Share = Schema.Struct({
|
||||
url: Schema.String,
|
||||
})
|
||||
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
|
||||
|
||||
const Time = Schema.Struct({
|
||||
created: Schema.Number,
|
||||
updated: Schema.Number,
|
||||
compacting: Schema.optional(Schema.Number),
|
||||
archived: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const Revert = Schema.Struct({
|
||||
messageID: MessageID,
|
||||
partID: Schema.optional(PartID),
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
diff: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: SessionID,
|
||||
slug: Schema.String,
|
||||
projectID: ProjectID,
|
||||
workspaceID: Schema.optional(WorkspaceID),
|
||||
directory: Schema.String,
|
||||
parentID: Schema.optional(SessionID),
|
||||
summary: Schema.optional(Summary),
|
||||
share: Schema.optional(Share),
|
||||
title: Schema.String,
|
||||
version: Schema.String,
|
||||
time: Time,
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
revert: Schema.optional(Revert),
|
||||
})
|
||||
.annotate({ identifier: "Session" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const ProjectInfo = Schema.Struct({
|
||||
id: ProjectID,
|
||||
name: Schema.optional(Schema.String),
|
||||
worktree: Schema.String,
|
||||
})
|
||||
.annotate({ identifier: "ProjectSummary" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ProjectInfo = Types.DeepMutable<Schema.Schema.Type<typeof ProjectInfo>>
|
||||
|
||||
export const GlobalInfo = Schema.Struct({
|
||||
...Info.fields,
|
||||
project: Schema.NullOr(ProjectInfo),
|
||||
})
|
||||
.annotate({ identifier: "GlobalSession" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>>
|
||||
|
||||
export const CreateInput = Schema.optional(
|
||||
Schema.Struct({
|
||||
parentID: Schema.optional(SessionID),
|
||||
title: Schema.optional(Schema.String),
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
workspaceID: Schema.optional(WorkspaceID),
|
||||
}),
|
||||
).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
|
||||
|
||||
export const ForkInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: Schema.optional(MessageID),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const GetInput = SessionID
|
||||
export const ChildrenInput = SessionID
|
||||
export const RemoveInput = SessionID
|
||||
export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export const SetArchivedInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
time: Schema.optional(Schema.Number),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const SetPermissionInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
permission: Permission.Ruleset,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const SetRevertInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
revert: Schema.optional(Revert),
|
||||
summary: Schema.optional(Summary),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const MessagesInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
limit: Schema.optional(Schema.Number),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Event = {
|
||||
Created: SyncEvent.define({
|
||||
@@ -207,7 +222,7 @@ export const Event = {
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
info: Info.zod,
|
||||
}),
|
||||
}),
|
||||
Updated: SyncEvent.define({
|
||||
@@ -216,14 +231,14 @@ export const Event = {
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: updateSchema(Info).extend({
|
||||
share: updateSchema(Info.shape.share.unwrap()).optional(),
|
||||
time: updateSchema(Info.shape.time).optional(),
|
||||
info: updateSchema(zodObject(Info)).extend({
|
||||
share: updateSchema(zodObject(Share)).optional(),
|
||||
time: updateSchema(zodObject(Time)).optional(),
|
||||
}),
|
||||
}),
|
||||
busSchema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
info: Info.zod,
|
||||
}),
|
||||
}),
|
||||
Deleted: SyncEvent.define({
|
||||
@@ -232,7 +247,7 @@ export const Event = {
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
info: Info.zod,
|
||||
}),
|
||||
}),
|
||||
Diff: BusEvent.define(
|
||||
|
||||
@@ -2,35 +2,35 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export const Info = z
|
||||
.union([
|
||||
z.object({
|
||||
type: z.literal("idle"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("retry"),
|
||||
attempt: z.number(),
|
||||
message: z.string(),
|
||||
next: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("busy"),
|
||||
}),
|
||||
])
|
||||
.meta({
|
||||
ref: "SessionStatus",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("idle"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("retry"),
|
||||
attempt: Schema.Number,
|
||||
message: Schema.String,
|
||||
next: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("busy"),
|
||||
}),
|
||||
])
|
||||
.annotate({ identifier: "SessionStatus" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Status: BusEvent.define(
|
||||
"session.status",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
status: Info,
|
||||
status: Info.zod,
|
||||
}),
|
||||
),
|
||||
// deprecated
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Storage } from "@/storage"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID } from "./schema"
|
||||
@@ -155,9 +156,10 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
export const DiffInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
})
|
||||
export const DiffInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
messageID: Schema.optional(MessageID),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type DiffInput = Schema.Schema.Type<typeof DiffInput>
|
||||
|
||||
export * as SessionSummary from "./summary"
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Database, eq, asc } from "../storage"
|
||||
import { TodoTable } from "./session.sql"
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
content: z.string().describe("Brief description of the task"),
|
||||
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
|
||||
priority: z.string().describe("Priority level of the task: high, medium, low"),
|
||||
})
|
||||
.meta({ ref: "Todo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "Brief description of the task" }),
|
||||
status: Schema.String.annotate({
|
||||
description: "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
}),
|
||||
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
|
||||
})
|
||||
.annotate({ identifier: "Todo" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"todo.updated",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
todos: z.array(Info),
|
||||
todos: z.array(Info.zod),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -4,8 +4,21 @@ import * as Tool from "./tool"
|
||||
import DESCRIPTION_WRITE from "./todowrite.txt"
|
||||
import { Todo } from "../session/todo"
|
||||
|
||||
// Parameters are kept inline rather than derived from Todo.Info because
|
||||
// Tool.define requires z.ZodObject-typed parameters for execute() inference,
|
||||
// and zodObject(Todo.Info) returns ZodObject<any> — reaching into .shape would
|
||||
// erase field types. Tool schemas migrate to Effect Schema as a separate slice
|
||||
// per specs/effect/schema.md.
|
||||
const parameters = z.object({
|
||||
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
|
||||
todos: z
|
||||
.array(
|
||||
z.object({
|
||||
content: z.string().describe("Brief description of the task"),
|
||||
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
|
||||
priority: z.string().describe("Priority level of the task: high, medium, low"),
|
||||
}),
|
||||
)
|
||||
.describe("The updated todo list"),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
|
||||
@@ -22,6 +22,33 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a Zod value from an Effect Schema (or a Schema-backed export with a
|
||||
* `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
|
||||
* `.omit`, `.extend`, and friends are accessible.
|
||||
*
|
||||
* The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
|
||||
* to an object; this helper keeps the "I started from a `Schema.Struct`" cast
|
||||
* in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
|
||||
* call sites.
|
||||
*
|
||||
* The return is intentionally loose — carrying Schema field types through the
|
||||
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
|
||||
* explosions for branded primitives (`string & Brand<"SessionID">` extends
|
||||
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
|
||||
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
|
||||
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
|
||||
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
|
||||
*/
|
||||
export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
|
||||
const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
|
||||
return derived as unknown as z.ZodObject<any>
|
||||
}
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodTypeAny {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const cached = walkCache.get(ast)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { InstallationChannel } from "../../src/installation/version"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
@@ -68,11 +69,15 @@ describe("installation", () => {
|
||||
expect(result).toBe("4.0.0-beta.1")
|
||||
})
|
||||
|
||||
test("reads npm registry versions", async () => {
|
||||
test("reads npm versions via npm view", async () => {
|
||||
const calls: string[][] = []
|
||||
const layer = testLayer(
|
||||
() => jsonResponse({ version: "1.5.0" }),
|
||||
() => {
|
||||
throw new Error("unexpected http request")
|
||||
},
|
||||
(cmd, args) => {
|
||||
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
|
||||
calls.push([cmd, ...args])
|
||||
if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
|
||||
return ""
|
||||
},
|
||||
)
|
||||
@@ -81,18 +86,47 @@ describe("installation", () => {
|
||||
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("1.5.0")
|
||||
expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
|
||||
})
|
||||
|
||||
test("reads npm registry versions for bun method", async () => {
|
||||
test("reads npm versions via bun pm view", async () => {
|
||||
const calls: string[][] = []
|
||||
const layer = testLayer(
|
||||
() => jsonResponse({ version: "1.6.0" }),
|
||||
() => "",
|
||||
() => {
|
||||
throw new Error("unexpected http request")
|
||||
},
|
||||
(cmd, args) => {
|
||||
calls.push([cmd, ...args])
|
||||
if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("1.6.0")
|
||||
expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
|
||||
})
|
||||
|
||||
test("reads npm versions via pnpm view", async () => {
|
||||
const calls: string[][] = []
|
||||
const layer = testLayer(
|
||||
() => {
|
||||
throw new Error("unexpected http request")
|
||||
},
|
||||
(cmd, args) => {
|
||||
calls.push([cmd, ...args])
|
||||
if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n'
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(result).toBe("1.7.0")
|
||||
expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
|
||||
})
|
||||
|
||||
test("reads scoop manifest versions", async () => {
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Global } from "@opencode-ai/shared/global"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Npm } from "../src/npm"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
const encoder = new TextEncoder()
|
||||
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
|
||||
const spawner = ChildProcessSpawner.make((command) => {
|
||||
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
||||
const output = handler(std?.command ?? "", std?.args ?? [])
|
||||
return Effect.succeed(
|
||||
ChildProcessSpawner.makeHandle({
|
||||
pid: ChildProcessSpawner.ProcessId(0),
|
||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
||||
isRunning: Effect.succeed(false),
|
||||
kill: () => Effect.void,
|
||||
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
|
||||
stderr: Stream.empty,
|
||||
all: Stream.empty,
|
||||
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||
getOutputFd: () => Stream.empty,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
}),
|
||||
)
|
||||
})
|
||||
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
|
||||
}
|
||||
|
||||
function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) {
|
||||
return Npm.layer.pipe(
|
||||
Layer.provide(mockSpawner(spawnHandler)),
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
}
|
||||
|
||||
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
Bun.write(
|
||||
path.join(dir, "package.json"),
|
||||
@@ -53,3 +93,51 @@ describe("Npm.install", () => {
|
||||
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.outdated", () => {
|
||||
test("checks latest via npm view", async () => {
|
||||
const calls: string[][] = []
|
||||
const layer = testLayer((cmd, args) => {
|
||||
calls.push([cmd, ...args])
|
||||
if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n'
|
||||
return ""
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
|
||||
})
|
||||
|
||||
test("keeps range comparison behavior", async () => {
|
||||
const layer = testLayer((cmd, args) => {
|
||||
if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n'
|
||||
return ""
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("falls back when npm view is unavailable", async () => {
|
||||
const calls: string[][] = []
|
||||
const layer = testLayer((cmd, args) => {
|
||||
calls.push([cmd, ...args])
|
||||
if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n'
|
||||
return ""
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
|
||||
expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ const svc = {
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
setArchived(input: z.output<typeof SessionNs.SetArchivedInput>) {
|
||||
setArchived(input: z.output<typeof SessionNs.SetArchivedInput.zod>) {
|
||||
return run(SessionNs.Service.use((svc) => svc.setArchived(input)))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const svc = {
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
messages(input: z.output<typeof SessionNs.MessagesInput>) {
|
||||
messages(input: z.output<typeof SessionNs.MessagesInput.zod>) {
|
||||
return run(SessionNs.Service.use((svc) => svc.messages(input)))
|
||||
},
|
||||
updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
|
||||
@@ -124,22 +124,22 @@ describe("session.retry.delay", () => {
|
||||
})
|
||||
|
||||
describe("session.retry.retryable", () => {
|
||||
test("maps too_many_requests json messages", () => {
|
||||
test("retries too_many_requests in raw json strings", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
||||
expect(SessionRetry.retryable(error)).toBe("Too Many Requests")
|
||||
expect(SessionRetry.retryable(error)).toBe(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
||||
})
|
||||
|
||||
test("maps overloaded provider codes", () => {
|
||||
test("retries exhausted codes in raw json strings", () => {
|
||||
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
||||
expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
|
||||
})
|
||||
|
||||
test("does not retry unknown json messages", () => {
|
||||
test("does not retry unknown raw json strings", () => {
|
||||
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not throw on numeric error codes", () => {
|
||||
test("does not throw on numeric codes in raw json strings", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
|
||||
const result = SessionRetry.retryable(error)
|
||||
expect(result).toBeUndefined()
|
||||
@@ -169,6 +169,66 @@ describe("session.retry.retryable", () => {
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries connection errors in plain text", () => {
|
||||
const msg = "Connection refused"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries timeout errors in plain text", () => {
|
||||
const msg = "Request timed out"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries 500 errors in plain text", () => {
|
||||
const msg = "HTTP 500 Internal Server Error"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries overloaded errors in plain text", () => {
|
||||
const msg = "Provider is overloaded"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
|
||||
})
|
||||
|
||||
test("retries 429 errors in plain text", () => {
|
||||
const msg = "HTTP 429 Too Many Requests"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries provider returned errors", () => {
|
||||
const msg = "Provider returned error: something went wrong"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries other side closed errors", () => {
|
||||
const msg = "Other side closed connection"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries reset before headers errors", () => {
|
||||
const msg = "Connection reset before headers"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries ended without errors", () => {
|
||||
const msg = "Request ended without sending chunks"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("retries retry delay exceeded errors", () => {
|
||||
const msg = "Retry delay exceeded"
|
||||
const error = wrap(msg)
|
||||
expect(SessionRetry.retryable(error)).toBe(msg)
|
||||
})
|
||||
|
||||
test("does not retry context overflow errors", () => {
|
||||
const error = new MessageV2.ContextOverflowError({
|
||||
message: "Input exceeds context window of this model",
|
||||
@@ -230,6 +290,35 @@ describe("session.retry.retryable", () => {
|
||||
expect(retryable).toBeDefined()
|
||||
expect(retryable).toBe("Response decompression failed")
|
||||
})
|
||||
|
||||
test("returns upsell message for FreeUsageLimitError in response body", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Usage limit exceeded",
|
||||
isRetryable: true,
|
||||
responseBody: '{"error":{"type":"FreeUsageLimitError"}}',
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe(SessionRetry.GO_UPSELL_MESSAGE)
|
||||
})
|
||||
|
||||
test("returns overloaded message when APIError message includes Overloaded", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Server is Overloaded, try again later",
|
||||
isRetryable: true,
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Provider is overloaded")
|
||||
})
|
||||
|
||||
test("retries rate_limit codes in raw json strings", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } }))
|
||||
expect(SessionRetry.retryable(error)).toBe(JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } }))
|
||||
})
|
||||
|
||||
test("retries server_error in raw json strings", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "server_error", code: "server_error" } }))
|
||||
expect(SessionRetry.retryable(error)).toBe(JSON.stringify({ type: "error", error: { type: "server_error", code: "server_error" } }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.message-v2.fromError", () => {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionRevert } from "../../src/session/revert"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
|
||||
// Covers the session-domain Effect Schema migration. For each migrated
|
||||
// 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.
|
||||
// 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 = 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 = WorkspaceID.zod.parse("wrk-primary")
|
||||
|
||||
function decodeUnknown<S extends Schema.Top>(schema: S) {
|
||||
const decode = Schema.decodeUnknownSync(schema as any)
|
||||
return (input: unknown): Schema.Schema.Type<S> => decode(input) as Schema.Schema.Type<S>
|
||||
}
|
||||
|
||||
describe("Session.Info", () => {
|
||||
const decode = decodeUnknown(Session.Info)
|
||||
|
||||
test("accepts minimal session", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "hello",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "First session",
|
||||
version: "0.1.0",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("round-trips every optional field", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "fullshape",
|
||||
projectID,
|
||||
workspaceID,
|
||||
directory: "/tmp/proj",
|
||||
parentID: sessionIDChild,
|
||||
summary: {
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
files: 2,
|
||||
diffs: [{ additions: 1, deletions: 0, file: "a.ts", patch: "--- a/a.ts" }],
|
||||
},
|
||||
share: { url: "https://share.example.com/s/1" },
|
||||
title: "Full session",
|
||||
version: "1.0.0",
|
||||
time: { created: 100, updated: 200, compacting: 150, archived: 300 },
|
||||
permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
|
||||
revert: {
|
||||
messageID,
|
||||
partID,
|
||||
snapshot: "snap-1",
|
||||
diff: "diff-1",
|
||||
},
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("rejects unbranded session id", () => {
|
||||
const bad = { id: "not-a-session-id" } as unknown
|
||||
expect(() => decode(bad)).toThrow()
|
||||
expect(() => Session.Info.zod.parse(bad)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects missing required fields", () => {
|
||||
const bad = { id: sessionID } as unknown
|
||||
expect(() => decode(bad)).toThrow()
|
||||
expect(() => Session.Info.zod.parse(bad)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session.ProjectInfo", () => {
|
||||
const decode = decodeUnknown(Session.ProjectInfo)
|
||||
|
||||
test("accepts with and without optional name", () => {
|
||||
const noName = { id: projectID, worktree: "/tmp/wt" }
|
||||
const withName = { ...noName, name: "alpha" }
|
||||
expect(decode(noName)).toEqual(noName)
|
||||
expect(decode(withName)).toEqual(withName)
|
||||
expect(Session.ProjectInfo.zod.parse(noName)).toEqual(noName)
|
||||
expect(Session.ProjectInfo.zod.parse(withName)).toEqual(withName)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session.GlobalInfo", () => {
|
||||
const decode = decodeUnknown(Session.GlobalInfo)
|
||||
|
||||
test("accepts null project", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "global",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "global",
|
||||
version: "0",
|
||||
time: { created: 0, updated: 0 },
|
||||
project: null,
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.GlobalInfo.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("accepts populated project", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "global",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "global",
|
||||
version: "0",
|
||||
time: { created: 0, updated: 0 },
|
||||
project: { id: projectID, worktree: "/tmp/wt", name: "alpha" },
|
||||
}
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.GlobalInfo.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Session input schemas", () => {
|
||||
test("CreateInput accepts undefined and populated forms", () => {
|
||||
const decode = decodeUnknown(Session.CreateInput)
|
||||
expect(decode(undefined)).toBeUndefined()
|
||||
expect(Session.CreateInput.zod.parse(undefined)).toBeUndefined()
|
||||
|
||||
const populated = {
|
||||
parentID: sessionID,
|
||||
title: "child",
|
||||
permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }],
|
||||
workspaceID,
|
||||
}
|
||||
expect(decode(populated)).toEqual(populated)
|
||||
expect(Session.CreateInput.zod.parse(populated)).toEqual(populated)
|
||||
})
|
||||
|
||||
test("ForkInput round-trips", () => {
|
||||
const decode = decodeUnknown(Session.ForkInput)
|
||||
const input = { sessionID, messageID }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Session.ForkInput.zod.parse(input)).toEqual(input)
|
||||
// messageID is optional
|
||||
const bare = { sessionID }
|
||||
expect(decode(bare)).toEqual(bare)
|
||||
expect(Session.ForkInput.zod.parse(bare)).toEqual(bare)
|
||||
})
|
||||
|
||||
test("SetTitleInput rejects missing title", () => {
|
||||
expect(() => decodeUnknown(Session.SetTitleInput)({ sessionID })).toThrow()
|
||||
expect(() => Session.SetTitleInput.zod.parse({ sessionID })).toThrow()
|
||||
})
|
||||
|
||||
test("SetArchivedInput accepts both with and without time", () => {
|
||||
const decode = decodeUnknown(Session.SetArchivedInput)
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(decode({ sessionID, time: 123 })).toEqual({ sessionID, time: 123 })
|
||||
})
|
||||
|
||||
test("SetPermissionInput requires a ruleset", () => {
|
||||
const decode = decodeUnknown(Session.SetPermissionInput)
|
||||
const input = { sessionID, permission: [{ action: "deny" as const, pattern: "*", permission: "write" }] }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(() => decode({ sessionID })).toThrow()
|
||||
})
|
||||
|
||||
test("MessagesInput accepts optional limit", () => {
|
||||
const decode = decodeUnknown(Session.MessagesInput)
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(decode({ sessionID, limit: 50 })).toEqual({ sessionID, limit: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionRevert.RevertInput", () => {
|
||||
const decode = decodeUnknown(SessionRevert.RevertInput)
|
||||
|
||||
test("messageID is required, partID is optional", () => {
|
||||
const withPart = { sessionID, messageID, partID }
|
||||
expect(decode(withPart)).toEqual(withPart)
|
||||
expect(SessionRevert.RevertInput.zod.parse(withPart)).toEqual(withPart)
|
||||
|
||||
const noPart = { sessionID, messageID }
|
||||
expect(decode(noPart)).toEqual(noPart)
|
||||
expect(SessionRevert.RevertInput.zod.parse(noPart)).toEqual(noPart)
|
||||
|
||||
expect(() => decode({ sessionID })).toThrow()
|
||||
expect(() => SessionRevert.RevertInput.zod.parse({ sessionID })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionSummary.DiffInput", () => {
|
||||
const decode = decodeUnknown(SessionSummary.DiffInput)
|
||||
|
||||
test("messageID optional", () => {
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(decode({ sessionID, messageID })).toEqual({ sessionID, messageID })
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStatus.Info", () => {
|
||||
const decode = decodeUnknown(SessionStatus.Info)
|
||||
|
||||
test("idle / busy discriminators", () => {
|
||||
expect(decode({ type: "idle" })).toEqual({ type: "idle" })
|
||||
expect(decode({ type: "busy" })).toEqual({ type: "busy" })
|
||||
expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" })
|
||||
})
|
||||
|
||||
test("retry carries attempt/message/next", () => {
|
||||
const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(SessionStatus.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("rejects unknown type", () => {
|
||||
expect(() => decode({ type: "bogus" })).toThrow()
|
||||
expect(() => SessionStatus.Info.zod.parse({ type: "bogus" })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Todo.Info", () => {
|
||||
const decode = decodeUnknown(Todo.Info)
|
||||
|
||||
test("three-field round-trip", () => {
|
||||
const input = { content: "do a thing", status: "pending", priority: "high" }
|
||||
expect(decode(input)).toEqual(input)
|
||||
expect(Todo.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionPrompt input schemas", () => {
|
||||
test("LoopInput is just sessionID", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.LoopInput)
|
||||
expect(decode({ sessionID })).toEqual({ sessionID })
|
||||
expect(SessionPrompt.LoopInput.zod.parse({ sessionID } as unknown)).toEqual({ sessionID })
|
||||
})
|
||||
|
||||
test("ShellInput requires agent + command", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.ShellInput)
|
||||
const expected = { sessionID, agent: "build", command: "echo hi" }
|
||||
const input: unknown = expected
|
||||
expect(decode(input)).toEqual(expected)
|
||||
expect(SessionPrompt.ShellInput.zod.parse(input as unknown)).toEqual(expected)
|
||||
expect(() => decode({ sessionID })).toThrow()
|
||||
})
|
||||
|
||||
test("PromptInput accepts a text part and a file part", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.PromptInput)
|
||||
const expected = {
|
||||
sessionID,
|
||||
parts: [
|
||||
{ type: "text" as const, text: "hello" },
|
||||
{ type: "file" as const, mime: "image/png", url: "data:image/png;base64,AAAA" },
|
||||
],
|
||||
}
|
||||
const input: unknown = expected
|
||||
const decoded = decode(input)
|
||||
expect(decoded.parts).toHaveLength(2)
|
||||
expect(decoded.parts[0]).toMatchObject({ type: "text", text: "hello" })
|
||||
expect(decoded.parts[1]).toMatchObject({ type: "file", mime: "image/png" })
|
||||
|
||||
const viaZod = SessionPrompt.PromptInput.zod.parse(input)
|
||||
expect(viaZod.parts).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("PromptInput rejects unknown part type", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.PromptInput)
|
||||
const bad = {
|
||||
sessionID,
|
||||
parts: [{ type: "nonsense", payload: 42 }],
|
||||
}
|
||||
expect(() => decode(bad)).toThrow()
|
||||
expect(() => SessionPrompt.PromptInput.zod.parse(bad)).toThrow()
|
||||
})
|
||||
|
||||
test("CommandInput round-trips core fields", () => {
|
||||
const decode = decodeUnknown(SessionPrompt.CommandInput)
|
||||
const expected = {
|
||||
sessionID,
|
||||
arguments: "--flag",
|
||||
command: "deploy",
|
||||
}
|
||||
const input: unknown = expected
|
||||
expect(decode(input)).toEqual(expected)
|
||||
expect(SessionPrompt.CommandInput.zod.parse(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
+23
-27
@@ -4174,34 +4174,30 @@
|
||||
"parts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^prt.*"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
},
|
||||
"mime": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/FilePartSource"
|
||||
}
|
||||
},
|
||||
"required": ["type", "mime", "url"]
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^prt.*"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file"
|
||||
},
|
||||
"mime": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/FilePartSource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": ["type", "mime", "url"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"name": "@opencode-ai/shared",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.14.21",
|
||||
"version": "1.14.22",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user