Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa35180ab | ||
|
|
4fdeb5aac2 | ||
|
|
43b51f09d0 | ||
|
|
c61ab51886 | ||
|
|
d373c562f2 | ||
|
|
5fa5d876fc | ||
|
|
805af011c9 | ||
|
|
480aa8b23b | ||
|
|
d62442bb5d | ||
|
|
8602937a37 | ||
|
|
77da433e0a | ||
|
|
ad79f3e0cf | ||
|
|
6c2dfd2f52 | ||
|
|
9a8b54fe62 | ||
|
|
dcdbdb218f | ||
|
|
5e49029e70 | ||
|
|
19abadaf27 |
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of props.diffs()) {
|
||||
for (const diff of diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
|
||||
@@ -59,10 +59,10 @@ Rules:
|
||||
|
||||
## Schema → Zod interop
|
||||
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
|
||||
|
||||
```ts
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims.
|
||||
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
||||
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
||||
schema via `@/util/effect-zod`.
|
||||
schema via `@opencode-ai/core/effect-zod`.
|
||||
|
||||
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
||||
moves to `@effect/platform`, every Schema-first DTO can flow through
|
||||
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
|
||||
|
||||
## Escape hatches
|
||||
|
||||
The walker in `@/util/effect-zod` exposes two explicit escape hatches for
|
||||
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
|
||||
cases the pure-Schema path cannot express. Each one stays in the codebase
|
||||
only as long as its upstream or local dependency requires it — inline
|
||||
comments document when each can be deleted.
|
||||
@@ -389,7 +389,7 @@ piecewise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `@/util/effect-zod` for all Schema → Zod conversion.
|
||||
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
|
||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||
Effect definitions for the same domain type.
|
||||
- Keep the migration incremental. Converting the domain model first is more
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# TUI Command Shim Removal
|
||||
|
||||
Problem:
|
||||
|
||||
- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization
|
||||
- v2 should expose only the keymap command API
|
||||
- tests and fixtures should not encode legacy command behavior as expected behavior
|
||||
|
||||
## Remove Public Types
|
||||
|
||||
In `packages/plugin/src/tui.ts`, remove:
|
||||
|
||||
- `TuiCommand`
|
||||
- `TuiCommandApi`
|
||||
- `TuiPluginApi.command`
|
||||
|
||||
Keep `api.keymap` as the only TUI command registration and execution surface.
|
||||
|
||||
## Remove Runtime Shim
|
||||
|
||||
Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`.
|
||||
|
||||
In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove:
|
||||
|
||||
- the `createCommandShim` import
|
||||
- the `command: createCommandShim(...)` field from `createTuiApi(...)`
|
||||
|
||||
In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove:
|
||||
|
||||
- the `createCommandShim` import
|
||||
- the `command: createCommandShim(...)` field from `pluginApi(...)`
|
||||
|
||||
## Migration Target
|
||||
|
||||
Plugin authors should replace old calls with keymap calls:
|
||||
|
||||
```ts
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: "plugin.command",
|
||||
title: "Plugin Command",
|
||||
namespace: "palette",
|
||||
slashName: "plugin",
|
||||
run() {
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }],
|
||||
})
|
||||
```
|
||||
|
||||
Direct replacements:
|
||||
|
||||
- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })`
|
||||
- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)`
|
||||
- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")`
|
||||
- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, run from package directories:
|
||||
|
||||
- `bun typecheck` in `packages/plugin`
|
||||
- `bun typecheck` in `packages/opencode`
|
||||
- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed
|
||||
@@ -24,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number })
|
||||
}
|
||||
}
|
||||
|
||||
function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
|
||||
function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) {
|
||||
return diffs?.map((item, i) => ({
|
||||
...item,
|
||||
file: redact(`${kind}-file`, String(i), item.file),
|
||||
patch: redact(`${kind}-patch`, String(i), item.patch),
|
||||
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file),
|
||||
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,17 @@ export function useEvent() {
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
// Workspace-scoped events match on workspace identity. Events without a
|
||||
// workspace label fall through to the directory check — a session with
|
||||
// no workspaceID can be live in the same directory as the TUI even when
|
||||
// the TUI itself is attached to a workspace (#26671).
|
||||
if (event.workspace !== undefined) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -526,10 +526,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
||||
for (const message of messages.data!) {
|
||||
const infos: (typeof draft.message)[string] = []
|
||||
for (const message of messages.data ?? []) {
|
||||
infos.push(message.info)
|
||||
draft.part[message.info.id] = message.parts
|
||||
}
|
||||
draft.message[sessionID] = infos
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TOAST_DURATION = 5000
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots"
|
||||
import type { useToast } from "../ui/toast"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Keymap from "../keymap"
|
||||
import { createCommandShim } from "./command-shim"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
@@ -147,7 +148,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return sync.data.session.length
|
||||
},
|
||||
diff(sessionID) {
|
||||
return sync.data.session_diff[sessionID] ?? []
|
||||
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
||||
)
|
||||
},
|
||||
todo(sessionID) {
|
||||
return sync.data.todo[sessionID] ?? []
|
||||
@@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||
}
|
||||
return {
|
||||
app: appApi(),
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
|
||||
keys: {
|
||||
formatSequence(parts) {
|
||||
return Keymap.formatKeySequence(parts, input.tuiConfig)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import type { DialogContext } from "../ui/dialog"
|
||||
|
||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||
const warned = new Set<string>()
|
||||
|
||||
type Warn = (api: string, replacement: string) => void
|
||||
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
|
||||
type CommandShimDialog = DialogContext | LegacyDialog
|
||||
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
|
||||
|
||||
function warnCommandShim(api: string, replacement: string) {
|
||||
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
|
||||
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
|
||||
}
|
||||
|
||||
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
|
||||
if (!("stack" in dialog)) return dialog
|
||||
return {
|
||||
replace(render, onClose) {
|
||||
dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return dialog.stack.length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnce(api: string, replacement: string, warn: Warn) {
|
||||
if (warned.has(api)) return
|
||||
warned.add(api)
|
||||
warn(api, replacement)
|
||||
}
|
||||
|
||||
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
||||
return {
|
||||
namespace: "palette",
|
||||
name: item.value,
|
||||
title: item.title,
|
||||
desc: item.description,
|
||||
category: item.category,
|
||||
suggested: item.suggested,
|
||||
hidden: item.hidden,
|
||||
enabled: item.enabled,
|
||||
slashName: item.slash?.name,
|
||||
slashAliases: item.slash?.aliases,
|
||||
run() {
|
||||
return item.onSelect?.(dialog)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
|
||||
return commands.flatMap((item) =>
|
||||
item.keybind
|
||||
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
? keybinds
|
||||
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
|
||||
: [
|
||||
{
|
||||
key: item.keybind,
|
||||
cmd: item.value,
|
||||
desc: item.title,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
export function createCommandShim(
|
||||
keymap: TuiPluginApi["keymap"],
|
||||
dialog: CommandShimDialog,
|
||||
keybinds: LegacyKeybinds,
|
||||
): TuiPluginApi["command"] {
|
||||
const shimDialog = createCommandShimDialog(dialog)
|
||||
return {
|
||||
register(cb) {
|
||||
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
|
||||
const commands = cb()
|
||||
return keymap.registerLayer({
|
||||
commands: commands.map((item) => toCommand(item, shimDialog)),
|
||||
bindings: toBindings(commands, keybinds),
|
||||
})
|
||||
},
|
||||
trigger(value) {
|
||||
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
|
||||
keymap.dispatchCommand(value)
|
||||
},
|
||||
show() {
|
||||
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
|
||||
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "./slots"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { createCommandShim } from "./command-shim"
|
||||
|
||||
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
||||
|
||||
@@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
|
||||
return {
|
||||
app: api.app,
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
||||
keys: api.keys,
|
||||
keymap,
|
||||
route,
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
|
||||
@@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Exit, Schema, SchemaGetter } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { configEntryNameFromPath } from "./entry-name"
|
||||
import { InvalidError } from "./error"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
|
||||
@@ -22,8 +22,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
import { ConfigCommand } from "./command"
|
||||
import { ConfigFormatter } from "./formatter"
|
||||
@@ -307,7 +307,7 @@ export const Info = Schema.Struct({
|
||||
})),
|
||||
)
|
||||
|
||||
// Uses the shared `DeepMutable` from `@/util/schema`. See the definition
|
||||
// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition
|
||||
// there for why the local variant is needed over `Types.DeepMutable` from
|
||||
// effect-smol (the upstream version collapses `unknown` to `{}`).
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Layout = Schema.Literals(["auto", "stretch"])
|
||||
.annotate({ identifier: "LayoutConfig" })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigLSP from "./lsp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as LSPServer from "../lsp/server"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Local = Schema.Struct({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigParse from "./parse"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
|
||||
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
|
||||
import z from "zod"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { InvalidError, JsonError } from "./error"
|
||||
|
||||
type ZodSchema<T> = z.ZodType<T>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigPermission from "./permission"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"])
|
||||
.annotate({ identifier: "PermissionActionConfig" })
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import path from "path"
|
||||
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigReference from "./reference"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const Git = Schema.Struct({
|
||||
repository: Schema.String.annotate({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Server = Schema.Struct({
|
||||
port: Schema.optional(PositiveInt).annotate({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
|
||||
Schema.brand("WorkspaceID"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema, Struct } from "effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
export const WorkspaceInfo = Schema.Struct({
|
||||
id: WorkspaceID,
|
||||
|
||||
@@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
path: Schema.String,
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { which } from "@/util/which"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { mergeDeep } from "remeda"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod as effectZod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "mcp" })
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
@@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||
import os from "os"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { Newtype } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
export class PermissionID extends Newtype<PermissionID>()(
|
||||
"PermissionID",
|
||||
|
||||
@@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { zod, zodObject } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, zodObject } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
@@ -230,7 +230,10 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const FileDiff = Schema.Struct({
|
||||
file: Schema.String,
|
||||
patch: Schema.String,
|
||||
// Mirrors Snapshot.FileDiff (see #26574). The current producer always
|
||||
// populates patch, but loosening matches the sibling schema so a
|
||||
// future code path that omits it can't crash /instance/vcs/diff.
|
||||
patch: Schema.optional(Schema.String),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
@@ -1162,7 +1162,7 @@ const layer: Layer.Layer<
|
||||
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||
|
||||
provider.models = yield* Effect.promise(async () => {
|
||||
const next = await models(provider, { auth: pluginAuth })
|
||||
const next = await models(toPublicInfo(provider), { auth: pluginAuth })
|
||||
return Object.fromEntries(
|
||||
Object.entries(next).map(([id, model]) => [
|
||||
id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import type { Proc } from "#pty"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { PtyID } from "./schema"
|
||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "pty" })
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TTL = Duration.seconds(60)
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { Newtype } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
export class QuestionID extends Newtype<QuestionID>()(
|
||||
"QuestionID",
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MCP } from "@/mcp"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
@@ -43,6 +43,7 @@ const ToolListItem = Schema.Struct({
|
||||
}).annotate({ identifier: "ToolListItem" })
|
||||
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||
export const ToolListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
})
|
||||
@@ -55,7 +56,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
)
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
export const SessionListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
...WorkspaceRoutingQueryFields,
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
cursor: Schema.optional(Schema.NumberFromString),
|
||||
|
||||
@@ -5,18 +5,21 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const FileQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
path: Schema.String,
|
||||
})
|
||||
|
||||
export const FindTextQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
pattern: Schema.String,
|
||||
})
|
||||
|
||||
export const FindFileQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
query: Schema.String,
|
||||
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
||||
@@ -26,6 +29,7 @@ export const FindFileQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const FindSymbolQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
query: Schema.String,
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const PathInfo = Schema.Struct({
|
||||
@@ -20,6 +20,7 @@ const PathInfo = Schema.Struct({
|
||||
}).annotate({ identifier: "Path" })
|
||||
|
||||
export const VcsDiffQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
mode: Vcs.Mode,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,13 +5,16 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/pty"
|
||||
export const Params = Schema.Struct({ ptyID: PtyID })
|
||||
export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
|
||||
export const CursorQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
cursor: Schema.optional(Schema.String),
|
||||
})
|
||||
export const ShellItem = Schema.Struct({
|
||||
path: Schema.String,
|
||||
name: Schema.String,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
@@ -26,7 +26,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
}),
|
||||
)
|
||||
export const ListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
...WorkspaceRoutingQueryFields,
|
||||
scope: Schema.optional(Schema.Literals(["project"])),
|
||||
path: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
@@ -34,8 +34,12 @@ export const ListQuery = Schema.Struct({
|
||||
search: Schema.optional(Schema.String),
|
||||
limit: Schema.optional(Schema.NumberFromString),
|
||||
})
|
||||
export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
|
||||
export const DiffQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]),
|
||||
})
|
||||
export const MessagesQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
||||
before: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session } from "@/session/session"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
|
||||
+11
-1
@@ -9,11 +9,21 @@ import * as Fence from "@/server/shared/fence"
|
||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Data, Effect, Layer } from "effect"
|
||||
import { Context, Data, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
|
||||
// Query fields this middleware reads from the URL. Spread into every
|
||||
// endpoint query schema in groups that apply WorkspaceRoutingMiddleware,
|
||||
// otherwise HttpApi rejects requests carrying these params with 400.
|
||||
// HttpApiMiddleware in effect-smol cannot declare query params today —
|
||||
// remove this once upstream supports middleware-declared query schemas.
|
||||
export const WorkspaceRoutingQueryFields = {
|
||||
directory: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
}
|
||||
|
||||
type RemoteTarget = Extract<Target, { type: "remote" }>
|
||||
|
||||
type RequestPlan = Data.TaggedEnum<{
|
||||
|
||||
@@ -23,8 +23,8 @@ import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Schema, Types } from "effect"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
import { SessionID } from "./schema"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
|
||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -46,8 +46,8 @@ import { Truncate } from "@/tool/truncate"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "../sync"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
|
||||
@@ -37,8 +37,8 @@ import type { Provider } from "@/provider/provider"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID } from "./schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as Session from "./session"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionID, MessageID } from "./schema"
|
||||
@@ -134,6 +134,7 @@ export const layer = Layer.effect(
|
||||
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
|
||||
const next = diffs.map((item) => {
|
||||
if (item.file === undefined) return item
|
||||
const file = unquoteGitPath(item.file)
|
||||
if (file === item.file) return item
|
||||
return { ...item, file }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { SessionID } from "./schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { Database } from "@/storage/db"
|
||||
|
||||
@@ -2,8 +2,8 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
export const Patch = Schema.Struct({
|
||||
hash: Schema.String,
|
||||
@@ -20,8 +20,11 @@ export const Patch = Schema.Struct({
|
||||
export type Patch = typeof Patch.Type
|
||||
|
||||
export const FileDiff = Schema.Struct({
|
||||
file: Schema.String,
|
||||
patch: Schema.String,
|
||||
// Optional because legacy/imported `summary_diffs` on disk may omit
|
||||
// file details and patch text. Required Schema rejected the whole
|
||||
// session response and broke session loading on Desktop.
|
||||
file: Schema.optional(Schema.String),
|
||||
patch: Schema.optional(Schema.String),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import z from "zod"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Git } from "@/git"
|
||||
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { EventID } from "./schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe(
|
||||
Schema.brand("EventID"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Option, Schema, Scope } from "effect"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { createReadStream } from "fs"
|
||||
import * as path from "path"
|
||||
import { createInterface } from "readline"
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Config } from "@/config/config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@/util/effect-zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID"))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import DESCRIPTION from "./shell.txt"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ShellID } from "./id"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
/**
|
||||
* Create a Schema-backed NamedError-shaped class.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
|
||||
import { Identifier } from "@opencode-ai/core/util/identifier"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Schema from "effect/Schema"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "./event"
|
||||
import { FileAttachment, Prompt } from "./session-prompt"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EventV2 } from "./event"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { V2Schema } from "./schema"
|
||||
import { optionalOmitUndefined } from "@/util/schema"
|
||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { Modelv2 } from "./model"
|
||||
|
||||
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
|
||||
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
|
||||
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
|
||||
import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
|
||||
|
||||
export const worktree = "/tmp/opencode"
|
||||
export const directory = `${worktree}/packages/opencode`
|
||||
|
||||
export async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
export function json(data: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||
})
|
||||
}
|
||||
|
||||
export function eventSource(): EventSource {
|
||||
return { subscribe: async () => () => {} }
|
||||
}
|
||||
|
||||
type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
||||
|
||||
export function createFetch(override?: FetchHandler) {
|
||||
const session = [] as URL[]
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
if (url.pathname === "/session") session.push(url)
|
||||
|
||||
const overridden = await override?.(url)
|
||||
if (overridden) return overridden
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/agent":
|
||||
case "/command":
|
||||
case "/experimental/workspace":
|
||||
case "/experimental/workspace/status":
|
||||
case "/formatter":
|
||||
case "/lsp":
|
||||
return json([])
|
||||
case "/config":
|
||||
case "/experimental/resource":
|
||||
case "/mcp":
|
||||
case "/provider/auth":
|
||||
case "/session/status":
|
||||
return json({})
|
||||
case "/config/providers":
|
||||
return json({ providers: {}, default: {} })
|
||||
case "/experimental/console":
|
||||
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
case "/path":
|
||||
return json({ home: "", state: "", config: "", worktree, directory })
|
||||
case "/project/current":
|
||||
return json({ id: "proj_test" })
|
||||
case "/provider":
|
||||
return json({ all: [], default: {}, connected: [] })
|
||||
case "/session":
|
||||
return json([])
|
||||
case "/vcs":
|
||||
return json({ branch: "main" })
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request: ${url.pathname}`)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
return { fetch, session }
|
||||
}
|
||||
|
||||
type Ctx = { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }
|
||||
|
||||
export async function mount(override?: FetchHandler) {
|
||||
const calls = createFetch(override)
|
||||
let sync!: ReturnType<typeof useSync>
|
||||
let kv!: ReturnType<typeof useKV>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
const ctx: Ctx = { kv: useKV(), sync: useSync() }
|
||||
onMount(() => {
|
||||
sync = ctx.sync
|
||||
kv = ctx.kv
|
||||
done()
|
||||
})
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ArgsProvider>
|
||||
<ExitProvider>
|
||||
<KVProvider>
|
||||
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<Probe />
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
))
|
||||
|
||||
await ready
|
||||
await wait(() => sync.status === "complete")
|
||||
return { app, kv, sync, session: calls.session }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
/**
|
||||
* Reproducer for #26560 — TUI crashes with
|
||||
* `TypeError: undefined is not an object (evaluating 'f.data.map')`
|
||||
* when entering a session whose messages endpoint returns a non-2xx.
|
||||
* The failure path is `sync.tsx#sync.session.sync` reading
|
||||
* `messages.data!` while the SDK leaves `data` undefined on error.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
import { directory, json, mount } from "./sync-fixture"
|
||||
|
||||
const sessionID = "ses_undef"
|
||||
|
||||
describe("tui sync (#26560)", () => {
|
||||
test("entering a session whose messages endpoint errors does not crash sync", async () => {
|
||||
const previous = Global.Path.state
|
||||
await using tmp = await tmpdir()
|
||||
Global.Path.state = tmp.path
|
||||
await Bun.write(`${tmp.path}/kv.json`, "{}")
|
||||
|
||||
const sessionPayload = {
|
||||
id: sessionID,
|
||||
title: "broken",
|
||||
time: { created: 0, updated: 0 },
|
||||
version: "1.14.42",
|
||||
directory,
|
||||
project_id: "proj_test",
|
||||
}
|
||||
const { app, sync } = await mount((url) => {
|
||||
if (url.pathname === `/session/${sessionID}`) return json(sessionPayload)
|
||||
if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 })
|
||||
if (url.pathname === `/session/${sessionID}/todo`) return json([])
|
||||
if (url.pathname === `/session/${sessionID}/diff`) return json([])
|
||||
if (url.pathname === "/session") return json([sessionPayload])
|
||||
return undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(sync.session.sync(sessionID)).resolves.toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
Global.Path.state = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,127 +1,8 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
|
||||
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
|
||||
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
|
||||
import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
|
||||
const worktree = "/tmp/opencode"
|
||||
const directory = `${worktree}/packages/opencode`
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function json(data: unknown) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
function eventSource(): EventSource {
|
||||
return {
|
||||
subscribe: async () => () => {},
|
||||
}
|
||||
}
|
||||
|
||||
function createFetch() {
|
||||
const session = [] as URL[]
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
if (url.pathname === "/session") session.push(url)
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/agent":
|
||||
case "/command":
|
||||
case "/experimental/workspace":
|
||||
case "/experimental/workspace/status":
|
||||
case "/formatter":
|
||||
case "/lsp":
|
||||
return json([])
|
||||
case "/config":
|
||||
case "/experimental/resource":
|
||||
case "/mcp":
|
||||
case "/provider/auth":
|
||||
case "/session/status":
|
||||
return json({})
|
||||
case "/config/providers":
|
||||
return json({ providers: {}, default: {} })
|
||||
case "/experimental/console":
|
||||
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
case "/path":
|
||||
return json({ home: "", state: "", config: "", worktree, directory })
|
||||
case "/project/current":
|
||||
return json({ id: "proj_test" })
|
||||
case "/provider":
|
||||
return json({ all: [], default: {}, connected: [] })
|
||||
case "/session":
|
||||
return json([])
|
||||
case "/vcs":
|
||||
return json({ branch: "main" })
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request: ${url.pathname}`)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
return { fetch, session }
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSync>
|
||||
let kv!: ReturnType<typeof useKV>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ArgsProvider>
|
||||
<ExitProvider>
|
||||
<KVProvider>
|
||||
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<Probe
|
||||
onReady={(ctx) => {
|
||||
sync = ctx.sync
|
||||
kv = ctx.kv
|
||||
done()
|
||||
}}
|
||||
/>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
))
|
||||
|
||||
await ready
|
||||
await wait(() => sync.status === "complete")
|
||||
return { app, kv, sync, session: calls.session }
|
||||
}
|
||||
|
||||
function Probe(props: { onReady: (ctx: { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }) => void }) {
|
||||
const kv = useKV()
|
||||
const sync = useSync()
|
||||
|
||||
onMount(() => {
|
||||
props.onReady({ kv, sync })
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
import { mount } from "./sync-fixture"
|
||||
|
||||
describe("tui sync", () => {
|
||||
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Regression coverage for issue #26671 — TUI does not live-render messages when
|
||||
* an external HTTP client POSTs to /session/{id}/prompt_async on the same
|
||||
* server, even though the web UI renders them correctly.
|
||||
*
|
||||
* The hypothesis under test: prompt_async forks via Effect.forkIn(scope), and
|
||||
* the forked work emits GlobalBus events (the same bus the SSE /event endpoint
|
||||
* forwards). Per #26586, Effect.forkIn preserves InstanceRef/WorkspaceRef
|
||||
* across the fork, so events emitted inside the fork should carry the
|
||||
* request's directory and workspace.
|
||||
*
|
||||
* Test 1 (live request): mount the in-process server, create a session in a
|
||||
* tmp directory (no workspace), POST /session/{id}/prompt_async, and assert
|
||||
* that some GlobalBus event scoped to the session fires with
|
||||
* { directory: <tmp.path>, workspace: undefined }. If this fails, the bug is
|
||||
* in event publishing. If it passes, publishing is correct and the bug lives
|
||||
* in the TUI's client-side filter.
|
||||
*
|
||||
* Test 2 (synthetic filter): replicate the TUI useEvent filter shape from
|
||||
* packages/opencode/src/cli/cmd/tui/context/event.ts and lock in the fixed
|
||||
* behaviour: events without a workspace label fall through to a directory
|
||||
* comparison even when the TUI itself is attached to a workspace, so a
|
||||
* directory-mode session driven by an external POST is forwarded correctly.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
function createSession(directory: string, input?: Session.CreateInput) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("session prompt_async events (issue #26671)", () => {
|
||||
test("forks publish GlobalBus events with the request's directory/workspace", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const session = await createSession(tmp.path, { title: "external prompt" })
|
||||
|
||||
// Subscribe BEFORE posting so we don't miss the first emit. The
|
||||
// GlobalBus.on inside Effect.callback registers synchronously when the
|
||||
// outer Promise is created, so kicking the request after this line is
|
||||
// safe.
|
||||
const eventPromise = waitGlobalBusEventPromise({
|
||||
timeout: 8_000,
|
||||
message: "no GlobalBus event observed for prompt_async",
|
||||
// Match the first non-housekeeping event scoped to this session. The
|
||||
// exact event type depends on how far the prompt gets before failing
|
||||
// (no provider configured): the user-message persistence emits sync
|
||||
// events, and any subsequent failure emits Session.Event.Error.
|
||||
predicate: (event) => {
|
||||
if (event.payload.type === "server.heartbeat") return false
|
||||
if (event.payload.type === "server.connected") return false
|
||||
// Attached payload always serialises the sessionID for session events.
|
||||
return JSON.stringify(event.payload).includes(session.id)
|
||||
},
|
||||
})
|
||||
|
||||
const promptAsyncPath = pathFor(SessionPaths.promptAsync, { sessionID: session.id })
|
||||
const response = await app().request(promptAsyncPath, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parts: [{ type: "text", text: "hello from external POST" }],
|
||||
}),
|
||||
})
|
||||
|
||||
// prompt_async returns 204 immediately; the work continues in the fork.
|
||||
expect(response.status).toBe(204)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.directory).toBe(tmp.path)
|
||||
expect(event.workspace).toBeUndefined()
|
||||
})
|
||||
|
||||
test("TUI useEvent filter forwards directory-scoped events even when the TUI has an active workspace", () => {
|
||||
// Mirrors the (fixed) filter at packages/opencode/src/cli/cmd/tui/context/event.ts.
|
||||
// If someone reverts the fix back to an early-return in the workspace
|
||||
// branch, case (c) below catches it.
|
||||
type IncomingEvent = {
|
||||
directory: string | undefined
|
||||
workspace: string | undefined
|
||||
payload: { type: string }
|
||||
}
|
||||
function tuiFilter(input: {
|
||||
event: IncomingEvent
|
||||
activeWorkspace: string | undefined
|
||||
activeDirectory: string
|
||||
}): boolean {
|
||||
const { event, activeWorkspace, activeDirectory } = input
|
||||
if (event.payload.type === "sync") return false
|
||||
if (event.directory === "global") return true
|
||||
|
||||
if (event.workspace !== undefined) {
|
||||
return event.workspace === activeWorkspace
|
||||
}
|
||||
return event.directory === activeDirectory
|
||||
}
|
||||
|
||||
// (a) Directory mode: matching directory is forwarded.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: undefined,
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
// (b) Workspace mode: matching workspace is forwarded.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: "W1", payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: "W1",
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
// (c) THE FIX FOR #26671:
|
||||
// TUI is in workspace mode, the inbound event has workspace=undefined
|
||||
// (the session has no workspaceID and the external POST didn't carry
|
||||
// workspace context), and the directory matches. The fixed filter
|
||||
// recognises that an event without a workspace label is directory-
|
||||
// scoped and consults the directory comparator, so this is forwarded.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: "W1",
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
// (d) Cross-workspace events stay dropped.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: "W2", payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: "W1",
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,38 @@ function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
|
||||
return "providers" in input && providerListHasFetch(input.providers)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function providerList(input: unknown, key: "all" | "providers") {
|
||||
if (!isRecord(input)) return []
|
||||
if (!Array.isArray(input[key])) return []
|
||||
return input[key]
|
||||
}
|
||||
|
||||
function providerByID(input: unknown, key: "all" | "providers", id: string) {
|
||||
return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id)
|
||||
}
|
||||
|
||||
function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) {
|
||||
const provider = providerByID(input, key, id)
|
||||
if (!isRecord(provider) || !isRecord(provider.models)) return false
|
||||
return Object.values(provider.models).some((model) => {
|
||||
if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false
|
||||
return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some(
|
||||
(cost) => typeof cost === "number" && cost > 0,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) {
|
||||
const provider = providerByID(input, key, id)
|
||||
if (!isRecord(provider)) return false
|
||||
if (provider.name === "mutated-provider") return true
|
||||
return isRecord(provider.options) && provider.options.mutatedByPlugin === true
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
app: ReturnType<typeof app>
|
||||
providerID: string
|
||||
@@ -125,6 +157,40 @@ function writeFunctionOptionsPlugin(dir: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function writeProviderModelsMutationPlugin(dir: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true })
|
||||
yield* fs.writeFileString(
|
||||
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
|
||||
[
|
||||
"export default {",
|
||||
' id: "test.provider-models-mutation",',
|
||||
" server: async () => ({",
|
||||
" provider: {",
|
||||
' id: "google",',
|
||||
" models: async (provider) => {",
|
||||
" const models = Object.fromEntries(",
|
||||
" Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),",
|
||||
" )",
|
||||
' provider.name = "mutated-provider"',
|
||||
" provider.options = { ...provider.options, mutatedByPlugin: true }",
|
||||
" for (const model of Object.values(provider.models ?? {})) {",
|
||||
" model.cost = { input: 0, output: 0 }",
|
||||
" }",
|
||||
" return models",
|
||||
" },",
|
||||
" },",
|
||||
" }),",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
@@ -222,6 +288,37 @@ describe("provider HttpApi", () => {
|
||||
const configBody = yield* Effect.promise(() => configResponse.json())
|
||||
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
|
||||
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps provider.models hook input mutations out of provider state", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
|
||||
|
||||
yield* fs.writeFileString(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
|
||||
)
|
||||
yield* writeProviderModelsMutationPlugin(dir)
|
||||
|
||||
const headers = { "x-opencode-directory": dir }
|
||||
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
|
||||
const configResponse = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request("/config/providers", { headers })),
|
||||
)
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* Effect.promise(() => providerResponse.json())
|
||||
const configBody = yield* Effect.promise(() => configResponse.json())
|
||||
expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
|
||||
expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function request(url: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app().request(url, init))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(
|
||||
options: Parameters<typeof tmpdir>[0],
|
||||
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(options)),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(fn))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
// Regression for the "OpenAPI advertises ?directory&workspace, runtime
|
||||
// rejects them" drift class. Each affected route must accept both params
|
||||
// without 400.
|
||||
describe("httpapi query schema drift", () => {
|
||||
const routingParams = (dir: string) =>
|
||||
`directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
|
||||
|
||||
const expectNotSchemaRejection = (status: number, url: string) => {
|
||||
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
|
||||
}
|
||||
|
||||
it.live(
|
||||
"session list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/session?${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"session messages accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"file find/file accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/find/file?query=foo&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"file find/text accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/find?pattern=foo&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"file read accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/file?path=foo&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"experimental session list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/experimental/session?${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"experimental tool list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"vcs diff accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
|
||||
const response = yield* request(url)
|
||||
expectNotSchemaRejection(response.status, url)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -297,6 +297,36 @@ describe("session HttpApi", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"serves sessions with migrated summary diffs missing file details",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* createSession(tmp.path, { title: "legacy diff" })
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: 1,
|
||||
summary_deletions: 0,
|
||||
summary_files: 1,
|
||||
summary_diffs: [{ additions: 1, deletions: 0 }],
|
||||
})
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
|
||||
const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((yield* json<Session.Info>(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"serves lifecycle mutation routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
|
||||
@@ -364,8 +394,15 @@ describe("session HttpApi", () => {
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "workspace session" }),
|
||||
})
|
||||
const messages = yield* request(
|
||||
`${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`,
|
||||
{
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
},
|
||||
)
|
||||
|
||||
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
|
||||
expect(messages.status).toBe(200)
|
||||
expect(
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Regression test for the same bug class as #26574 (sibling of #26566 and
|
||||
* #26553). The Desktop app calls GET /session/<id>/diff; before #26574
|
||||
* the response was Schema-encoded against `Snapshot.FileDiff` with
|
||||
* `patch: Schema.String` (required), so any session whose stored
|
||||
* `summary_diffs` had a row without `patch` returned HTTP 400 and the
|
||||
* session never loaded.
|
||||
*
|
||||
* This test inserts a session row with a missing-patch diff entry and
|
||||
* asserts that GET /session/<id>/diff returns 200 with the row intact.
|
||||
*/
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "@/server/server"
|
||||
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function pathFor(template: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template)
|
||||
}
|
||||
|
||||
describe("session diff with missing patch (#26574)", () => {
|
||||
it.live("GET /session/<id>/diff returns 200 when summary_diffs row has no patch", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })),
|
||||
(t) => Effect.promise(() => t[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Effect.runPromise(
|
||||
Effect.provide(
|
||||
Session.Service.use((s) => s.create({ title: "missing-patch" })),
|
||||
Session.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
// Mimic legacy/imported on-disk shape: a diff entry with no
|
||||
// `patch` text. Pre-fix the typed response encoder rejects
|
||||
// this and returns 400.
|
||||
await Effect.runPromise(
|
||||
Effect.provide(
|
||||
Storage.Service.use((s) =>
|
||||
s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
|
||||
),
|
||||
Storage.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), {
|
||||
headers,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }>
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0]?.file).toBe("legacy.txt")
|
||||
expect(body[0]?.additions).toBe(1)
|
||||
expect(body[0]?.patch).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
@@ -165,4 +164,28 @@ describe("session messages endpoint", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("accepts directory query used by workspace routing", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await withoutWatcher(() =>
|
||||
WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
await fill(session.id, 1)
|
||||
const app = Server.Default().app
|
||||
|
||||
const res = await app.request(
|
||||
`/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body).toHaveLength(1)
|
||||
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,26 @@ describe("Session.Info", () => {
|
||||
expect(Session.Info.zod.parse(input)).toEqual(input)
|
||||
})
|
||||
|
||||
test("accepts migrated summary diffs without file details", () => {
|
||||
const input = {
|
||||
id: sessionID,
|
||||
slug: "legacy-diff",
|
||||
projectID,
|
||||
directory: "/tmp/proj",
|
||||
title: "Legacy diff",
|
||||
version: "0.1.0",
|
||||
summary: {
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
files: 1,
|
||||
diffs: [{ additions: 1, deletions: 0 }],
|
||||
},
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -238,7 +238,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
|
||||
expect(tool?.state.status).toBe("completed")
|
||||
|
||||
// Poll for diff — summarize() is fire-and-forget
|
||||
let diff: Array<{ file: string }> = []
|
||||
let diff: Array<{ file?: string }> = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
diff = yield* summary.diff({ sessionID: session.id })
|
||||
if (diff.length > 0) break
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Result, Schema } from "effect"
|
||||
import { toJsonSchema } from "../../src/util/effect-zod"
|
||||
import { toJsonSchema } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
// Each tool exports its parameters schema at module scope so this test can
|
||||
// import them without running the tool's Effect-based init. The JSON Schema
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { zod, ZodOverride } from "../../src/util/effect-zod"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
function json(schema: z.ZodTypeAny) {
|
||||
const { $schema: _, ...rest } = z.toJSONSchema(schema)
|
||||
|
||||
@@ -77,6 +77,42 @@ export type TuiKeys = {
|
||||
|
||||
export type TuiKeymap = Keymap<Renderable, KeyEvent>
|
||||
|
||||
/**
|
||||
* Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2.
|
||||
*
|
||||
* @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead.
|
||||
*/
|
||||
export type TuiCommand = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
hidden?: boolean
|
||||
enabled?: boolean
|
||||
slash?: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
onSelect?: (dialog?: TuiDialogStack) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
|
||||
*
|
||||
* @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
|
||||
* `api.keymap.dispatchCommand("command.palette.show")` instead.
|
||||
*/
|
||||
export type TuiCommandApi = {
|
||||
/** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */
|
||||
register: (cb: () => TuiCommand[]) => () => void
|
||||
/** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */
|
||||
trigger: (value: string) => void
|
||||
/** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */
|
||||
show: () => void
|
||||
}
|
||||
|
||||
export type TuiDialogProps = {
|
||||
size?: "medium" | "large" | "xlarge"
|
||||
onClose: () => void
|
||||
@@ -461,6 +497,13 @@ export type TuiWorkspace = {
|
||||
|
||||
export type TuiPluginApi = {
|
||||
app: TuiApp
|
||||
/**
|
||||
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
|
||||
*
|
||||
* @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and
|
||||
* `api.keymap.dispatchCommand("command.palette.show")` instead.
|
||||
*/
|
||||
command?: TuiCommandApi
|
||||
keys: TuiKeys
|
||||
keymap: TuiKeymap
|
||||
route: {
|
||||
|
||||
@@ -119,8 +119,8 @@ export type PermissionRequest = {
|
||||
}
|
||||
|
||||
export type SnapshotFileDiff = {
|
||||
file: string
|
||||
patch: string
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
@@ -1520,7 +1520,7 @@ export type VcsFileStatus = {
|
||||
|
||||
export type VcsFileDiff = {
|
||||
file: string
|
||||
patch: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
|
||||
@@ -9120,7 +9120,7 @@
|
||||
"enum": ["added", "deleted", "modified"]
|
||||
}
|
||||
},
|
||||
"required": ["file", "patch", "additions", "deletions"],
|
||||
"required": ["additions", "deletions"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderAuthError": {
|
||||
@@ -13039,7 +13039,7 @@
|
||||
"enum": ["added", "deleted", "modified"]
|
||||
}
|
||||
},
|
||||
"required": ["file", "patch", "additions", "deletions"],
|
||||
"required": ["file", "additions", "deletions"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VcsApplyError": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user