mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 22:21:25 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f76ca58a54 | |||
| 137992ca85 | |||
| d97523adbd | |||
| ffd38aade4 |
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: bun turbo test:ci --log-order=stream --log-prefix=task
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
@@ -74,26 +74,6 @@ jobs:
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
- name: Publish unit reports
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
|
||||
with:
|
||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||
check_name: "unit results (${{ matrix.settings.name }})"
|
||||
detailed_summary: true
|
||||
include_time_in_summary: true
|
||||
fail_on_failure: false
|
||||
|
||||
- name: Upload unit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: packages/*/.artifacts/unit/junit.xml
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
@@ -151,7 +131,6 @@ jobs:
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
@@ -162,6 +141,5 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: |
|
||||
packages/app/e2e/junit-*.xml
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
|
||||
@@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
||||
|
||||
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
||||
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./e2e/test-results",
|
||||
@@ -24,7 +18,7 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
"db": "bun drizzle-kit",
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"test": "bun test",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
export const Plugin = {
|
||||
id: PluginV2.ID.make("core/config-reference"),
|
||||
effect: Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter(
|
||||
(entry): entry is Config.Document => entry.type === "document",
|
||||
)) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? new Reference.LocalSource({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)),
|
||||
})
|
||||
: new Reference.GitSource({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
branch: typeof entry === "string" ? undefined : entry.branch,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield* update((editor) => {
|
||||
for (const [name, source] of entries) editor.add(name, source)
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
}
|
||||
|
||||
function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local {
|
||||
return typeof entry === "string"
|
||||
? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")
|
||||
: "path" in entry
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
@@ -16,33 +16,3 @@ export type Entry = typeof Entry.Type
|
||||
|
||||
export const Info = Schema.Record(Schema.String, Entry)
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export type NormalizedEntry =
|
||||
| { readonly kind: "local"; readonly path: string }
|
||||
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
|
||||
| { readonly kind: "invalid"; readonly message: string }
|
||||
|
||||
export type NormalizedInfo = Record<string, NormalizedEntry>
|
||||
|
||||
export function validateAlias(name: string) {
|
||||
if (name.length === 0) return "Reference alias must not be empty"
|
||||
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
|
||||
}
|
||||
|
||||
export function normalizeEntry(entry: Entry): NormalizedEntry {
|
||||
if (typeof entry === "string") {
|
||||
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
|
||||
return { kind: "git", repository: entry }
|
||||
}
|
||||
if ("path" in entry) return { kind: "local", path: entry.path }
|
||||
return { kind: "git", repository: entry.repository, branch: entry.branch }
|
||||
}
|
||||
|
||||
export function normalize(info: Info): NormalizedInfo {
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([name, entry]) => {
|
||||
const message = validateAlias(name)
|
||||
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { EventV2 } from "./event"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { Protected } from "./filesystem/protected"
|
||||
import { Ripgrep } from "./filesystem/ripgrep"
|
||||
@@ -17,7 +16,6 @@ import { ToolOutputStore } from "./tool-output-store"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ReadInput = typeof ReadInput.Type
|
||||
|
||||
@@ -134,7 +132,6 @@ export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
@@ -158,7 +155,6 @@ export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget"
|
||||
real: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
}) {}
|
||||
|
||||
@@ -239,7 +235,6 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Effect.serviceOption(Global.Service)
|
||||
const references = yield* ProjectReference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
const ignored = ignore()
|
||||
@@ -251,21 +246,12 @@ export const layer = Layer.effect(
|
||||
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignorefile) ignored.add(ignorefile)
|
||||
const select = Effect.fnUntraced(function* (reference?: string) {
|
||||
if (!reference) return { directory: location.directory, root }
|
||||
const resolved = yield* references.get(reference)
|
||||
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
||||
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
||||
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
||||
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
||||
})
|
||||
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
|
||||
const resolve = Effect.fnUntraced(function* (input?: string) {
|
||||
const managed = path.join(
|
||||
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
|
||||
ToolOutputStore.MANAGED_DIRECTORY,
|
||||
)
|
||||
if (input && path.isAbsolute(input)) {
|
||||
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
|
||||
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
|
||||
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
|
||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
||||
@@ -274,9 +260,9 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.die(new Error("Path escapes managed tool output"))
|
||||
return { absolute: input, real, directory: managed, root: managedRoot }
|
||||
}
|
||||
const selected = yield* select(reference)
|
||||
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||
if (!FSUtil.contains(selected.directory, absolute))
|
||||
const selected = { directory: location.directory, root }
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
@@ -335,24 +321,24 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new ReadPath({
|
||||
type,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
})
|
||||
})
|
||||
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return {
|
||||
real: target.real,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
}
|
||||
})
|
||||
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
|
||||
@@ -510,25 +496,24 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
|
||||
const directory = yield* resolve(input.path, input.reference)
|
||||
const directory = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
|
||||
return new ListTarget({
|
||||
...directory,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
resource: relative,
|
||||
})
|
||||
})
|
||||
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new RootTarget({
|
||||
...target,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
reference: input.reference,
|
||||
resource: relative,
|
||||
type,
|
||||
})
|
||||
})
|
||||
@@ -644,5 +629,4 @@ export const layer = Layer.effect(
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provideMerge(ProjectReference.locationLayer),
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@ export const Flag = {
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"),
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationSearch } from "./location-search"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { Reference } from "./reference"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
@@ -52,7 +52,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
ProjectReference.locationLayer,
|
||||
Reference.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
|
||||
@@ -9,8 +9,7 @@ import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
|
||||
/**
|
||||
* Location-scoped raw search substrate. Search authority is selected only by
|
||||
* FileSystem, preserving Location-relative paths and named read
|
||||
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
|
||||
* share the same bounded filesystem behavior.
|
||||
*
|
||||
@@ -106,7 +105,7 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
path: RelativePath.make(relative),
|
||||
canonical,
|
||||
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
|
||||
resource: relative,
|
||||
mtime: info.mtime.pipe(
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
@@ -25,6 +26,7 @@ import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Reference } from "../reference"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
@@ -42,6 +44,7 @@ type Plugin = {
|
||||
| Config.Service
|
||||
| ModelsDev.Service
|
||||
| SkillV2.Service
|
||||
| Reference.Service
|
||||
>
|
||||
}
|
||||
|
||||
@@ -67,6 +70,7 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
@@ -85,6 +89,7 @@ export const layer = Layer.effect(
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, references),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
@@ -104,6 +109,7 @@ export const layer = Layer.effect(
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
yield* add(ConfigReferencePlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
@@ -124,4 +130,5 @@ export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
export * as ProjectReference from "./project-reference"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
export type Resolved =
|
||||
| { readonly name: string; readonly kind: "local"; readonly path: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "git"
|
||||
readonly repository: string
|
||||
readonly reference: Repository.RemoteReference
|
||||
readonly path: string
|
||||
readonly branch?: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
|
||||
|
||||
type Valid = Exclude<Resolved, { kind: "invalid" }>
|
||||
|
||||
export type Mention =
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "reference"
|
||||
readonly reference: Valid
|
||||
readonly target?: string
|
||||
readonly path: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "missing"
|
||||
readonly target: string
|
||||
readonly path: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
|
||||
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
|
||||
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
|
||||
|
||||
type Materializer = {
|
||||
readonly name: string
|
||||
readonly repository: string
|
||||
readonly path: string
|
||||
readonly run: Effect.Effect<void, RepositoryCache.Error>
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
|
||||
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const references = resolveAll({
|
||||
references: ConfigReference.normalize(
|
||||
Object.assign(
|
||||
{},
|
||||
...(yield* config.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.map((document) => document.info.references ?? {}),
|
||||
),
|
||||
),
|
||||
directory: location.project.directory,
|
||||
home: global.home,
|
||||
repos: global.repos,
|
||||
})
|
||||
const materializers = yield* Effect.forEach(
|
||||
uniqueGitReferences(references),
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
return {
|
||||
name: reference.name,
|
||||
repository: reference.repository,
|
||||
path: reference.path,
|
||||
run: yield* Effect.cached(
|
||||
cache
|
||||
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
|
||||
.pipe(Effect.asVoid),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(
|
||||
materializers,
|
||||
(materializer) =>
|
||||
materializer.run.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize project reference", {
|
||||
name: materializer.name,
|
||||
repository: materializer.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4, discard: true },
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
|
||||
const normalized = normalizePath(target)
|
||||
if (!normalized)
|
||||
return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
|
||||
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list: Effect.fn("ProjectReference.list")(function* () {
|
||||
return references
|
||||
}),
|
||||
get: Effect.fn("ProjectReference.get")(function* (name: string) {
|
||||
return references.find((reference) => reference.name === name)
|
||||
}),
|
||||
ensurePath,
|
||||
containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
|
||||
const normalized = normalizePath(target)
|
||||
return normalized
|
||||
? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
|
||||
: false
|
||||
}),
|
||||
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
|
||||
const [name, ...rest] = value.split("/")
|
||||
const target = rest.length ? rest.join("/") : undefined
|
||||
const reference = references.find((reference) => reference.name === name)
|
||||
if (!reference) return
|
||||
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
|
||||
if (reference.kind === "git") yield* ensurePath(reference.path)
|
||||
if (!target) return { name, kind: "reference", reference, path: reference.path }
|
||||
|
||||
const resolved = path.resolve(reference.path, target)
|
||||
if (!FSUtil.contains(reference.path, resolved))
|
||||
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
|
||||
if (!(yield* fs.existsSafe(resolved)))
|
||||
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
|
||||
return { name, kind: "reference", reference, target, path: resolved }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
const inert: Interface = {
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
}
|
||||
|
||||
export function resolveAll(input: {
|
||||
references: ConfigReference.NormalizedInfo
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]): Resolved => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
return {
|
||||
name,
|
||||
kind: "invalid",
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolve(input: {
|
||||
name: string
|
||||
reference: ConfigReference.NormalizedEntry
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}): Resolved {
|
||||
if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
|
||||
if (input.reference.kind === "local") {
|
||||
return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
|
||||
}
|
||||
const reference = Repository.parse(input.reference.repository)
|
||||
if (!reference || !Repository.isRemote(reference)) {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.reference.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.reference.repository,
|
||||
reference,
|
||||
path: Repository.cachePath(input.repos, reference),
|
||||
branch: input.reference.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
|
||||
function uniqueGitReferences(references: Resolved[]) {
|
||||
const seen = new Set<string>()
|
||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git" || seen.has(reference.path)) return false
|
||||
seen.add(reference.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePath(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function contains(parent: string, child: string) {
|
||||
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export * as Reference from "./reference"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft } from "immer"
|
||||
import { Global } from "./global"
|
||||
import { EventV2 } from "./event"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||
name: Schema.String,
|
||||
path: AbsolutePath,
|
||||
source: Schema.suspend(() => Source),
|
||||
}) {}
|
||||
|
||||
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
|
||||
type: Schema.Literal("local"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
|
||||
type: Schema.Literal("git"),
|
||||
repository: Schema.String,
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Source>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Reference") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const events = yield* EventV2.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ sources: new Map() }),
|
||||
editor: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, castDraft(source)),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (editor) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
const seen = new Map<string, string | undefined>()
|
||||
for (const [name, source] of editor.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(name, new Info({ name, path: source.path, source }))
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const target = Repository.cachePath(global.repos, repository)
|
||||
if (seen.has(target) && seen.get(target) !== source.branch) continue
|
||||
seen.set(target, source.branch)
|
||||
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
@@ -349,6 +349,5 @@ const toMessage = (input: Admitted) =>
|
||||
text: input.prompt.text,
|
||||
files: input.prompt.files,
|
||||
agents: input.prompt.agents,
|
||||
references: input.prompt.references,
|
||||
time: { created: input.timeCreated },
|
||||
})
|
||||
|
||||
@@ -132,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
text: event.data.prompt.text,
|
||||
files: event.data.prompt.files,
|
||||
agents: event.data.prompt.agents,
|
||||
references: event.data.prompt.references,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ export class User extends Schema.Class<User>("Session.Message.User")({
|
||||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
references: Prompt.fields.references,
|
||||
type: Schema.Literal("user"),
|
||||
time: Schema.Struct({
|
||||
created: V2Schema.DateTimeUtcFromMillis,
|
||||
|
||||
@@ -29,32 +29,18 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literals(["local", "git", "invalid"]),
|
||||
uri: Schema.String.pipe(Schema.optional),
|
||||
repository: Schema.String.pipe(Schema.optional),
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
target: Schema.String.pipe(Schema.optional),
|
||||
targetUri: Schema.String.pipe(Schema.optional),
|
||||
problem: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
|
||||
}) {
|
||||
static readonly equivalence = Schema.toEquivalence(Prompt)
|
||||
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
|
||||
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
|
||||
return new Prompt({
|
||||
text: input.text,
|
||||
...(input.files === undefined ? {} : { files: input.files }),
|
||||
...(input.agents === undefined ? {} : { agents: input.agents }),
|
||||
...(input.references === undefined ? {} : { references: input.references }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
...(message.references?.length ? { references: message.references } : {}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
@@ -21,7 +20,7 @@ export const name = "edit"
|
||||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
|
||||
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
|
||||
@@ -100,7 +99,7 @@ export const layer = Layer.effectDiscard(
|
||||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
|
||||
@@ -15,9 +15,6 @@ export const Input = Schema.Struct({
|
||||
path: LocationSearch.FilesInput.fields.path.annotate({
|
||||
description: "Relative directory to search. Defaults to the active Location.",
|
||||
}),
|
||||
reference: LocationSearch.FilesInput.fields.reference.annotate({
|
||||
description: "Named project reference to search instead of the active Location",
|
||||
}),
|
||||
limit: LocationSearch.FilesInput.fields.limit.annotate({
|
||||
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
|
||||
}),
|
||||
@@ -41,8 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
|
||||
/**
|
||||
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and traversal.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
@@ -55,20 +50,19 @@ export const layer = Layer.effectDiscard(
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
|
||||
const root = yield* filesystem.resolveRoot({ path: input.path })
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
limit: input.limit,
|
||||
},
|
||||
|
||||
@@ -18,9 +18,6 @@ export const Input = Schema.Struct({
|
||||
path: LocationSearch.GrepInput.fields.path.annotate({
|
||||
description: "Relative file or directory to search. Defaults to the active Location.",
|
||||
}),
|
||||
reference: LocationSearch.GrepInput.fields.reference.annotate({
|
||||
description: "Named project reference to search instead of the active Location",
|
||||
}),
|
||||
include: LocationSearch.GrepInput.fields.include.annotate({
|
||||
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
|
||||
}),
|
||||
@@ -56,8 +53,6 @@ export const toModelOutput = (output: Output) => {
|
||||
/**
|
||||
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and ripgrep execution.
|
||||
*
|
||||
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
@@ -70,7 +65,7 @@ export const layer = Layer.effectDiscard(
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
input: Input,
|
||||
output: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
@@ -83,7 +78,6 @@ export const layer = Layer.effectDiscard(
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* Model-facing V2 file-write leaf. Relative paths resolve within the active
|
||||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||||
* absolute external paths retain mutation capability through a separate
|
||||
* external_directory approval before edit approval. Named project references
|
||||
* are read-oriented and deliberately are not accepted by mutation tools.
|
||||
* external_directory approval before edit approval.
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
@@ -21,7 +20,7 @@ export const name = "write"
|
||||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
@@ -55,7 +54,7 @@ export const layer = Layer.effectDiscard(
|
||||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
|
||||
@@ -7,25 +7,14 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const inertReferences = ProjectReference.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
|
||||
function provide(
|
||||
directory: string,
|
||||
references = inertReferences,
|
||||
filesystem = FSUtil.defaultLayer,
|
||||
data = Global.Path.data,
|
||||
) {
|
||||
@@ -36,7 +25,6 @@ function provide(
|
||||
filesystem,
|
||||
Ripgrep.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, references),
|
||||
Global.layerWith({ data }),
|
||||
),
|
||||
),
|
||||
@@ -69,7 +57,7 @@ describe("FileSystem", () => {
|
||||
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
|
||||
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
|
||||
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
|
||||
}).pipe(provide(worktree, FSUtil.defaultLayer, data))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -258,7 +246,7 @@ describe("FileSystem", () => {
|
||||
}
|
||||
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
|
||||
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -344,7 +332,7 @@ describe("FileSystem", () => {
|
||||
next: 3,
|
||||
})
|
||||
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -380,7 +368,7 @@ describe("FileSystem", () => {
|
||||
|
||||
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
|
||||
expect(maximum).toBe(16)
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -402,9 +390,8 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
test("rejects empty list aliases and page limits over 2000", () => {
|
||||
test("rejects page limits over 2000", () => {
|
||||
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
|
||||
expect(() => decode({ reference: "" })).toThrow()
|
||||
expect(() => decode({ limit: 2_001 })).toThrow()
|
||||
})
|
||||
|
||||
@@ -461,125 +448,4 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reads and lists paths relative to a local project reference", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
|
||||
type: "text",
|
||||
content: "docs",
|
||||
})
|
||||
expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("materializes Git references before filesystem access", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
const ensured: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
expect(
|
||||
yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
|
||||
).toMatchObject({ content: "docs" })
|
||||
expect(ensured).toEqual([docs])
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
references(
|
||||
{
|
||||
sdk: {
|
||||
name: "sdk",
|
||||
kind: "git",
|
||||
repository: "owner/repo",
|
||||
reference: Repository.parseRemote("owner/repo"),
|
||||
path: docs,
|
||||
},
|
||||
},
|
||||
(target) => Effect.sync(() => ensured.push(target ?? "")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects unknown, invalid, and escaping project reference paths", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(docs))
|
||||
const service = yield* FileSystem.Service
|
||||
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
references({
|
||||
docs: { name: "docs", kind: "local", path: docs },
|
||||
invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects aliases when project references are disabled", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
|
||||
true,
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects symlink escapes from project references", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
const outside = path.join(directory, "outside.txt")
|
||||
return Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(outside, "outside")
|
||||
await fs.symlink(outside, path.join(docs, "link.txt"))
|
||||
})
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* (yield* FileSystem.Service)
|
||||
.read({ reference: "docs", path: RelativePath.make("link.txt") })
|
||||
.pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function references(
|
||||
entries: Record<string, ProjectReference.Resolved>,
|
||||
ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
|
||||
) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Global } from "../src/global"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Npm } from "../src/npm"
|
||||
import { Project } from "../src/project"
|
||||
import { ProjectReference } from "../src/project-reference"
|
||||
import { Reference } from "../src/reference"
|
||||
import { LocationSearch } from "../src/location-search"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
@@ -71,7 +71,7 @@ describe("LocationServiceMap", () => {
|
||||
const update = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((boot) => boot.wait())
|
||||
yield* ProjectReference.Service
|
||||
yield* Reference.Service
|
||||
yield* LocationSearch.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
test("keeps project references outside the mutation input API", () => {
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -16,15 +15,12 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const inertReferences = references({})
|
||||
|
||||
function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
|
||||
function provide(directory: string, data = Global.Path.data) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, projectReferences),
|
||||
Global.layerWith({ data }),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
@@ -56,7 +52,7 @@ describe("LocationSearch", () => {
|
||||
const search = yield* LocationSearch.Service
|
||||
const result = yield* search.grep({ pattern: "FAIL", path: output })
|
||||
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
|
||||
}).pipe(provide(directory, inertReferences, data))
|
||||
}).pipe(provide(directory, data))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -83,7 +79,7 @@ describe("LocationSearch", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("searches files under a relative subdirectory and named local reference", () =>
|
||||
it.live("searches files under a relative subdirectory", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
@@ -98,11 +94,7 @@ describe("LocationSearch", () => {
|
||||
expect(
|
||||
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
|
||||
).toEqual([RelativePath.make("src/active.ts")])
|
||||
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
|
||||
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
|
||||
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
|
||||
])
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -264,13 +256,3 @@ describe("LocationSearch", () => {
|
||||
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function references(entries: Record<string, ProjectReference.Resolved>) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigReference } from "@opencode-ai/core/config/reference"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("ProjectReference", () => {
|
||||
it.live("uses the broad experimental flag unless references are explicitly configured", () =>
|
||||
withEnv(
|
||||
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined },
|
||||
Effect.sync(() => {
|
||||
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap(() =>
|
||||
withEnv(
|
||||
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" },
|
||||
Effect.sync(() => {
|
||||
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes aliases and resolves relative local paths from the project root", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "packages", "app")
|
||||
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
|
||||
|
||||
const references = ProjectReference.resolveAll({
|
||||
references: ConfigReference.normalize({
|
||||
docs: { path: "./docs" },
|
||||
home: "~/notes",
|
||||
sdk: { repository: "owner/repo", branch: "main" },
|
||||
shorthand: "owner/other",
|
||||
invalid: "not-a-repo",
|
||||
"bad/name": "owner/repo",
|
||||
}),
|
||||
directory: project,
|
||||
home: path.join(tmp.path, "home"),
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
})
|
||||
|
||||
expect(references).toMatchObject([
|
||||
{ name: "docs", kind: "local", path: path.join(project, "docs") },
|
||||
{ name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") },
|
||||
{ name: "sdk", kind: "git", branch: "main" },
|
||||
{ name: "shorthand", kind: "git" },
|
||||
{ name: "invalid", kind: "invalid", repository: "not-a-repo" },
|
||||
{ name: "bad/name", kind: "invalid" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("marks same-cache references with different branches invalid", () =>
|
||||
Effect.sync(() => {
|
||||
const references = ProjectReference.resolveAll({
|
||||
references: ConfigReference.normalize({
|
||||
main: { repository: "owner/repo", branch: "main" },
|
||||
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
||||
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
||||
}),
|
||||
directory: "/project",
|
||||
home: "/home",
|
||||
repos: "/repos",
|
||||
})
|
||||
|
||||
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
||||
expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("merges config aliases and exposes mention and managed-path operations", () =>
|
||||
withoutReferences(
|
||||
withTmp((tmp) => {
|
||||
const calls: RepositoryCache.EnsureInput[] = []
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "packages", "app")
|
||||
const docs = path.join(project, "docs")
|
||||
const repos = path.join(tmp.path, "repos")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(nested, { recursive: true })
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
|
||||
yield* withReferences(
|
||||
Effect.gen(function* () {
|
||||
const references = yield* ProjectReference.Service
|
||||
const git = path.join(repos, "github.com", "owner", "repo")
|
||||
|
||||
expect(yield* references.list()).toMatchObject([
|
||||
{ name: "docs", kind: "local", path: docs },
|
||||
{ name: "sdk", kind: "git", path: git },
|
||||
])
|
||||
expect(yield* references.resolveMention("docs/README.md")).toMatchObject({
|
||||
name: "docs",
|
||||
kind: "reference",
|
||||
target: "README.md",
|
||||
path: path.join(docs, "README.md"),
|
||||
})
|
||||
expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({
|
||||
name: "docs",
|
||||
kind: "missing",
|
||||
})
|
||||
expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({
|
||||
name: "docs",
|
||||
kind: "invalid",
|
||||
})
|
||||
expect(yield* references.resolveMention("unknown")).toBeUndefined()
|
||||
expect(yield* references.resolveMention("sdk")).toMatchObject({
|
||||
name: "sdk",
|
||||
kind: "reference",
|
||||
path: git,
|
||||
})
|
||||
expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true)
|
||||
expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false)
|
||||
yield* references.ensurePath()
|
||||
expect(calls).toHaveLength(1)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: nested,
|
||||
project,
|
||||
repos,
|
||||
documents: [
|
||||
document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
|
||||
document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }),
|
||||
],
|
||||
ensure: (input) => Effect.sync(() => result(repos, calls, input)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("is inert while the runtime flag is disabled", () =>
|
||||
withoutReferences(
|
||||
withTmp((tmp) => {
|
||||
const calls: RepositoryCache.EnsureInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const references = yield* ProjectReference.Service
|
||||
expect(yield* references.list()).toEqual([])
|
||||
expect(yield* references.get("sdk")).toBeUndefined()
|
||||
expect(yield* references.resolveMention("sdk")).toBeUndefined()
|
||||
expect(
|
||||
yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")),
|
||||
).toBe(false)
|
||||
yield* references.ensurePath()
|
||||
expect(calls).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: tmp.path,
|
||||
project: tmp.path,
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
documents: [document({ sdk: "owner/repo" })],
|
||||
ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("starts Git materialization in the background without blocking the location layer", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
yield* withReferences(
|
||||
Effect.gen(function* () {
|
||||
expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1)
|
||||
yield* Deferred.await(started).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 second",
|
||||
orElse: () => Effect.die(new Error("refresh did not start")),
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: tmp.path,
|
||||
project: tmp.path,
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
documents: [document({ sdk: "owner/repo" })],
|
||||
ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function document(references: ConfigReference.Info) {
|
||||
return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
|
||||
}
|
||||
|
||||
function result(
|
||||
repos: string,
|
||||
calls: RepositoryCache.EnsureInput[],
|
||||
input: RepositoryCache.EnsureInput,
|
||||
): RepositoryCache.Result {
|
||||
calls.push(input)
|
||||
return {
|
||||
repository: input.reference.label,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath: Repository.cachePath(repos, input.reference),
|
||||
status: "cached",
|
||||
branch: input.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function testLayer(input: {
|
||||
directory: string
|
||||
project: string
|
||||
repos: string
|
||||
documents: Config.Document[]
|
||||
ensure: RepositoryCache.Interface["ensure"]
|
||||
}) {
|
||||
return ProjectReference.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(input.directory) },
|
||||
{ projectDirectory: AbsolutePath.make(input.project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })),
|
||||
Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
body,
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||
return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body)
|
||||
}
|
||||
|
||||
function withoutReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||
return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body)
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(env: Record<string, string | undefined>, body: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
() => body,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const scope = yield* Scope.make()
|
||||
const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const path = AbsolutePath.make("/docs")
|
||||
yield* update((editor) => editor.add("docs", new Reference.LocalSource({ type: "local", path })))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
new Reference.Info({ name: "docs", path, source: new Reference.LocalSource({ type: "local", path }) }),
|
||||
])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* references.list()).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(Reference.layer),
|
||||
Effect.provide(cache),
|
||||
Effect.provide(EventV2.defaultLayer),
|
||||
Effect.provide(Global.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives Git paths without exposing cache operations", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const update = yield* references.transform()
|
||||
const repository = Repository.parseRemote("owner/repo")
|
||||
const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" })
|
||||
yield* update((editor) => editor.add("sdk", source))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
new Reference.Info({ name: "sdk", path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), source }),
|
||||
])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(Reference.layer),
|
||||
Effect.provide(cache),
|
||||
Effect.provide(EventV2.defaultLayer),
|
||||
Effect.provide(Global.defaultLayer),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
@@ -17,7 +17,6 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.
|
||||
describe("toLLMMessages", () => {
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.AgentSwitched({
|
||||
@@ -44,7 +43,6 @@ describe("toLLMMessages", () => {
|
||||
text: "Inspect this image",
|
||||
files: [file],
|
||||
agents: [new AgentAttachment({ name: "build" })],
|
||||
references: [reference],
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Synthetic({
|
||||
@@ -84,7 +82,7 @@ describe("toLLMMessages", () => {
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||
metadata: { agents: [{ name: "build" }] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(2).map((message) => message.content)).toEqual([
|
||||
|
||||
@@ -43,12 +43,10 @@ const filesystem = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
resolutions.push(input)
|
||||
const relative = input.path ?? RelativePath.make(".")
|
||||
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
|
||||
return new FileSystem.RootTarget({
|
||||
real: `/project/${relative}`,
|
||||
root: "/project",
|
||||
resource,
|
||||
reference: input.reference,
|
||||
resource: relative,
|
||||
type: "directory",
|
||||
})
|
||||
}),
|
||||
@@ -122,10 +120,10 @@ describe("GlobTool", () => {
|
||||
action: "glob",
|
||||
resources: ["**/*.ts"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
|
||||
metadata: { root: "src", path: "src", limit: 12 },
|
||||
},
|
||||
])
|
||||
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
|
||||
expect(resolutions).toEqual([{ path: RelativePath.make("src") }])
|
||||
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
|
||||
}),
|
||||
)
|
||||
@@ -169,39 +167,6 @@ describe("GlobTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("searches named references with root and reference metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("guide.md"),
|
||||
canonical: "/project/docs/guide.md",
|
||||
resource: "docs:guide.md",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
|
||||
type: "text",
|
||||
value: "docs:guide.md",
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
resources: ["*.md"],
|
||||
save: ["*"],
|
||||
metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("formats bounded and partial results without discarding structured output", () =>
|
||||
Effect.sync(() => {
|
||||
const output = new LocationSearch.FilesResult({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgr
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
@@ -39,8 +38,7 @@ const filesystem = Layer.succeed(
|
||||
new FileSystem.RootTarget({
|
||||
real: `/project/${input.path ?? "."}`,
|
||||
root: "/project",
|
||||
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
|
||||
reference: input.reference,
|
||||
resource: input.path ?? ".",
|
||||
type: "directory",
|
||||
}),
|
||||
),
|
||||
@@ -115,23 +113,12 @@ const reset = () => {
|
||||
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
function references(entries: Record<string, ProjectReference.Resolved>) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
||||
function provideLive(directory: string, projectReferences = references({})) {
|
||||
function provideLive(directory: string) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, projectReferences),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
const search = LocationSearch.layer.pipe(
|
||||
@@ -170,29 +157,13 @@ describe("GrepTool", () => {
|
||||
action: "grep",
|
||||
resources: ["needle"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
|
||||
metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
|
||||
yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
|
||||
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["guide"],
|
||||
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
|
||||
})
|
||||
expect(searches).toEqual([
|
||||
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not search when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
@@ -248,7 +219,7 @@ describe("GrepTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
runtimeIt.live("greps active Location and named-reference files with include globs", () =>
|
||||
runtimeIt.live("greps active Location files with include globs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -259,23 +230,15 @@ describe("GrepTool", () => {
|
||||
reset()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
|
||||
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
|
||||
await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
|
||||
})
|
||||
expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
|
||||
)
|
||||
}).pipe(Effect.provide(provideLive(tmp.path)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ const filesystem = Layer.succeed(
|
||||
? Effect.succeed(
|
||||
new FileSystem.ReadPath({
|
||||
type: resolvedType,
|
||||
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
|
||||
resource: input.path,
|
||||
}),
|
||||
)
|
||||
: Effect.die(resolveFailure),
|
||||
@@ -457,20 +457,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes project references with their canonical identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
|
||||
})
|
||||
|
||||
expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unexpected resolution defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -27,8 +27,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "bun ./script/build.ts",
|
||||
"verify:package": "bun ./script/verify-package.ts"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# Bun shell migration plan
|
||||
|
||||
Practical phased replacement of Bun `$` calls.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
|
||||
|
||||
Keep behavior stable while improving safety, testability, and observability.
|
||||
|
||||
Current baseline from audit:
|
||||
|
||||
- 143 runtime command invocations across 17 files
|
||||
- 84 are git commands
|
||||
- Largest hotspots:
|
||||
- `src/cli/cmd/github.ts` (33)
|
||||
- `src/worktree/index.ts` (22)
|
||||
- `src/lsp/server.ts` (21)
|
||||
- `src/installation/index.ts` (20)
|
||||
- `src/snapshot/index.ts` (18)
|
||||
|
||||
## Decisions
|
||||
|
||||
- Extend `src/util/process.ts` (do not create a separate exec module).
|
||||
- Proceed with phased migration for both git and non-git paths.
|
||||
- Keep plugin `$` compatibility in 1.x and remove in 2.0.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not remove plugin `$` compatibility in this effort.
|
||||
- Do not redesign command semantics beyond what is needed to preserve behavior.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep migration phased, not big-bang.
|
||||
- Minimize behavioral drift.
|
||||
- Keep these explicit shell-only exceptions:
|
||||
- `src/session/prompt.ts` raw command execution
|
||||
- worktree start scripts in `src/worktree/index.ts`
|
||||
|
||||
## Process API proposal (`src/util/process.ts`)
|
||||
|
||||
Add higher-level wrappers on top of current spawn support.
|
||||
|
||||
Core methods:
|
||||
|
||||
- `Process.run(cmd, opts)`
|
||||
- `Process.text(cmd, opts)`
|
||||
- `Process.lines(cmd, opts)`
|
||||
- `Process.status(cmd, opts)`
|
||||
- `Process.shell(command, opts)` for intentional shell execution
|
||||
|
||||
Git helpers:
|
||||
|
||||
- `Process.git(args, opts)`
|
||||
- `Process.gitText(args, opts)`
|
||||
|
||||
Shared options:
|
||||
|
||||
- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
|
||||
- `allowFailure` / non-throw mode
|
||||
- optional redaction + trace metadata
|
||||
|
||||
Standard result shape:
|
||||
|
||||
- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
|
||||
- helpers like `text()` and `arrayBuffer()` where useful
|
||||
|
||||
## Phased rollout
|
||||
|
||||
### Phase 0: Foundation
|
||||
|
||||
- Implement Process wrappers in `src/util/process.ts`.
|
||||
- Refactor `src/util/git.ts` to use Process only.
|
||||
- Add tests for exit handling, timeout, abort, and output capture.
|
||||
|
||||
### Phase 1: High-impact hotspots
|
||||
|
||||
Migrate these first:
|
||||
|
||||
- `src/cli/cmd/github.ts`
|
||||
- `src/worktree/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/installation/index.ts`
|
||||
- `src/snapshot/index.ts`
|
||||
|
||||
Within each file, migrate git paths first where applicable.
|
||||
|
||||
### Phase 2: Remaining git-heavy files
|
||||
|
||||
Migrate git-centric call sites to `Process.git*` helpers:
|
||||
|
||||
- `../core/src/filesystem.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `../core/src/filesystem/watcher.ts`
|
||||
- `src/storage/storage.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
|
||||
### Phase 3: Remaining non-git files
|
||||
|
||||
Migrate residual non-git usages:
|
||||
|
||||
- `src/cli/cmd/tui/util/clipboard.ts`
|
||||
- `src/util/archive.ts`
|
||||
- `../core/src/filesystem/ripgrep.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/cli/cmd/uninstall.ts`
|
||||
|
||||
### Phase 4: Stabilize
|
||||
|
||||
- Remove dead wrappers and one-off patterns.
|
||||
- Keep plugin `$` compatibility isolated and documented as temporary.
|
||||
- Create linked 2.0 task for plugin `$` removal.
|
||||
|
||||
## Validation strategy
|
||||
|
||||
- Unit tests for new `Process` methods and options.
|
||||
- Integration tests on hotspot modules.
|
||||
- Smoke tests for install, snapshot, worktree, and GitHub flows.
|
||||
- Regression checks for output parsing behavior.
|
||||
|
||||
## Risk mitigation
|
||||
|
||||
- File-by-file PRs with small diffs.
|
||||
- Preserve behavior first, simplify second.
|
||||
- Keep shell-only exceptions explicit and documented.
|
||||
- Add consistent error shaping and logging at Process layer.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
|
||||
- approved shell-only exceptions
|
||||
- temporary plugin compatibility path (1.x)
|
||||
- Git paths use `Process.git*` consistently.
|
||||
- CI and targeted smoke tests pass.
|
||||
- 2.0 issue exists for plugin `$` removal.
|
||||
@@ -7,8 +7,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
|
||||
"bench:test": "bun run script/bench-test-suite.ts",
|
||||
"profile:test": "bun run script/profile-test-files.ts",
|
||||
|
||||
@@ -213,12 +213,12 @@ export const TuiThreadCommand = cmd({
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
process.exit(0)
|
||||
} finally {
|
||||
try {
|
||||
unguard?.()
|
||||
} catch {}
|
||||
}
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
// scratch
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
export * as ConfigReference from "./reference"
|
||||
|
||||
import { ConfigReferenceV1 } from "@opencode-ai/core/v1/config/reference"
|
||||
|
||||
export type NormalizedEntry =
|
||||
| {
|
||||
kind: "local"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
kind: "git"
|
||||
repository: string
|
||||
branch?: string
|
||||
}
|
||||
| {
|
||||
kind: "invalid"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NormalizedInfo = Record<string, NormalizedEntry>
|
||||
|
||||
export function validateAlias(name: string) {
|
||||
if (name.length === 0) return "Reference alias must not be empty"
|
||||
if (/[\/\s`,]/.test(name)) {
|
||||
return "Reference alias must not contain /, whitespace, comma, or backtick"
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEntry(entry: ConfigReferenceV1.Entry): NormalizedEntry {
|
||||
if (typeof entry === "string") {
|
||||
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) {
|
||||
return { kind: "local", path: entry }
|
||||
}
|
||||
return { kind: "git", repository: entry }
|
||||
}
|
||||
|
||||
if ("path" in entry) return { kind: "local", path: entry.path }
|
||||
return { kind: "git", repository: entry.repository, branch: entry.branch }
|
||||
}
|
||||
|
||||
export function normalize(info: ConfigReferenceV1.Info): NormalizedInfo {
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([name, entry]) => {
|
||||
const aliasError = validateAlias(name)
|
||||
return [name, aliasError ? { kind: "invalid" as const, message: aliasError } : normalizeEntry(entry)] as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,6 @@ import { Format } from "@/format"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Installation } from "@/installation"
|
||||
@@ -98,7 +97,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Format.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Installation.defaultLayer,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
|
||||
import type { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
@@ -7,6 +6,5 @@ export type InternalTuiPlugin = BuiltinTuiPlugin
|
||||
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
|
||||
return createBuiltinPlugins({
|
||||
experimentalEventSystem: flags.experimentalEventSystem,
|
||||
experimentalSessionSwitcher: Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { Service } from "./bootstrap-service"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export { Service } from "./bootstrap-service"
|
||||
export type { Interface } from "./bootstrap-service"
|
||||
@@ -27,7 +26,6 @@ export const layer = Layer.effect(
|
||||
const lsp = yield* LSP.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const project = yield* Project.Service
|
||||
const reference = yield* Reference.Service
|
||||
const search = yield* Search.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
@@ -52,7 +50,7 @@ export const layer = Layer.effect(
|
||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||
// its per-instance state scope. We just await materialization here.
|
||||
yield* Effect.forEach(
|
||||
[reference, lsp, shareNext, format, vcs, snapshot, project],
|
||||
[lsp, shareNext, format, vcs, snapshot, project],
|
||||
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
@@ -69,7 +67,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
LSP.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Search.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import path from "path"
|
||||
import { Effect, Context, Layer, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigReference } from "@/config/reference"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { parseRepositoryReference, repositoryCachePath, type RemoteReference } from "@/util/repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
export type Resolved =
|
||||
| {
|
||||
name: string
|
||||
kind: "local"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "git"
|
||||
repository: string
|
||||
reference: RemoteReference
|
||||
path: string
|
||||
branch?: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "invalid"
|
||||
repository?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
references: Resolved[]
|
||||
materializeAll: Effect.Effect<void>
|
||||
materializeByPath: Materializer[]
|
||||
}
|
||||
|
||||
type Materializer = { path: string; run: Effect.Effect<void> }
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly ensure: (target?: string) => Effect.Effect<void>
|
||||
readonly contains: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
|
||||
|
||||
export function referencePath(input: { directory: string; worktree: string; value: string }) {
|
||||
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
|
||||
return path.isAbsolute(input.value)
|
||||
? input.value
|
||||
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
|
||||
}
|
||||
|
||||
function resolveGit(
|
||||
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
|
||||
): Resolved {
|
||||
const parsed = parseRepositoryReference(input.repository)
|
||||
if (!parsed || parsed.protocol === "file:") {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.repository,
|
||||
reference: parsed,
|
||||
path: repositoryCachePath(parsed),
|
||||
...("branch" in input ? { branch: input.branch } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function branchLabel(branch: string | undefined) {
|
||||
return branch ?? "default branch"
|
||||
}
|
||||
|
||||
function normalizedTarget(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function containsReferencePath(referencePath: string, target: string) {
|
||||
return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target)
|
||||
}
|
||||
|
||||
function uniqueGitReferences(references: Resolved[]) {
|
||||
const seenPath = new Set<string>()
|
||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git") return false
|
||||
if (seenPath.has(reference.path)) return false
|
||||
seenPath.add(reference.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function materializeReference(cache: RepositoryCache.Interface, reference: Extract<Resolved, { kind: "git" }>) {
|
||||
return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(
|
||||
Effect.asVoid,
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const materializers = Effect.fn("Reference.materializers")(function* (
|
||||
cache: RepositoryCache.Interface,
|
||||
references: Resolved[],
|
||||
) {
|
||||
return yield* Effect.forEach(
|
||||
uniqueGitReferences(references),
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
return { path: reference.path, run: yield* Effect.cached(materializeReference(cache, reference)) }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) {
|
||||
if (!input.flags.experimentalReferences) return Effect.void
|
||||
return Effect.forEach(
|
||||
input.materializers,
|
||||
Effect.fnUntraced(function* (item) {
|
||||
yield* item.run
|
||||
}),
|
||||
{ concurrency: 4, discard: true },
|
||||
)
|
||||
}
|
||||
|
||||
function materializeByPath(materializers: Materializer[], target: string) {
|
||||
return materializers.find((item) => containsReferencePath(item.path, target))?.run ?? Effect.void
|
||||
}
|
||||
|
||||
function containsGitReferencePath(references: Resolved[], target: string) {
|
||||
return references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, target))
|
||||
}
|
||||
|
||||
export function resolve(input: {
|
||||
name: string
|
||||
reference: ConfigReference.NormalizedEntry
|
||||
directory: string
|
||||
worktree: string
|
||||
}): Resolved {
|
||||
if (input.reference.kind === "invalid") {
|
||||
return { name: input.name, kind: "invalid", message: input.reference.message }
|
||||
}
|
||||
if (input.reference.kind === "local") {
|
||||
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
|
||||
}
|
||||
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
|
||||
}
|
||||
|
||||
export function resolveAll(input: { references: ConfigReference.NormalizedInfo; directory: string; worktree: string }) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]) => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
|
||||
return {
|
||||
name,
|
||||
kind: "invalid" as const,
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Reference.state")(function* (ctx) {
|
||||
const cfg = yield* config.get()
|
||||
const references = resolveAll({
|
||||
references: ConfigReference.normalize(cfg.reference ?? {}),
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
})
|
||||
const materializeByPath = yield* materializers(cache, references)
|
||||
const materializeAllCached = yield* Effect.cached(materializeAll({ flags, materializers: materializeByPath }))
|
||||
|
||||
return { references, materializeAll: materializeAllCached, materializeByPath }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Reference.init")(function* () {
|
||||
if (!flags.experimentalReferences) return
|
||||
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
}),
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.references)
|
||||
}),
|
||||
get: Effect.fn("Reference.get")(function* (name: string) {
|
||||
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
|
||||
}),
|
||||
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
||||
if (!flags.experimentalReferences) return
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
|
||||
}),
|
||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||
if (!flags.experimentalReferences) return false
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return false
|
||||
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Reference from "./reference"
|
||||
@@ -1,320 +0,0 @@
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Git } from "@/git"
|
||||
import {
|
||||
repositoryCachePath,
|
||||
sameRepositoryReference,
|
||||
parseRepositoryReference,
|
||||
parseRemoteRepositoryReference,
|
||||
validateRepositoryBranch,
|
||||
InvalidRepositoryBranchError,
|
||||
InvalidRepositoryReferenceError,
|
||||
UnsupportedLocalRepositoryError,
|
||||
type RemoteReference,
|
||||
} from "@/util/repository"
|
||||
|
||||
export type Result = {
|
||||
repository: string
|
||||
host: string
|
||||
remote: string
|
||||
localPath: string
|
||||
status: "cached" | "cloned" | "refreshed"
|
||||
head?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export type EnsureInput = {
|
||||
reference: RemoteReference
|
||||
refresh?: boolean
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
|
||||
"RepositoryCacheInvalidRepositoryError",
|
||||
{
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
||||
"RepositoryCacheInvalidBranchError",
|
||||
{
|
||||
branch: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class CloneFailedError extends Schema.TaggedErrorClass<CloneFailedError>()("RepositoryCacheCloneFailedError", {
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FetchFailedError extends Schema.TaggedErrorClass<FetchFailedError>()("RepositoryCacheFetchFailedError", {
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CheckoutFailedError extends Schema.TaggedErrorClass<CheckoutFailedError>()(
|
||||
"RepositoryCacheCheckoutFailedError",
|
||||
{
|
||||
repository: Schema.String,
|
||||
branch: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResetFailedError extends Schema.TaggedErrorClass<ResetFailedError>()("RepositoryCacheResetFailedError", {
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class LockFailedError extends Schema.TaggedErrorClass<LockFailedError>()("RepositoryCacheLockFailedError", {
|
||||
localPath: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationError>()(
|
||||
"RepositoryCacheOperationError",
|
||||
{
|
||||
operation: Schema.String,
|
||||
path: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| InvalidRepositoryError
|
||||
| InvalidBranchError
|
||||
| CloneFailedError
|
||||
| FetchFailedError
|
||||
| CheckoutFailedError
|
||||
| ResetFailedError
|
||||
| LockFailedError
|
||||
| CacheOperationError
|
||||
|
||||
export interface Interface {
|
||||
ensure: (input: EnsureInput) => Effect.Effect<Result, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
|
||||
|
||||
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
|
||||
if (!input.reuse) return "cloned" as const
|
||||
if (input.branchMatches === false) return "refreshed" as const
|
||||
if (input.refresh) return "refreshed" as const
|
||||
return "cached" as const
|
||||
}
|
||||
|
||||
function resetTarget(input: {
|
||||
requestedBranch?: string
|
||||
remoteHead: { code: number; stdout: string }
|
||||
branch: { code: number; stdout: string }
|
||||
}) {
|
||||
if (input.requestedBranch) return `origin/${input.requestedBranch}`
|
||||
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
|
||||
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
|
||||
}
|
||||
if (input.branch.code === 0 && input.branch.stdout) {
|
||||
return `origin/${input.branch.stdout}`
|
||||
}
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof globalThis.Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export function isError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof InvalidRepositoryError ||
|
||||
error instanceof InvalidBranchError ||
|
||||
error instanceof CloneFailedError ||
|
||||
error instanceof FetchFailedError ||
|
||||
error instanceof CheckoutFailedError ||
|
||||
error instanceof ResetFailedError ||
|
||||
error instanceof LockFailedError ||
|
||||
error instanceof CacheOperationError
|
||||
)
|
||||
}
|
||||
|
||||
export const parseRemoteReference = Effect.fn("RepositoryCache.parseRemoteReference")(function* (repository: string) {
|
||||
try {
|
||||
return parseRemoteRepositoryReference(repository)
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRepositoryReferenceError || error instanceof UnsupportedLocalRepositoryError) {
|
||||
return yield* new InvalidRepositoryError({ repository: error.repository, message: error.message })
|
||||
}
|
||||
return yield* new InvalidRepositoryError({
|
||||
repository,
|
||||
message: errorMessage(error),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||
try {
|
||||
validateRepositoryBranch(branch)
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRepositoryBranchError) {
|
||||
return yield* new InvalidBranchError({ branch: error.branch, message: error.message })
|
||||
}
|
||||
return yield* new InvalidBranchError({ branch, message: errorMessage(error) })
|
||||
}
|
||||
})
|
||||
|
||||
const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
|
||||
input: EnsureInput,
|
||||
services: {
|
||||
fs: FSUtil.Interface
|
||||
git: Git.Interface
|
||||
},
|
||||
) {
|
||||
if (input.branch) yield* validateBranch(input.branch)
|
||||
|
||||
const repository = input.reference.label
|
||||
const remote = input.reference.remote
|
||||
const localPath = repositoryCachePath(input.reference)
|
||||
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.fail(new LockFailedError({ localPath, message: errorMessage(error) || `Failed to lock ${localPath}` })),
|
||||
),
|
||||
),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.fail(
|
||||
new CacheOperationError({
|
||||
operation: "ensure cache directory",
|
||||
path: localPath,
|
||||
message: errorMessage(error),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const exists = yield* services.fs.existsSafe(localPath)
|
||||
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
|
||||
const origin = hasGitDir
|
||||
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
|
||||
: undefined
|
||||
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
|
||||
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
|
||||
if (exists && !reuse) {
|
||||
yield* services.fs.remove(localPath, { recursive: true }).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.fail(
|
||||
new CacheOperationError({
|
||||
operation: "remove stale cache",
|
||||
path: localPath,
|
||||
message: errorMessage(error),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
|
||||
const status = statusForRepository({
|
||||
reuse,
|
||||
refresh: input.refresh,
|
||||
branchMatches: input.branch ? currentBranch === input.branch : undefined,
|
||||
})
|
||||
|
||||
if (status === "cloned") {
|
||||
const clone = yield* services.git.run(
|
||||
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
|
||||
{ cwd: path.dirname(localPath) },
|
||||
)
|
||||
if (clone.exitCode !== 0) {
|
||||
return yield* new CloneFailedError({
|
||||
repository,
|
||||
message: clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "refreshed") {
|
||||
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
|
||||
if (fetch.exitCode !== 0) {
|
||||
return yield* new FetchFailedError({
|
||||
repository,
|
||||
message: fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.branch) {
|
||||
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
|
||||
cwd: localPath,
|
||||
})
|
||||
if (checkout.exitCode !== 0) {
|
||||
return yield* new CheckoutFailedError({
|
||||
repository,
|
||||
branch: input.branch,
|
||||
message:
|
||||
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
|
||||
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
|
||||
const target = resetTarget({
|
||||
requestedBranch: input.branch,
|
||||
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
|
||||
branch: { code: branch.exitCode, stdout: branch.text().trim() },
|
||||
})
|
||||
|
||||
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
|
||||
if (reset.exitCode !== 0) {
|
||||
return yield* new ResetFailedError({
|
||||
repository,
|
||||
message: reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
|
||||
const branch = yield* services.git.branch(localPath)
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
return {
|
||||
repository,
|
||||
host: input.reference.host,
|
||||
remote,
|
||||
localPath,
|
||||
status,
|
||||
head: headText,
|
||||
branch,
|
||||
} satisfies Result
|
||||
}),
|
||||
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
return Service.of({
|
||||
ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
|
||||
return yield* ensureWithServices(input, { fs, git })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
export * as RepositoryCache from "./repository-cache"
|
||||
@@ -17,7 +17,6 @@ import { ProjectCopyApi } from "./groups/project-copy"
|
||||
import { ProviderApi } from "./groups/provider"
|
||||
import { PtyApi, PtyConnectApi } from "./groups/pty"
|
||||
import { QuestionApi } from "./groups/question"
|
||||
import { ReferenceApi } from "./groups/reference"
|
||||
import { SessionApi } from "./groups/session"
|
||||
import { SyncApi } from "./groups/sync"
|
||||
import { TuiApi } from "./groups/tui"
|
||||
@@ -61,7 +60,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(QuestionApi)
|
||||
.addHttpApi(PermissionApi)
|
||||
.addHttpApi(ProviderApi)
|
||||
.addHttpApi(ReferenceApi)
|
||||
.addHttpApi(SessionApi)
|
||||
.addHttpApi(SyncApi)
|
||||
.addHttpApi(TuiApi)
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const ReferenceDescriptor = Schema.Union([
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literal("local"),
|
||||
path: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literal("git"),
|
||||
repository: Schema.String,
|
||||
path: Schema.String,
|
||||
branch: Schema.optional(Schema.String),
|
||||
}),
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literal("invalid"),
|
||||
repository: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
}),
|
||||
]).annotate({ identifier: "ReferenceDescriptor" })
|
||||
|
||||
export const ReferenceApi = HttpApi.make("reference")
|
||||
.add(
|
||||
HttpApiGroup.make("reference")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", "/reference", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(ReferenceDescriptor), "Resolved configured references"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "reference.list",
|
||||
summary: "List configured references",
|
||||
description: "List configured references resolved in the current workspace.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "reference",
|
||||
description: "Configured reference routes.",
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
|
||||
export const referenceHandlers = HttpApiBuilder.group(InstanceHttpApi, "reference", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return handlers.handle("list", () =>
|
||||
reference.list().pipe(
|
||||
Effect.map((references) =>
|
||||
references.map((item) => {
|
||||
if (item.kind !== "git") return item
|
||||
return {
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
repository: item.repository,
|
||||
path: item.path,
|
||||
...(item.branch !== undefined ? { branch: item.branch } : {}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -15,6 +16,15 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
|
||||
if (!defect) return Effect.failCause(cause)
|
||||
|
||||
const error = defect.defect
|
||||
if (
|
||||
ConfigErrorV1.JsonError.isInstance(error) ||
|
||||
ConfigErrorV1.InvalidError.isInstance(error) ||
|
||||
ConfigErrorV1.FrontmatterError.isInstance(error) ||
|
||||
ConfigErrorV1.DirectoryTypoError.isInstance(error)
|
||||
) {
|
||||
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
|
||||
}
|
||||
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
|
||||
return Effect.logError("failed", { ref, error, cause: Cause.pretty(cause) }).pipe(
|
||||
|
||||
@@ -35,7 +35,6 @@ import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { LLM } from "@/session/llm"
|
||||
@@ -86,7 +85,6 @@ import { projectCopyHandlers } from "./handlers/project-copy"
|
||||
import { providerHandlers } from "./handlers/provider"
|
||||
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
|
||||
import { questionHandlers } from "./handlers/question"
|
||||
import { referenceHandlers } from "./handlers/reference"
|
||||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
@@ -149,7 +147,6 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
projectCopyHandlers,
|
||||
ptyHandlers,
|
||||
questionHandlers,
|
||||
referenceHandlers,
|
||||
permissionHandlers,
|
||||
providerHandlers,
|
||||
sessionHandlers,
|
||||
@@ -237,7 +234,6 @@ export function createRoutes(
|
||||
Provider.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
@@ -264,7 +260,7 @@ export function createRoutes(
|
||||
]),
|
||||
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
||||
Layer.provide(InstanceLayer.layer),
|
||||
Layer.provide(Observability.layer),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,10 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/core/session/prompt"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
|
||||
import { SessionReminders } from "./reminders"
|
||||
import { SessionTools } from "./tools"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
@@ -122,7 +120,6 @@ export const layer = Layer.effect(
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const references = yield* Reference.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const database = yield* Database.Service
|
||||
@@ -140,46 +137,10 @@ export const layer = Layer.effect(
|
||||
yield* state.cancel(sessionID)
|
||||
})
|
||||
|
||||
const resolveReferenceParts = Effect.fnUntraced(function* (template: string) {
|
||||
const parts: Types.DeepMutable<PromptInput["parts"]> = []
|
||||
const seen = new Set<string>()
|
||||
yield* Effect.forEach(
|
||||
ConfigMarkdown.files(template),
|
||||
Effect.fnUntraced(function* (match) {
|
||||
const name = match[1]
|
||||
if (!name) return
|
||||
const alias = name.split("/")[0]
|
||||
if (!alias || seen.has(alias)) return
|
||||
const reference = yield* references.get(alias)
|
||||
if (!reference) return
|
||||
seen.add(alias)
|
||||
|
||||
const start = match.index ?? 0
|
||||
const source = { value: match[0], start, end: start + match[0].length }
|
||||
if (reference.kind === "invalid") {
|
||||
parts.push(referenceTextPart({ reference, source }))
|
||||
return
|
||||
}
|
||||
|
||||
yield* references.ensure(reference.path)
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(reference.path).href,
|
||||
filename: alias,
|
||||
mime: "application/x-directory",
|
||||
source: { type: "file", text: source, path: alias },
|
||||
})
|
||||
}),
|
||||
{ concurrency: 1, discard: true },
|
||||
)
|
||||
return parts
|
||||
})
|
||||
|
||||
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const parts: Types.DeepMutable<PromptInput["parts"]> = [
|
||||
{ type: "text", text: template },
|
||||
...(yield* resolveReferenceParts(template)),
|
||||
]
|
||||
const files = ConfigMarkdown.files(template)
|
||||
const seen = new Set<string>()
|
||||
@@ -191,10 +152,6 @@ export const layer = Layer.effect(
|
||||
if (seen.has(name)) return
|
||||
seen.add(name)
|
||||
|
||||
const slash = name.indexOf("/")
|
||||
const alias = slash === -1 ? name : name.slice(0, slash)
|
||||
if (yield* references.get(alias)) return
|
||||
|
||||
const filepath = name.startsWith("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: path.resolve(ctx.worktree, name)
|
||||
@@ -1019,22 +976,7 @@ export const layer = Layer.effect(
|
||||
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
|
||||
})
|
||||
|
||||
const submittedParts: Types.DeepMutable<PromptInput["parts"]> = [...input.parts]
|
||||
const attachedReferences = new Set(
|
||||
input.parts.flatMap((part) =>
|
||||
part.type === "file" && part.mime === "application/x-directory" ? [part.url] : [],
|
||||
),
|
||||
)
|
||||
for (const part of input.parts) {
|
||||
if (part.type !== "text" || part.synthetic) continue
|
||||
for (const reference of yield* resolveReferenceParts(part.text)) {
|
||||
if (reference.type === "file" && attachedReferences.has(reference.url)) continue
|
||||
if (reference.type === "file") attachedReferences.add(reference.url)
|
||||
submittedParts.push(reference)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedParts = yield* Effect.forEach(submittedParts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||
const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||
Effect.map((x) => x.flat().map(assign)),
|
||||
)
|
||||
|
||||
@@ -1092,26 +1034,6 @@ export const layer = Layer.effect(
|
||||
if (part.type === "text") {
|
||||
if (part.synthetic) result.synthetic.push(part.text)
|
||||
else result.text.push(part.text)
|
||||
const reference = referencePromptMetadata(part.metadata?.reference)
|
||||
if (reference) {
|
||||
result.references.push(
|
||||
new ReferenceAttachment({
|
||||
name: reference.name,
|
||||
kind: reference.kind,
|
||||
uri: reference.path ? pathToFileURL(reference.path).href : undefined,
|
||||
repository: reference.repository,
|
||||
branch: reference.branch,
|
||||
target: reference.target,
|
||||
targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
|
||||
problem: reference.problem,
|
||||
source: new Source({
|
||||
start: reference.source.start,
|
||||
end: reference.source.end,
|
||||
text: reference.source.value,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (part.type === "file") {
|
||||
result.files.push(
|
||||
@@ -1149,7 +1071,6 @@ export const layer = Layer.effect(
|
||||
text: [] as string[],
|
||||
files: [] as FileAttachment[],
|
||||
agents: [] as AgentAttachment[],
|
||||
references: [] as ReferenceAttachment[],
|
||||
synthetic: [] as string[],
|
||||
},
|
||||
)
|
||||
@@ -1164,7 +1085,6 @@ export const layer = Layer.effect(
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
references: nextPrompt.references,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1642,7 +1562,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Database.defaultLayer,
|
||||
SystemPrompt.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { MessageV2 } from "../message-v2"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const Source = Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
})
|
||||
|
||||
export const ReferencePromptMetadata = Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Literals(["local", "git", "invalid"]),
|
||||
path: Schema.optional(Schema.String),
|
||||
repository: Schema.optional(Schema.String),
|
||||
branch: Schema.optional(Schema.String),
|
||||
target: Schema.optional(Schema.String),
|
||||
targetPath: Schema.optional(Schema.String),
|
||||
problem: Schema.optional(Schema.String),
|
||||
source: Source,
|
||||
})
|
||||
export type ReferencePromptMetadata = typeof ReferencePromptMetadata.Type
|
||||
|
||||
const decodeReferencePromptMetadata = Schema.decodeUnknownOption(ReferencePromptMetadata)
|
||||
|
||||
export function referencePromptMetadata(input: unknown) {
|
||||
return Option.getOrUndefined(decodeReferencePromptMetadata(input))
|
||||
}
|
||||
|
||||
export function referenceTextPart(input: {
|
||||
reference: Reference.Resolved
|
||||
source: ReferencePromptMetadata["source"]
|
||||
target?: string
|
||||
targetPath?: string
|
||||
problem?: string
|
||||
}): SessionV1.TextPartInput {
|
||||
const metadata: ReferencePromptMetadata = {
|
||||
name: input.reference.name,
|
||||
kind: input.reference.kind,
|
||||
...(input.reference.kind === "invalid"
|
||||
? { repository: input.reference.repository }
|
||||
: { path: input.reference.path }),
|
||||
...(input.reference.kind === "git"
|
||||
? { repository: input.reference.repository, branch: input.reference.branch }
|
||||
: {}),
|
||||
...(input.target === undefined ? {} : { target: input.target }),
|
||||
...(input.targetPath ? { targetPath: input.targetPath } : {}),
|
||||
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
|
||||
source: input.source,
|
||||
}
|
||||
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
|
||||
return {
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: [
|
||||
`Referenced configured reference ${label}.`,
|
||||
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
|
||||
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
|
||||
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
|
||||
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
|
||||
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
|
||||
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
|
||||
...(metadata.problem
|
||||
? [`Problem: ${metadata.problem}`]
|
||||
: ["Inspect the configured reference with Read, Glob, and Grep when useful."]),
|
||||
].join("\n"),
|
||||
metadata: { reference: metadata },
|
||||
}
|
||||
}
|
||||
|
||||
export * as ReferencePrompt from "./reference"
|
||||
@@ -6,7 +6,6 @@ import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }),
|
||||
@@ -19,7 +18,6 @@ export const GlobTool = Tool.define(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const reference = yield* Reference.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
|
||||
return {
|
||||
@@ -40,13 +38,12 @@ export const GlobTool = Tool.define(
|
||||
|
||||
let search = params.path ?? ins.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||
yield* reference.ensure(search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (info?.type === "File") {
|
||||
throw new Error(`glob path must be a directory: ${search}`)
|
||||
}
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
bypass: yield* reference.contains(search),
|
||||
bypass: false,
|
||||
kind: "directory",
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
@@ -25,7 +24,6 @@ export const GrepTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const searchSvc = yield* Search.Service
|
||||
const reference = yield* Reference.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -56,10 +54,9 @@ export const GrepTool = Tool.define(
|
||||
const requested = path.isAbsolute(params.path ?? ins.directory)
|
||||
? (params.path ?? ins.directory)
|
||||
: path.join(ins.directory, params.path ?? ".")
|
||||
yield* reference.ensure(requested)
|
||||
const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
yield* assertExternalDirectoryEffect(ctx, requested, {
|
||||
bypass: yield* reference.contains(requested),
|
||||
bypass: false,
|
||||
kind: requestedInfo?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -66,14 +65,13 @@ type Metadata = {
|
||||
export const ReadTool = Tool.define<
|
||||
typeof Parameters,
|
||||
Metadata,
|
||||
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Search.Service | Scope.Scope
|
||||
FSUtil.Service | Instruction.Service | LSP.Service | Search.Service | Scope.Scope
|
||||
>(
|
||||
"read",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const reference = yield* Reference.Service
|
||||
const search = yield* Search.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
@@ -243,7 +241,6 @@ export const ReadTool = Tool.define<
|
||||
if (process.platform === "win32") {
|
||||
filepath = FSUtil.normalizePath(filepath)
|
||||
}
|
||||
yield* reference.ensure(filepath)
|
||||
const title = path.relative(instance.worktree, filepath)
|
||||
|
||||
const stat = yield* fs.stat(filepath).pipe(
|
||||
@@ -254,7 +251,7 @@ export const ReadTool = Tool.define<
|
||||
)
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, filepath, {
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)),
|
||||
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
|
||||
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
@@ -91,7 +90,6 @@ export const layer: Layer.Layer<
|
||||
| Session.Service
|
||||
| BackgroundJob.Service
|
||||
| Provider.Service
|
||||
| Reference.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| FSUtil.Service
|
||||
@@ -350,7 +348,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { ConfigReference } from "../../src/config/reference"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { Git } from "../../src/git"
|
||||
import { Reference } from "../../src/reference/reference"
|
||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Reference.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
||||
)
|
||||
const references = testEffect(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
referenceLayer({ experimentalReferences: true }),
|
||||
),
|
||||
)
|
||||
|
||||
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
||||
return previous
|
||||
}),
|
||||
() => self,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
||||
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
}),
|
||||
)
|
||||
|
||||
const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const proc = Bun.spawn(["git", ...args], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||
return stdout.trim()
|
||||
})
|
||||
})
|
||||
|
||||
const waitForContent = (
|
||||
fs: FSUtil.Interface,
|
||||
file: string,
|
||||
content: string,
|
||||
attempts = 50,
|
||||
): Effect.Effect<void, FSUtil.Error> =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* fs.readFileStringSafe(file)) === content) return
|
||||
if (attempts <= 0) throw new Error(`timed out waiting for ${file}`)
|
||||
yield* Effect.sleep("100 millis")
|
||||
yield* waitForContent(fs, file, content, attempts - 1)
|
||||
})
|
||||
|
||||
describe("reference", () => {
|
||||
it.live("resolves supported local and git config forms", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.resolve("opencode-reference-root")
|
||||
const local = Reference.resolve({
|
||||
name: "docs",
|
||||
reference: ConfigReference.normalizeEntry({ path: "../docs" }),
|
||||
directory: path.join(root, "packages", "app"),
|
||||
worktree: root,
|
||||
})
|
||||
const repo = Reference.resolve({
|
||||
name: "effect",
|
||||
reference: ConfigReference.normalizeEntry({ repository: "Effect-TS/effect", branch: "main" }),
|
||||
directory: path.join(root, "packages", "app"),
|
||||
worktree: root,
|
||||
})
|
||||
const localString = Reference.resolve({
|
||||
name: "notes",
|
||||
reference: ConfigReference.normalizeEntry("./notes"),
|
||||
directory: path.join(root, "packages", "app"),
|
||||
worktree: root,
|
||||
})
|
||||
const repoString = Reference.resolve({
|
||||
name: "repo",
|
||||
reference: ConfigReference.normalizeEntry("owner/repo"),
|
||||
directory: path.join(root, "packages", "app"),
|
||||
worktree: root,
|
||||
})
|
||||
|
||||
expect(local.kind).toBe("local")
|
||||
if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs"))
|
||||
expect(localString.kind).toBe("local")
|
||||
if (localString.kind === "local") expect(localString.path).toBe(path.resolve(root, "notes"))
|
||||
expect(repo.kind).toBe("git")
|
||||
if (repo.kind === "git") {
|
||||
expect(repo.repository).toBe("Effect-TS/effect")
|
||||
expect(repo.branch).toBe("main")
|
||||
expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"))
|
||||
}
|
||||
expect(repoString.kind).toBe("git")
|
||||
if (repoString.kind === "git") {
|
||||
expect(repoString.repository).toBe("owner/repo")
|
||||
expect(repoString.path).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps invalid repository references visible without materializing", () =>
|
||||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = yield* Reference.Service
|
||||
const references = yield* reference.list()
|
||||
const invalid = yield* reference.get("bad")
|
||||
|
||||
expect(references.map((item) => item.name)).toEqual(["bad"])
|
||||
expect(invalid).toMatchObject({
|
||||
name: "bad",
|
||||
kind: "invalid",
|
||||
repository: "not-a-repo",
|
||||
})
|
||||
if (invalid?.kind === "invalid") expect(invalid.message).toContain("Repository must be a git URL")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
reference: {
|
||||
bad: "not-a-repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("marks same-cache references with different branches invalid", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.resolve("opencode-reference-root")
|
||||
const references = Reference.resolveAll({
|
||||
directory: root,
|
||||
worktree: root,
|
||||
references: ConfigReference.normalize({
|
||||
main: { repository: "owner/repo", branch: "main" },
|
||||
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
||||
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
||||
expect(references[1]?.kind).toBe("invalid")
|
||||
if (references[1]?.kind === "invalid") {
|
||||
expect(references[1].message).toContain("conflicts with @main")
|
||||
expect(references[1].message).toContain("@dev requests dev")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("represents invalid aliases as invalid references", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.resolve("opencode-reference-root")
|
||||
const references = Reference.resolveAll({
|
||||
directory: root,
|
||||
worktree: root,
|
||||
references: ConfigReference.normalize({
|
||||
"bad/name": "owner/repo",
|
||||
}),
|
||||
})
|
||||
|
||||
expect(references).toEqual([
|
||||
{
|
||||
name: "bad/name",
|
||||
kind: "invalid",
|
||||
message: "Reference alias must not contain /, whitespace, comma, or backtick",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
references.live("materializes configured git references during init", () =>
|
||||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "opencode-reference-test")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add readme"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const reference = yield* Reference.Service
|
||||
yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
Effect.gen(function* () {
|
||||
yield* reference.init()
|
||||
yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n")
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true)
|
||||
expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n")
|
||||
|
||||
const resolved = yield* reference.get("docs")
|
||||
expect(resolved?.kind).toBe("git")
|
||||
if (resolved?.kind === "git") expect(resolved.path).toBe(cache)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
reference: {
|
||||
docs: "opencode-reference-test/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
references.live("refreshes configured git references on new instance init", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "opencode-reference-refresh")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add readme"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = yield* Reference.Service
|
||||
yield* reference.init()
|
||||
yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
reference: {
|
||||
docs: "opencode-reference-refresh/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const branch = yield* git(source, ["branch", "--show-current"])
|
||||
yield* git(source, ["remote", "add", "origin", remoteRepo])
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "update readme"])
|
||||
yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
||||
|
||||
yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = yield* Reference.Service
|
||||
yield* reference.init()
|
||||
yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
reference: {
|
||||
docs: "opencode-reference-refresh/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -53,7 +53,7 @@ describe("HttpApi error middleware", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not expose config defects from generic middleware", () =>
|
||||
it.live("returns invalid config defects as structured client errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const configError = new ConfigErrorV1.InvalidError({
|
||||
path: "/tmp/opencode.json",
|
||||
@@ -70,11 +70,16 @@ describe("HttpApi error middleware", () => {
|
||||
const body = yield* response.json
|
||||
const serialized = JSON.stringify(body)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expectUnknownErrorBody(body)
|
||||
expect(serialized).not.toContain("/tmp/opencode.json")
|
||||
expect(serialized).not.toContain("provider")
|
||||
expect(serialized).not.toContain("anthropic")
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toMatchObject({
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "/tmp/opencode.json",
|
||||
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
|
||||
},
|
||||
})
|
||||
expect(serialized).toContain("/tmp/opencode.json")
|
||||
expect(serialized).toContain("anthropic")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -670,7 +670,7 @@ const scenarios: Scenario[] = [
|
||||
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
|
||||
.json(200, locationData(object)),
|
||||
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
|
||||
http.protected.get("/reference", "reference.list").json(200, array),
|
||||
http.protected.get("/api/reference", "v2.reference.list").json(200, object),
|
||||
http.protected
|
||||
.get("/api/provider/{providerID}", "v2.provider.get")
|
||||
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
|
||||
|
||||
@@ -11,7 +11,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("reference HttpApi", () => {
|
||||
test("lists presentation-safe references resolved in the server workspace", async () => {
|
||||
test("lists usable references resolved in the server workspace", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: false,
|
||||
@@ -24,30 +24,31 @@ describe("reference HttpApi", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const response = await Server.Default().app.request("/reference", {
|
||||
const response = await Server.Default().app.request("/api/reference", {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual([
|
||||
{
|
||||
name: "docs",
|
||||
kind: "local",
|
||||
path: path.join(tmp.path, "docs"),
|
||||
},
|
||||
{
|
||||
name: "effect",
|
||||
kind: "git",
|
||||
repository: "Effect-TS/effect",
|
||||
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
|
||||
branch: "main",
|
||||
},
|
||||
{
|
||||
name: "bad",
|
||||
kind: "invalid",
|
||||
repository: "not-a-repo",
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
},
|
||||
])
|
||||
const body = await response.json()
|
||||
expect(body).toMatchObject({ location: { directory: tmp.path } })
|
||||
expect(body.data).toEqual([
|
||||
{
|
||||
name: "docs",
|
||||
path: path.join(tmp.path, "docs"),
|
||||
source: {
|
||||
type: "local",
|
||||
path: path.join(tmp.path, "docs"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "effect",
|
||||
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
|
||||
source: {
|
||||
type: "git",
|
||||
repository: "Effect-TS/effect",
|
||||
branch: "main",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,8 +50,6 @@ import { Truncate } from "@/tool/truncate"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Format } from "../../src/format"
|
||||
import { Reference } from "../../src/reference/reference"
|
||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
@@ -191,9 +189,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
@@ -219,7 +215,6 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
||||
return SessionPrompt.layer.pipe(
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(summary),
|
||||
Layer.provideMerge(run),
|
||||
Layer.provideMerge(compact),
|
||||
@@ -2021,92 +2016,6 @@ noLLMServer.instance(
|
||||
{ config: cfg },
|
||||
)
|
||||
|
||||
noLLMServer.instance(
|
||||
"resolves configured reference mentions to one root directory attachment",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory: dir } = yield* TestInstance
|
||||
const docs = path.join(dir, "external-docs")
|
||||
yield* ensureDir(path.join(docs, "guide"))
|
||||
yield* ensureDir(path.join(dir, "docs"))
|
||||
yield* writeText(path.join(docs, "README.md"), "reference readme")
|
||||
yield* writeText(path.join(docs, "guide", "intro.md"), "reference intro")
|
||||
yield* writeText(path.join(dir, "docs", "README.md"), "workspace readme")
|
||||
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const parts = yield* prompt.resolvePromptParts(
|
||||
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
|
||||
)
|
||||
const files = parts.filter((part): part is SessionV1.FilePartInput => part.type === "file")
|
||||
const agents = parts.filter((part): part is SessionV1.AgentPartInput => part.type === "agent")
|
||||
const text = parts.find((part): part is SessionV1.TextPartInput => part.type === "text" && !part.synthetic)
|
||||
|
||||
expect(text?.text).toContain("@docs")
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]).toMatchObject({
|
||||
filename: "docs",
|
||||
mime: "application/x-directory",
|
||||
source: { type: "file", path: "docs", text: { value: "@docs" } },
|
||||
})
|
||||
expect(fileURLToPath(files[0].url)).toBe(docs)
|
||||
expect(agents.map((agent) => agent.name)).toEqual(["build"])
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
...cfg,
|
||||
reference: {
|
||||
docs: "./external-docs",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
noLLMServer.instance(
|
||||
"stores raw reference mentions alongside directory attachments",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory: dir } = yield* TestInstance
|
||||
const docs = path.join(dir, "external-docs")
|
||||
yield* ensureDir(docs)
|
||||
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({})
|
||||
const message = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "Use @docs for context" }],
|
||||
})
|
||||
|
||||
const stored = yield* MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||
const synthetic = stored.parts.filter(
|
||||
(part): part is SessionV1.TextPart => part.type === "text" && part.synthetic === true,
|
||||
)
|
||||
const files = stored.parts.filter((part): part is SessionV1.FilePart => part.type === "file")
|
||||
const text = stored.parts.find((part): part is SessionV1.TextPart => part.type === "text" && !part.synthetic)
|
||||
|
||||
expect(text?.text).toBe("Use @docs for context")
|
||||
expect(synthetic.some((part) => part.text.includes(JSON.stringify({ filePath: docs })))).toBe(true)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]).toMatchObject({
|
||||
filename: "docs",
|
||||
mime: "application/x-directory",
|
||||
source: { type: "file", path: "docs", text: { value: "@docs", start: 4, end: 9 } },
|
||||
})
|
||||
expect(fileURLToPath(files[0].url)).toBe(docs)
|
||||
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
...cfg,
|
||||
reference: {
|
||||
docs: "./external-docs",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Special characters in filenames
|
||||
|
||||
noLLMServer.instance(
|
||||
|
||||
@@ -59,8 +59,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { Format } from "../../src/format"
|
||||
import { Reference } from "../../src/reference/reference"
|
||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const mcp = Layer.succeed(
|
||||
@@ -136,9 +134,7 @@ function makeHttp() {
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(Search.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||
@@ -164,7 +160,6 @@ function makeHttp() {
|
||||
SessionPrompt.layer.pipe(
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provideMerge(run),
|
||||
Layer.provideMerge(compact),
|
||||
|
||||
@@ -12,8 +12,6 @@ import { Truncate } from "@/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Git } from "@/git"
|
||||
@@ -21,13 +19,6 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Permission } from "../../src/permission"
|
||||
import type * as Tool from "../../src/tool/tool"
|
||||
|
||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Reference.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
|
||||
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
@@ -36,11 +27,9 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
referenceLayer(flags),
|
||||
)
|
||||
|
||||
const it = testEffect(toolLayer())
|
||||
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||
|
||||
const ctx = {
|
||||
@@ -145,44 +134,4 @@ describe("tool.glob", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
references.instance(
|
||||
"does not ask for external_directory permission inside configured git references",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestInstance
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-glob-reference", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "opencode-glob-reference")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
yield* fs.writeWithDirs(path.join(source, "src", "index.ts"), "export const value = 1\n")
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add source"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const { items, next } = asks()
|
||||
const info = yield* GlobTool
|
||||
const glob = yield* info.init()
|
||||
const result = yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
glob.execute({ pattern: "*.ts", path: path.join(cache, "src") }, next),
|
||||
)
|
||||
|
||||
expect(result.metadata.count).toBe(1)
|
||||
expect(full(result.output)).toContain(full(path.join(cache, "src", "index.ts")))
|
||||
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
reference: {
|
||||
docs: "opencode-glob-reference/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,8 +14,6 @@ import { Agent } from "../../src/agent/agent"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
import { Permission } from "../../src/permission"
|
||||
import type * as Tool from "../../src/tool/tool"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -23,13 +21,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Git } from "@/git"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Reference.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
|
||||
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
@@ -38,11 +29,9 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
referenceLayer(flags),
|
||||
)
|
||||
|
||||
const it = testEffect(toolLayer())
|
||||
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
|
||||
|
||||
const ctx = {
|
||||
@@ -215,52 +204,4 @@ describe("tool.grep", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
references.instance(
|
||||
"does not ask for external_directory permission inside configured git references",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestInstance
|
||||
const appfs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-grep-reference", "repo")
|
||||
yield* appfs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => appfs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "opencode-grep-reference")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
yield* appfs.writeWithDirs(path.join(source, "src", "notes.md"), "needle\n")
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add notes"])
|
||||
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||
const next: Tool.Context = {
|
||||
...ctx,
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
}
|
||||
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
grep.execute({ pattern: "needle", path: path.join(cache, "src"), include: "*.md" }, next),
|
||||
)
|
||||
|
||||
expect(result.metadata.matches).toBe(1)
|
||||
expect(full(result.output)).toContain(full(path.join(cache, "src", "notes.md")))
|
||||
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
reference: {
|
||||
docs: "opencode-grep-reference/repo",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
tmpdirScoped,
|
||||
} from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
@@ -45,13 +43,6 @@ const ctx = {
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Reference.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RepositoryCache.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
|
||||
const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
@@ -59,13 +50,11 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
referenceLayer(flags),
|
||||
Search.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer))
|
||||
const references = testEffect(Layer.mergeAll(readLayer({ experimentalReferences: true }), testInstanceStoreLayer))
|
||||
|
||||
const init = Effect.fn("ReadToolTest.init")(function* () {
|
||||
const info = yield* ReadTool
|
||||
@@ -266,43 +255,6 @@ describe("tool.read external_directory permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
references.live("does not ask for external_directory permission when reading configured references", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
||||
yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore))
|
||||
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "opencode-read-reference")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
yield* put(path.join(source, "notes.md"), "reference notes")
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add notes"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const dir = yield* tmpdirScoped({
|
||||
git: true,
|
||||
config: {
|
||||
reference: {
|
||||
docs: "opencode-read-reference/repo",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { items, next } = asks()
|
||||
const result = yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
exec(dir, { filePath: path.join(cache, "notes.md") }, next),
|
||||
)
|
||||
const ext = items.find((item) => item.permission === "external_directory")
|
||||
|
||||
expect(result.output).toContain("reference notes")
|
||||
expect(ext).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("tool.read env file permissions", () => {
|
||||
|
||||
@@ -29,8 +29,6 @@ import { Format } from "@/format"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
@@ -60,8 +58,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
|
||||
@@ -172,8 +172,6 @@ import type {
|
||||
QuestionReplyErrors,
|
||||
QuestionReplyResponses,
|
||||
QuestionV2Reply,
|
||||
ReferenceListErrors,
|
||||
ReferenceListResponses,
|
||||
SessionAbortErrors,
|
||||
SessionAbortResponses,
|
||||
SessionChildrenErrors,
|
||||
@@ -288,6 +286,8 @@ import type {
|
||||
V2ProviderListResponses,
|
||||
V2QuestionRequestListErrors,
|
||||
V2QuestionRequestListResponses,
|
||||
V2ReferenceListErrors,
|
||||
V2ReferenceListResponses,
|
||||
V2SessionCompactErrors,
|
||||
V2SessionCompactResponses,
|
||||
V2SessionContextErrors,
|
||||
@@ -3335,38 +3335,6 @@ export class Provider extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Reference extends HeyApiClient {
|
||||
/**
|
||||
* List configured references
|
||||
*
|
||||
* List configured references resolved in the current workspace.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ReferenceListResponses, ReferenceListErrors, ThrowOnError>({
|
||||
url: "/reference",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Session2 extends HeyApiClient {
|
||||
/**
|
||||
* List sessions
|
||||
@@ -5580,7 +5548,6 @@ export class Fs extends HeyApiClient {
|
||||
workspace?: string
|
||||
}
|
||||
path: string
|
||||
reference?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -5591,7 +5558,6 @@ export class Fs extends HeyApiClient {
|
||||
args: [
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "query", key: "path" },
|
||||
{ in: "query", key: "reference" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -5615,7 +5581,6 @@ export class Fs extends HeyApiClient {
|
||||
workspace?: string
|
||||
}
|
||||
path?: string
|
||||
reference?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -5626,7 +5591,6 @@ export class Fs extends HeyApiClient {
|
||||
args: [
|
||||
{ in: "query", key: "location" },
|
||||
{ in: "query", key: "path" },
|
||||
{ in: "query", key: "reference" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -5746,6 +5710,30 @@ export class Question3 extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Reference extends HeyApiClient {
|
||||
/**
|
||||
* List references
|
||||
*
|
||||
* List references available in the requested location.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<V2ReferenceListResponses, V2ReferenceListErrors, ThrowOnError>({
|
||||
url: "/api/reference",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class V2 extends HeyApiClient {
|
||||
private _health?: Health
|
||||
get health(): Health {
|
||||
@@ -5801,6 +5789,11 @@ export class V2 extends HeyApiClient {
|
||||
get question(): Question3 {
|
||||
return (this._question ??= new Question3({ client: this.client }))
|
||||
}
|
||||
|
||||
private _reference?: Reference
|
||||
get reference(): Reference {
|
||||
return (this._reference ??= new Reference({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class OpencodeClient extends HeyApiClient {
|
||||
@@ -5921,11 +5914,6 @@ export class OpencodeClient extends HeyApiClient {
|
||||
return (this._provider ??= new Provider({ client: this.client }))
|
||||
}
|
||||
|
||||
private _reference?: Reference
|
||||
get reference(): Reference {
|
||||
return (this._reference ??= new Reference({ client: this.client }))
|
||||
}
|
||||
|
||||
private _session?: Session2
|
||||
get session(): Session2 {
|
||||
return (this._session ??= new Session2({ client: this.client }))
|
||||
|
||||
@@ -57,6 +57,7 @@ export type Event =
|
||||
| EventAccountSwitched
|
||||
| EventPermissionV2Asked
|
||||
| EventPermissionV2Replied
|
||||
| EventReferenceUpdated
|
||||
| EventFileWatcherUpdated
|
||||
| EventPtyCreated
|
||||
| EventPtyUpdated
|
||||
@@ -633,7 +634,6 @@ export type Prompt = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
references?: Array<PromptReferenceAttachment>
|
||||
}
|
||||
|
||||
export type Pty = {
|
||||
@@ -1296,6 +1296,13 @@ export type GlobalEvent = {
|
||||
reply: PermissionV2Reply
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "reference.updated"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "file.watcher.updated"
|
||||
@@ -2590,26 +2597,6 @@ export type ProviderAuthError1 = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ReferenceDescriptor =
|
||||
| {
|
||||
name: string
|
||||
kind: "local"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "git"
|
||||
repository: string
|
||||
path: string
|
||||
branch?: string
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
kind: "invalid"
|
||||
repository?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NotFoundError = {
|
||||
name: "NotFoundError"
|
||||
data: {
|
||||
@@ -2986,18 +2973,6 @@ export type PromptAgentAttachment = {
|
||||
source?: PromptSource
|
||||
}
|
||||
|
||||
export type PromptReferenceAttachment = {
|
||||
name: string
|
||||
kind: "local" | "git" | "invalid"
|
||||
uri?: string
|
||||
repository?: string
|
||||
branch?: string
|
||||
target?: string
|
||||
targetUri?: string
|
||||
problem?: string
|
||||
source?: PromptSource
|
||||
}
|
||||
|
||||
export type SessionErrorUnknown = {
|
||||
type: "unknown"
|
||||
message: string
|
||||
@@ -3896,7 +3871,6 @@ export type SessionMessageUser = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
references?: Array<PromptReferenceAttachment>
|
||||
type: "user"
|
||||
}
|
||||
|
||||
@@ -4212,6 +4186,23 @@ export type QuestionV2Reply = {
|
||||
answers: Array<QuestionV2Answer>
|
||||
}
|
||||
|
||||
export type ReferenceLocalSource = {
|
||||
type: "local"
|
||||
path: string
|
||||
}
|
||||
|
||||
export type ReferenceGitSource = {
|
||||
type: "git"
|
||||
repository: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export type ReferenceInfo = {
|
||||
name: string
|
||||
path: string
|
||||
source: ReferenceLocalSource | ReferenceGitSource
|
||||
}
|
||||
|
||||
export type EventModelsDevRefreshed = {
|
||||
id: string
|
||||
type: "models-dev.refreshed"
|
||||
@@ -4933,6 +4924,14 @@ export type EventPermissionV2Replied = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventReferenceUpdated = {
|
||||
id: string
|
||||
type: "reference.updated"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
id: string
|
||||
type: "file.watcher.updated"
|
||||
@@ -7640,34 +7639,6 @@ export type ProviderOauthCallbackResponses = {
|
||||
|
||||
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
|
||||
|
||||
export type ReferenceListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/reference"
|
||||
}
|
||||
|
||||
export type ReferenceListErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ReferenceListError = ReferenceListErrors[keyof ReferenceListErrors]
|
||||
|
||||
export type ReferenceListResponses = {
|
||||
/**
|
||||
* Resolved configured references
|
||||
*/
|
||||
200: Array<ReferenceDescriptor>
|
||||
}
|
||||
|
||||
export type ReferenceListResponse = ReferenceListResponses[keyof ReferenceListResponses]
|
||||
|
||||
export type SessionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -10098,7 +10069,6 @@ export type V2FsReadData = {
|
||||
workspace?: string
|
||||
}
|
||||
path: string
|
||||
reference?: string
|
||||
}
|
||||
url: "/api/fs/read"
|
||||
}
|
||||
@@ -10137,7 +10107,6 @@ export type V2FsListData = {
|
||||
workspace?: string
|
||||
}
|
||||
path?: string
|
||||
reference?: string
|
||||
}
|
||||
url: "/api/fs/list"
|
||||
}
|
||||
@@ -10384,6 +10353,43 @@ export type V2SessionQuestionRejectResponses = {
|
||||
|
||||
export type V2SessionQuestionRejectResponse = V2SessionQuestionRejectResponses[keyof V2SessionQuestionRejectResponses]
|
||||
|
||||
export type V2ReferenceListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
}
|
||||
url: "/api/reference"
|
||||
}
|
||||
|
||||
export type V2ReferenceListErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2ReferenceListError = V2ReferenceListErrors[keyof V2ReferenceListErrors]
|
||||
|
||||
export type V2ReferenceListResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
location: LocationInfo
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2ReferenceListResponse = V2ReferenceListResponses[keyof V2ReferenceListResponses]
|
||||
|
||||
export type PtyConnectData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
+139
-189
@@ -5400,64 +5400,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/reference": {
|
||||
"get": {
|
||||
"tags": ["reference"],
|
||||
"operationId": "reference.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Resolved configured references",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ReferenceDescriptor"
|
||||
},
|
||||
"description": "Resolved configured references"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List configured references resolved in the current workspace.",
|
||||
"summary": "List configured references",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.reference.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/session": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -11414,14 +11356,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -11515,14 +11449,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12062,6 +11988,87 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/reference": {
|
||||
"get": {
|
||||
"tags": ["reference"],
|
||||
"operationId": "v2.reference.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/LocationInfo"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ReferenceInfo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List references available in the requested location.",
|
||||
"summary": "List references",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.reference.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/pty/{ptyID}/connect": {
|
||||
"get": {
|
||||
"tags": ["pty"],
|
||||
@@ -14068,12 +14075,6 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptAgentAttachment"
|
||||
}
|
||||
},
|
||||
"references": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptReferenceAttachment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["text"],
|
||||
@@ -20041,70 +20042,6 @@
|
||||
"required": ["name", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferenceDescriptor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["local"]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "kind", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["git"]
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "kind", "repository", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["invalid"]
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "kind", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"NotFoundError": {
|
||||
"type": "object",
|
||||
"required": ["name", "data"],
|
||||
@@ -21498,41 +21435,6 @@
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PromptReferenceAttachment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["local", "git", "invalid"]
|
||||
},
|
||||
"uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"target": {
|
||||
"type": "string"
|
||||
},
|
||||
"targetUri": {
|
||||
"type": "string"
|
||||
},
|
||||
"problem": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/PromptSource"
|
||||
}
|
||||
},
|
||||
"required": ["name", "kind"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionErrorUnknown": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24339,12 +24241,6 @@
|
||||
"$ref": "#/components/schemas/PromptAgentAttachment"
|
||||
}
|
||||
},
|
||||
"references": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptReferenceAttachment"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["user"]
|
||||
@@ -25214,6 +25110,60 @@
|
||||
"required": ["answers"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferenceLocalSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["local"]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "path"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferenceGitSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["git"]
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "repository"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReferenceInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ReferenceLocalSource"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ReferenceGitSource"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["name", "path", "source"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventModels-devRefreshed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -28767,10 +28717,6 @@
|
||||
"name": "provider",
|
||||
"description": "Experimental HttpApi provider routes."
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"description": "Configured reference routes."
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"description": "Experimental HttpApi session routes."
|
||||
@@ -28835,6 +28781,10 @@
|
||||
"name": "session questions",
|
||||
"description": "Experimental session question routes."
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"description": "Location-scoped project references."
|
||||
},
|
||||
{
|
||||
"name": "pty",
|
||||
"description": "PTY websocket route."
|
||||
|
||||
@@ -12,6 +12,7 @@ import { EventGroup } from "./groups/event"
|
||||
import { AgentGroup } from "./groups/agent"
|
||||
import { HealthGroup } from "./groups/health"
|
||||
import { QuestionGroup } from "./groups/question"
|
||||
import { ReferenceGroup } from "./groups/reference"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
|
||||
export const Api = HttpApi.make("server")
|
||||
@@ -27,6 +28,7 @@ export const Api = HttpApi.make("server")
|
||||
.add(SkillGroup)
|
||||
.add(EventGroup)
|
||||
.add(QuestionGroup)
|
||||
.add(ReferenceGroup)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
||||
@@ -8,13 +8,11 @@ import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./locat
|
||||
const ReadQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: RelativePath,
|
||||
reference: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const ListQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
reference: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
|
||||
|
||||
export const ReferenceGroup = HttpApiGroup.make("server.reference")
|
||||
.add(
|
||||
HttpApiEndpoint.get("reference.list", "/api/reference", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Reference.Info)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.reference.list",
|
||||
summary: "List references",
|
||||
description: "List references available in the requested location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "reference",
|
||||
description: "Location-scoped project references.",
|
||||
}),
|
||||
)
|
||||
.middleware(LocationMiddleware)
|
||||
@@ -16,6 +16,7 @@ import { EventHandler } from "./handlers/event"
|
||||
import { AgentHandler } from "./handlers/agent"
|
||||
import { HealthHandler } from "./handlers/health"
|
||||
import { QuestionHandler } from "./handlers/question"
|
||||
import { ReferenceHandler } from "./handlers/reference"
|
||||
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
||||
|
||||
export const handlers = Layer.mergeAll(
|
||||
@@ -31,6 +32,7 @@ export const handlers = Layer.mergeAll(
|
||||
SkillHandler,
|
||||
EventHandler,
|
||||
QuestionHandler,
|
||||
ReferenceHandler,
|
||||
).pipe(
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(locationLayer),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../groups/location"
|
||||
|
||||
export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
|
||||
handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
|
||||
)
|
||||
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"./config": "./src/config/index.tsx",
|
||||
"./context/args": "./src/context/args.tsx",
|
||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||
"./context/exit": "./src/context/exit.tsx",
|
||||
"./context/kv": "./src/context/kv.tsx",
|
||||
"./context/project": "./src/context/project.tsx",
|
||||
"./context/runtime": "./src/context/runtime.tsx",
|
||||
@@ -26,7 +27,6 @@
|
||||
"./attention": "./src/attention.ts",
|
||||
"./editor": "./src/editor.ts",
|
||||
"./editor-zed": "./src/editor-zed.ts",
|
||||
"./context/aggregate-failures": "./src/context/aggregate-failures.ts",
|
||||
"./runtime": "./src/runtime.tsx",
|
||||
"./terminal-win32": "./src/terminal-win32.ts",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
|
||||
+111
-92
@@ -5,6 +5,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { EpilogueProvider } from "./context/epilogue"
|
||||
import * as Selection from "./util/selection"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
|
||||
@@ -80,6 +81,7 @@ import { createTuiAttention } from "./attention"
|
||||
import * as TuiAudio from "./audio"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
@@ -175,8 +177,8 @@ function isVersionGreater(left: string, right: string) {
|
||||
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const global = yield* Global.Service
|
||||
const epilogue = { value: undefined as string | undefined }
|
||||
const output = yield* Effect.scoped(
|
||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise(() =>
|
||||
@@ -194,7 +196,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
},
|
||||
}),
|
||||
),
|
||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
||||
(renderer) =>
|
||||
Effect.sync(() => {
|
||||
destroyRenderer(renderer)
|
||||
}),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
@@ -212,7 +217,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
|
||||
const shutdown = yield* Deferred.make<void>()
|
||||
const shutdown = yield* Deferred.make<unknown>()
|
||||
const onSighup = () => destroyRenderer(renderer)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => process.on("SIGHUP", onSighup)),
|
||||
@@ -229,103 +234,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
<ExitProvider
|
||||
exit={(reason) => {
|
||||
if (renderer.isDestroyed) return
|
||||
exit.reason = reason
|
||||
destroyRenderer(renderer)
|
||||
}}
|
||||
>
|
||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<EpilogueProvider set={(value) => (epilogue.value = value)}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</EpilogueProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</EpilogueProvider>
|
||||
</ExitProvider>
|
||||
)
|
||||
}, renderer)
|
||||
})
|
||||
yield* Deferred.await(shutdown)
|
||||
return epilogue.value
|
||||
}),
|
||||
)
|
||||
yield* Effect.sync(() => {
|
||||
win32FlushInputBuffer()
|
||||
if (output) process.stdout.write(output + "\n")
|
||||
if (exit.reason !== undefined)
|
||||
process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n")
|
||||
if (exit.epilogue) process.stdout.write(exit.epilogue + "\n")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -346,6 +364,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
|
||||
@@ -786,7 +805,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
title: "Exit the app",
|
||||
slashName: "exit",
|
||||
slashAliases: ["quit", "q"],
|
||||
run: () => destroyRenderer(renderer),
|
||||
run: () => exit(),
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
@@ -1020,7 +1039,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
||||
)
|
||||
|
||||
destroyRenderer(renderer)
|
||||
void exit()
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { destroyRenderer } from "../util/renderer"
|
||||
import { useExit } from "../context/exit"
|
||||
|
||||
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
|
||||
const term = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
destroyRenderer(renderer)
|
||||
void exit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -66,7 +66,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={() => destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}>
|
||||
<box onMouseUp={() => void exit()} backgroundColor={colors.primary} padding={1}>
|
||||
<text fg={colors.bg}>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEditorContext } from "../../context/editor"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useSyncV2 } from "../../context/sync-v2"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTuiConfig } from "../../config"
|
||||
@@ -19,7 +20,6 @@ import { Locale } from "../../util/locale"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||
import type { ReferenceDescriptor } from "@opencode-ai/sdk/v2"
|
||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
@@ -85,6 +85,7 @@ export function Autocomplete(props: {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const syncV2 = useSyncV2()
|
||||
const project = useProject()
|
||||
const slashes = useCommandSlashes()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
@@ -272,37 +273,12 @@ export function Autocomplete(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function referencePromptText(reference: ReferenceDescriptor) {
|
||||
const problem = reference.kind === "invalid" ? reference.message : undefined
|
||||
return [
|
||||
`Referenced configured reference @${reference.name}.`,
|
||||
...(reference.kind === "local" ? ["Kind: local directory"] : []),
|
||||
...(reference.kind === "git" ? ["Kind: git repository"] : []),
|
||||
...(reference.kind === "invalid" && reference.repository ? [`Repository: ${reference.repository}`] : []),
|
||||
...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []),
|
||||
...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||
...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]),
|
||||
...(problem
|
||||
? [`Problem: ${problem}`]
|
||||
: ["Inspect the configured reference with Read, Glob, and Grep when useful."]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const [references] = createResource(
|
||||
() => project.workspace.current(),
|
||||
async (workspace) => {
|
||||
const result = await sdk.client.reference.list({ workspace })
|
||||
return result.data ?? []
|
||||
},
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const referenceMatch = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return
|
||||
const { baseQuery } = extractLineRange(search())
|
||||
const slash = baseQuery.indexOf("/")
|
||||
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
||||
return references().find((item) => item.name === alias)
|
||||
return syncV2.data.reference.find((item) => item.name === alias)
|
||||
})
|
||||
|
||||
function normalizeMentionPath(filePath: string) {
|
||||
@@ -435,29 +411,21 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
const referenceAliases = createMemo(() =>
|
||||
references().map(
|
||||
syncV2.data.reference.map(
|
||||
(reference): AutocompleteOption => ({
|
||||
display: "@" + reference.name,
|
||||
description: reference.kind === "invalid" ? reference.message : " dir",
|
||||
description: " dir",
|
||||
onSelect: () => {
|
||||
if (reference.kind !== "invalid") {
|
||||
insertPart(reference.name, {
|
||||
type: "file",
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
url: pathToFileURL(reference.path).href,
|
||||
source: {
|
||||
type: "file",
|
||||
text: { start: 0, end: 0, value: "" },
|
||||
path: reference.name,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
insertPart(reference.name, {
|
||||
type: "text",
|
||||
text: referencePromptText(reference),
|
||||
synthetic: true,
|
||||
type: "file",
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
url: pathToFileURL(reference.path).href,
|
||||
source: {
|
||||
type: "file",
|
||||
text: { start: 0, end: 0, value: "" },
|
||||
path: reference.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useSync } from "../../context/sync"
|
||||
import { useEvent } from "../../context/event"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
|
||||
import { normalizePromptContent, openEditor } from "../../editor"
|
||||
import { destroyRenderer } from "../../util/renderer"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { promptOffsetWidth } from "../../prompt/display"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { usePromptHistory, type PromptInfo } from "../../prompt/history"
|
||||
@@ -163,6 +163,7 @@ export function Prompt(props: PromptProps) {
|
||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
@@ -955,7 +956,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!agent) return false
|
||||
const trimmed = store.prompt.input.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
destroyRenderer(renderer)
|
||||
void exit()
|
||||
return true
|
||||
}
|
||||
const selectedModel = local.model.current()
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { cliErrorMessage } from "../util/error"
|
||||
|
||||
/**
|
||||
* Aggregate Promise.allSettled results into a single Error that names every
|
||||
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||
* boundaries so a single 4xx doesn't drown its parallel siblings as
|
||||
* unhandled rejections — every failure surfaces in one labeled message.
|
||||
*/
|
||||
export type LabeledSettled = {
|
||||
name: string
|
||||
result: PromiseSettledResult<unknown>
|
||||
}
|
||||
|
||||
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
const failed = labeled.filter(
|
||||
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
|
||||
)
|
||||
if (failed.length === 0) return null
|
||||
|
||||
const reasons = Array.from(
|
||||
failed
|
||||
.map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
|
||||
.reduce((grouped, failure) => {
|
||||
grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
|
||||
return grouped
|
||||
}, new Map<string, string[]>())
|
||||
.entries(),
|
||||
)
|
||||
.map(([message, names]) =>
|
||||
names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
|
||||
)
|
||||
.join("; ")
|
||||
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||
const err = new Error(summary)
|
||||
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||
return err
|
||||
}
|
||||
|
||||
function reasonMessage(reason: unknown): string {
|
||||
const formatted = cliErrorMessage(reason)
|
||||
if (formatted) return formatted
|
||||
|
||||
if (reason instanceof Error) return reason.message
|
||||
if (typeof reason === "string") return reason
|
||||
if (reason && typeof reason === "object") {
|
||||
const obj = reason as { message?: unknown; name?: unknown }
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.name === "string") return obj.name
|
||||
}
|
||||
return String(reason)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export type Exit = (reason?: unknown) => void
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { exit: Exit }) => input.exit,
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEvent } from "./event"
|
||||
import type {
|
||||
Event,
|
||||
ReferenceInfo,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
@@ -10,6 +11,8 @@ import type {
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useProject } from "./project"
|
||||
import { createEffect } from "solid-js"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
@@ -60,12 +63,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
messages: {
|
||||
[sessionID: string]: SessionMessage[]
|
||||
}
|
||||
reference: ReferenceInfo[]
|
||||
}>({
|
||||
messages: {},
|
||||
reference: [],
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const applied = new Set<string>()
|
||||
const buffering = new Map<string, Event[]>()
|
||||
const syncing = new Map<string, Promise<void>>()
|
||||
@@ -117,6 +123,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
return result
|
||||
}
|
||||
|
||||
async function syncReferences(workspace = project.workspace.current()) {
|
||||
const result = await sdk.client.v2.reference.list({ location: { workspace } })
|
||||
if (workspace !== project.workspace.current()) return
|
||||
setStore("reference", reconcile(result.data?.data ?? []))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
project.workspace.current()
|
||||
void syncReferences()
|
||||
})
|
||||
|
||||
function apply(event: Event) {
|
||||
switch (event.type) {
|
||||
case "session.next.agent.switched":
|
||||
@@ -147,7 +164,6 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
references: event.properties.prompt.references,
|
||||
time: { created: event.properties.timestamp },
|
||||
})
|
||||
})
|
||||
@@ -163,7 +179,6 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
references: event.properties.prompt.references,
|
||||
time: { created: event.properties.timeCreated },
|
||||
})
|
||||
})
|
||||
@@ -418,6 +433,9 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
break
|
||||
case "reference.updated":
|
||||
void syncReferences()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,11 @@ import { useEvent } from "./event"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTuiStartup } from "./runtime"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import { aggregateFailures } from "./aggregate-failures"
|
||||
import { useKV } from "./kv"
|
||||
import { destroyRenderer } from "../util/renderer"
|
||||
|
||||
const emptyConsoleState: ConsoleState = {
|
||||
consoleManagedProviders: [],
|
||||
@@ -424,7 +422,7 @@ export const {
|
||||
}
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
const args = useArgs()
|
||||
|
||||
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||
@@ -442,23 +440,14 @@ export const {
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
|
||||
{ name: "config.providers", promise: providersPromise },
|
||||
{ name: "provider.list", promise: providerListPromise },
|
||||
{ name: "app.agents", promise: agentsPromise },
|
||||
{ name: "config.get", promise: configPromise },
|
||||
{ name: "project.sync", promise: projectPromise },
|
||||
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
|
||||
]
|
||||
|
||||
await Promise.allSettled(blockingRequests.map((r) => r.promise))
|
||||
.then((settled) => {
|
||||
// Surface every failed endpoint in one labeled message instead of
|
||||
// letting the first rejection drown its siblings as unhandled
|
||||
// rejections.
|
||||
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
|
||||
if (failure) throw failure
|
||||
})
|
||||
await Promise.all([
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
])
|
||||
.then(async () => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
@@ -523,7 +512,7 @@ export const {
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
if (fatal) {
|
||||
destroyRenderer(renderer)
|
||||
exit(e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import HomeFooter from "./home/footer"
|
||||
import HomeTips from "./home/tips"
|
||||
import SessionSwitcher from "./session"
|
||||
import SidebarContext from "./sidebar/context"
|
||||
import SidebarFiles from "./sidebar/files"
|
||||
import SidebarFooter from "./sidebar/footer"
|
||||
@@ -22,7 +21,6 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
|
||||
export function createBuiltinPlugins(options: {
|
||||
experimentalEventSystem: boolean
|
||||
experimentalSessionSwitcher: boolean
|
||||
}): BuiltinTuiPlugin[] {
|
||||
return [
|
||||
HomeFooter,
|
||||
@@ -38,6 +36,5 @@ export function createBuiltinPlugins(options: {
|
||||
WhichKey,
|
||||
DiffViewer,
|
||||
...(options.experimentalEventSystem ? [SessionV2Debug] : []),
|
||||
...(options.experimentalSessionSwitcher ? [SessionSwitcher] : []),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useCommandShortcut } from "../../keymap"
|
||||
import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogSessionDeleteFailed } from "../../component/dialog-session-delete-failed"
|
||||
import {
|
||||
openWorkspaceSelect,
|
||||
type WorkspaceSelection,
|
||||
warpWorkspaceSession,
|
||||
} from "../../component/dialog-workspace-create"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane"
|
||||
import { relativeTime } from "./util"
|
||||
|
||||
export function SessionSwitcherDialog() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const toast = useToast()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("session.delete")
|
||||
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
||||
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
||||
let select: DialogSelectRef<string> | undefined
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
async (input) => {
|
||||
if (!input.query) return undefined
|
||||
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal<string | undefined>(
|
||||
undefined,
|
||||
150,
|
||||
)
|
||||
const focusedSessionInfo = createMemo(() => {
|
||||
const id = focusedSession()
|
||||
if (!id) return undefined
|
||||
return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id)
|
||||
})
|
||||
|
||||
function recoverFailed(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
||||
const workspace = project.workspace.get(session.workspaceID!)
|
||||
const list = () => dialog.replace(() => <SessionSwitcherDialog />)
|
||||
const warp = async (selection: WorkspaceSelection) => {
|
||||
const workspaceID = await (async () => {
|
||||
if (selection.type === "none") return null
|
||||
if (selection.type === "existing") return selection.workspaceID
|
||||
const result = await sdk.client.experimental.workspace
|
||||
.create({ type: selection.workspaceType, branch: null })
|
||||
.catch(() => undefined)
|
||||
const created = result?.data
|
||||
if (!created) {
|
||||
toast.show({
|
||||
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await project.workspace.sync()
|
||||
return created.id
|
||||
})()
|
||||
if (workspaceID === undefined) return
|
||||
await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID: session.workspaceID,
|
||||
workspaceID,
|
||||
sessionID: session.id,
|
||||
copyChanges: false,
|
||||
done: list,
|
||||
})
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogSessionDeleteFailed
|
||||
session={session.title}
|
||||
workspace={workspace?.name ?? session.workspaceID!}
|
||||
onDone={list}
|
||||
onDelete={async () => {
|
||||
const current = currentSessionID()
|
||||
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.session.refresh()
|
||||
if (search()) await refetch()
|
||||
if (info?.workspaceID === session.workspaceID) {
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
return true
|
||||
}}
|
||||
onRestore={() => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
onSelect: (selection) => {
|
||||
void warp(selection)
|
||||
},
|
||||
})
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
||||
return sessionsList
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
}
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const quickSwitchHint = createMemo(() => {
|
||||
const first = quickSwitch1()
|
||||
const last = quickSwitch9()
|
||||
if (!first || !last) return undefined
|
||||
return quickSwitchRange(first, last)
|
||||
})
|
||||
const options = createMemo<DialogSelectOption<string>[]>(() => {
|
||||
const today = new Date().toDateString()
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => [x.id, x]),
|
||||
)
|
||||
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
||||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
||||
|
||||
function buildOption(id: string, category: string): DialogSelectOption<string> | undefined {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
const footer = relativeTime(x.time.updated)
|
||||
const isWorktree = workspace?.type === "worktree"
|
||||
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy" || status?.type === "retry"
|
||||
const slot = slotByID.get(x.id)
|
||||
const gutter =
|
||||
slot !== undefined || isWorking
|
||||
? () => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={slot !== undefined}>
|
||||
<text fg={theme.accent}>{slot}</text>
|
||||
</Show>
|
||||
<Show when={isWorking}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
: undefined
|
||||
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
|
||||
return {
|
||||
title: titleText,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
categoryView:
|
||||
category === "Pinned" ? (
|
||||
<text>
|
||||
<span style={{ fg: theme.accent }}>
|
||||
<b>Pinned</b>
|
||||
</span>
|
||||
<Show when={quickSwitchHint()}>
|
||||
{(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
|
||||
</Show>
|
||||
</text>
|
||||
) : undefined,
|
||||
footer,
|
||||
gutter,
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = displayOrder
|
||||
.filter((id) => !pinnedSet.has(id))
|
||||
.map((id) => {
|
||||
const x = sessionMap.get(id)
|
||||
if (!x) return undefined
|
||||
const label = new Date(x.time.updated).toDateString()
|
||||
return buildOption(id, label === today ? "Today" : label)
|
||||
})
|
||||
.filter((x): x is DialogSelectOption<string> => x !== undefined)
|
||||
|
||||
return [
|
||||
...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption<string> => x !== undefined),
|
||||
...remaining,
|
||||
]
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on([options, currentSessionID], ([items, current]) => {
|
||||
const selected = untrack(() => select?.selected)
|
||||
const selectedID = selected && items.some((item) => item.value === selected.value) ? selected.value : undefined
|
||||
const currentID = current && items.some((item) => item.value === current) ? current : undefined
|
||||
setFocusedSession(selectedID ?? currentID ?? items[0]?.value)
|
||||
}),
|
||||
)
|
||||
|
||||
const showPreview = createMemo(() => dimensions().width >= 100)
|
||||
const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
|
||||
|
||||
createEffect(() => {
|
||||
dialog.setSize(showPreview() ? "xlarge" : "large")
|
||||
})
|
||||
|
||||
const list = (
|
||||
<DialogSelect
|
||||
ref={(value) => (select = value)}
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={(option) => {
|
||||
setToDelete(undefined)
|
||||
scheduleFocused(option.value)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "session.pin.toggle",
|
||||
title: "pin/unpin",
|
||||
onTrigger: (option: { value: string }) => {
|
||||
local.session.togglePin(option.value)
|
||||
queueMicrotask(() => select?.moveTo(option.value))
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const session = sessions().find((item) => item.id === option.value)
|
||||
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
|
||||
|
||||
try {
|
||||
const result = await sdk.client.session.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
if (result.error) {
|
||||
if (session?.workspaceID) {
|
||||
recoverFailed(session)
|
||||
} else {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete session",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (session?.workspaceID) {
|
||||
recoverFailed(session)
|
||||
} else {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete session",
|
||||
message: errorMessage(err),
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
if (status && status !== "connected") {
|
||||
await sync.session.refresh()
|
||||
}
|
||||
if (search()) await refetch()
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "session.rename",
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<box flexDirection="row" width="100%" height={height()}>
|
||||
<box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
|
||||
{list}
|
||||
</box>
|
||||
<Show when={showPreview()}>
|
||||
<box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
|
||||
<box flexGrow={1} flexShrink={1} flexDirection="column">
|
||||
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function quickSwitchRange(first: string, last: string) {
|
||||
const prefix = first.slice(0, -1)
|
||||
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
||||
return `${first} through ${last}`
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { SessionSwitcherDialog } from "./dialog"
|
||||
|
||||
const id = "internal:session-switcher"
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.keymap.registerLayer({
|
||||
priority: 1000,
|
||||
commands: [
|
||||
{
|
||||
name: "session.list",
|
||||
title: "Switch session",
|
||||
category: "Session",
|
||||
namespace: "palette",
|
||||
suggested: () => api.state.session.count() > 0,
|
||||
slashName: "sessions",
|
||||
slashAliases: ["resume", "continue"],
|
||||
run() {
|
||||
api.ui.dialog.replace(() => <SessionSwitcherDialog />)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -1,288 +0,0 @@
|
||||
import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
|
||||
import type { Message, Part, Session as SdkSession } from "@opencode-ai/sdk/v2"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
|
||||
|
||||
type WithParts = { info: Message; parts: Part[] }
|
||||
|
||||
type Sdk = ReturnType<typeof useSDK>
|
||||
type Sync = ReturnType<typeof useSync>
|
||||
|
||||
const messageCache = new Map<string, Promise<WithParts[]>>()
|
||||
|
||||
function cacheKey(sessionID: string, version: number) {
|
||||
return `${sessionID}:${version}`
|
||||
}
|
||||
|
||||
function hydrateFromSync(sync: Sync, sessionID: string): WithParts[] | undefined {
|
||||
const infos = sync.data.message[sessionID]
|
||||
if (!infos || infos.length === 0) return undefined
|
||||
return infos.map((info) => ({ info, parts: sync.data.part[info.id] ?? [] }))
|
||||
}
|
||||
|
||||
function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<WithParts[]> {
|
||||
const key = cacheKey(sessionID, version)
|
||||
const cached = messageCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = sdk.client.session
|
||||
.messages({ sessionID, limit: 50 })
|
||||
.then((res) => {
|
||||
if (res.error) throw res.error
|
||||
return (res.data as WithParts[] | undefined) ?? []
|
||||
})
|
||||
.catch((error) => {
|
||||
messageCache.delete(key)
|
||||
throw error
|
||||
})
|
||||
messageCache.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
|
||||
for (const id of sessionIDs) {
|
||||
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
|
||||
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function createLeadingTrailingSignal<T>(initial: T, ms: number): [Accessor<T>, (v: T) => void, (v: T) => void] {
|
||||
const [get, set] = createSignal(initial)
|
||||
const setNow = (v: T) => set(() => v)
|
||||
const schedule = leadingAndTrailing(debounce, setNow, ms)
|
||||
return [get, setNow, schedule]
|
||||
}
|
||||
|
||||
export function SessionPreviewPane(props: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
session?: Accessor<SdkSession | undefined>
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
|
||||
const session = createMemo(() => {
|
||||
const provided = props.session?.()
|
||||
if (provided) return provided
|
||||
const id = props.sessionID()
|
||||
if (!id) return undefined
|
||||
return sync.data.session.find((s) => s.id === id)
|
||||
})
|
||||
|
||||
const status = createMemo(() => {
|
||||
const id = props.sessionID()
|
||||
if (!id) return undefined
|
||||
return sync.data.session_status?.[id]?.type
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const top = sync.data.session
|
||||
.filter((s) => s.parentID === undefined)
|
||||
.slice()
|
||||
.sort((a, b) => b.time.updated - a.time.updated)
|
||||
.slice(0, 5)
|
||||
.map((s) => s.id)
|
||||
prefetchPreviews(sdk, sync, top)
|
||||
})
|
||||
|
||||
const syncedMessages = createMemo(() => {
|
||||
const id = props.sessionID()
|
||||
if (!id) return undefined
|
||||
return hydrateFromSync(sync, id)
|
||||
})
|
||||
|
||||
const [fetchedMessages] = createResource(
|
||||
() => {
|
||||
const id = props.sessionID()
|
||||
if (!id || syncedMessages()) return undefined
|
||||
return { sessionID: id, version: session()?.time.updated ?? 0 }
|
||||
},
|
||||
async (input) => loadMessages(sdk, input.sessionID, input.version),
|
||||
)
|
||||
|
||||
const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
|
||||
|
||||
const exchange = createMemo(() => {
|
||||
const items = messages()
|
||||
if (!items || items.length === 0) return undefined
|
||||
const sorted = items.toSorted((a, b) => messageCreated(a) - messageCreated(b))
|
||||
const user = sorted.findLast((item) => messageRole(item) === "user")
|
||||
const assistant = user
|
||||
? sorted.findLast((item) => messageRole(item) === "assistant" && messageParentID(item) === user.info.id)
|
||||
: sorted.findLast((item) => messageRole(item) === "assistant")
|
||||
return { user, assistant }
|
||||
})
|
||||
|
||||
const loading = createMemo(() => fetchedMessages.loading && !exchange())
|
||||
|
||||
const statusLabel = createMemo(() => {
|
||||
const s = status()
|
||||
if (s === "busy") return "working"
|
||||
if (s === "retry") return "retrying"
|
||||
return "idle"
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
height={maxHeight()}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show
|
||||
when={session()}
|
||||
fallback={
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
No session selected
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(s) => (
|
||||
<>
|
||||
<Header session={s()} statusLabel={statusLabel()} />
|
||||
<Show when={loading()}>
|
||||
<Spinner>loading preview...</Spinner>
|
||||
</Show>
|
||||
<Show
|
||||
when={exchange()}
|
||||
fallback={
|
||||
<Show when={!loading()}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
|
||||
</text>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(ex) => <Exchange exchange={ex()} />}
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function messageRole(item: WithParts) {
|
||||
return (item.info as { role?: string }).role
|
||||
}
|
||||
|
||||
function messageCreated(item: WithParts) {
|
||||
return (item.info.time as { created?: number }).created ?? 0
|
||||
}
|
||||
|
||||
function messageParentID(item: WithParts) {
|
||||
return (item.info as { parentID?: string }).parentID
|
||||
}
|
||||
|
||||
const ROW_WIDTH = 40
|
||||
|
||||
function Header(props: { session: SdkSession; statusLabel: string }) {
|
||||
const { theme } = useTheme()
|
||||
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
|
||||
const statusRest = createMemo(() => {
|
||||
const joined = ` · ${relativeTime(props.session.time.updated)}`
|
||||
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
<Row height={1}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD} wrapMode="none" overflow="hidden">
|
||||
{title()}
|
||||
</text>
|
||||
</Row>
|
||||
<Row height={1}>
|
||||
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
|
||||
<span>{props.statusLabel}</span>
|
||||
<span>{statusRest()}</span>
|
||||
</text>
|
||||
</Row>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Row(props: { height: number; children: JSX.Element }) {
|
||||
return (
|
||||
<box height={props.height} flexShrink={0} overflow="hidden">
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const PROMPT_MAX_CHARS = 240
|
||||
const REPLY_MAX_LINES = 12
|
||||
const REPLY_MAX_CHARS = 800
|
||||
|
||||
function Exchange(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const userText = createMemo(() =>
|
||||
props.exchange.user ? extractMessageText(props.exchange.user.parts, PROMPT_MAX_CHARS) : undefined,
|
||||
)
|
||||
const assistantMarkdown = createMemo(() =>
|
||||
props.exchange.assistant
|
||||
? extractMessageMarkdown(props.exchange.assistant.parts, REPLY_MAX_LINES, REPLY_MAX_CHARS)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<Show when={userText()}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
<span style={{ fg: theme.textMuted }}>› </span>
|
||||
{userText()!}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={assistantMarkdown()}>
|
||||
<markdown
|
||||
content={assistantMarkdown()!}
|
||||
syntaxStyle={syntax()}
|
||||
streaming={false}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "columns" }}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.backgroundPanel}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!userText() && !assistantMarkdown()}>
|
||||
<NonTextHint exchange={props.exchange} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
|
||||
const { theme } = useTheme()
|
||||
const summary = createMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const item of [props.exchange.user, props.exchange.assistant]) {
|
||||
if (!item) continue
|
||||
for (const part of item.parts) {
|
||||
counts[part.type] = (counts[part.type] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.map(([k, n]) => `${n} ${k}`)
|
||||
.join(", ")
|
||||
})
|
||||
return (
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
<Show when={summary()} fallback="No text content in the latest messages">
|
||||
Latest exchange has no text content ({summary()})
|
||||
</Show>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Part } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
|
||||
export function relativeTime(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp
|
||||
if (diff < 0) return "just now"
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
const d = new Date(timestamp)
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
export function extractMessageText(parts: readonly Part[], maxLength: number): string {
|
||||
const joined = collectTextParts(parts).join(" ").replace(/\s+/g, " ").trim()
|
||||
return Locale.truncate(joined, maxLength)
|
||||
}
|
||||
|
||||
export function extractMessageMarkdown(parts: readonly Part[], maxLines: number, maxChars: number): string {
|
||||
const joined = collectTextParts(parts).join("\n\n").trim()
|
||||
if (!joined) return joined
|
||||
|
||||
let truncated = joined
|
||||
const lines = truncated.split("\n")
|
||||
if (lines.length > maxLines) {
|
||||
truncated = lines.slice(0, maxLines).join("\n")
|
||||
}
|
||||
if (truncated.length > maxChars) {
|
||||
truncated = truncated.slice(0, maxChars).trimEnd()
|
||||
}
|
||||
if (truncated.length === joined.length) return joined
|
||||
// Close any unterminated fenced code block so the renderer doesn't keep
|
||||
// the rest of the panel in "code mode".
|
||||
const fences = (truncated.match(/^```/gm) ?? []).length
|
||||
if (fences % 2 === 1) truncated += "\n```"
|
||||
return truncated + "\n\n…"
|
||||
}
|
||||
|
||||
function collectTextParts(parts: readonly Part[]): string[] {
|
||||
const chunks: string[] = []
|
||||
for (const part of parts) {
|
||||
if (part.type !== "text") continue
|
||||
const p = part as Part & { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
if (p.synthetic || p.ignored) continue
|
||||
if (!p.text) continue
|
||||
chunks.push(p.text)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -72,7 +72,6 @@ export interface DialogSelectOption<T = any> {
|
||||
export type DialogSelectRef<T> = {
|
||||
filter: string
|
||||
filtered: DialogSelectOption<T>[]
|
||||
selected: DialogSelectOption<T> | undefined
|
||||
moveTo(value: T): void
|
||||
}
|
||||
|
||||
@@ -409,9 +408,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
get filtered() {
|
||||
return filtered()
|
||||
},
|
||||
get selected() {
|
||||
return selected()
|
||||
},
|
||||
moveTo(value) {
|
||||
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
|
||||
if (index >= 0) moveTo(index, true)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
|
||||
export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
|
||||
renderer.setTerminalTitle("")
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,52 @@ function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event)
|
||||
events.emit(event)
|
||||
}
|
||||
|
||||
test("sync v2 refreshes references after updates", async () => {
|
||||
const events = createEventSource()
|
||||
let requests = 0
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/reference") return
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||
})
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
await wait(() => requests === 1)
|
||||
events.emit(global({ id: "evt_reference_1", type: "reference.updated", properties: {} }))
|
||||
await wait(() => sync.data.reference.length === 1)
|
||||
expect(sync.data.reference[0]?.name).toBe("docs")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Regression test for the TUI bootstrap aggregation helper. Replaces the
|
||||
* pre-fix Promise.all behavior where the first rejection drowned every
|
||||
* sibling endpoint's failure as an unhandled rejection.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { aggregateFailures } from "@opencode-ai/tui/context/aggregate-failures"
|
||||
|
||||
describe("aggregateFailures", () => {
|
||||
test("returns null when every result is fulfilled", () => {
|
||||
expect(
|
||||
aggregateFailures([
|
||||
{ name: "config", result: { status: "fulfilled", value: 1 } },
|
||||
{ name: "providers", result: { status: "fulfilled", value: 2 } },
|
||||
]),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test("names the failed endpoint when one rejects", () => {
|
||||
const err = aggregateFailures([
|
||||
{ name: "config", result: { status: "fulfilled", value: 1 } },
|
||||
{
|
||||
name: "providers",
|
||||
result: { status: "rejected", reason: new Error("Service unavailable") },
|
||||
},
|
||||
])
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err!.message).toContain("1 of 2")
|
||||
expect(err!.message).toContain("providers: Service unavailable")
|
||||
})
|
||||
|
||||
test("names every failed endpoint when multiple reject", () => {
|
||||
const err = aggregateFailures([
|
||||
{ name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } },
|
||||
{ name: "providers", result: { status: "fulfilled", value: 1 } },
|
||||
{ name: "agents", result: { status: "rejected", reason: { message: "boom" } } },
|
||||
])
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err!.message).toContain("2 of 3")
|
||||
expect(err!.message).toContain("config: 400 Bad Request")
|
||||
expect(err!.message).toContain("agents: boom")
|
||||
})
|
||||
|
||||
test("formats structured config errors hidden inside SDK error causes", () => {
|
||||
const configError = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "/tmp/opencode.json",
|
||||
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
|
||||
},
|
||||
}
|
||||
const err = aggregateFailures([
|
||||
{
|
||||
name: "config.get",
|
||||
result: {
|
||||
status: "rejected",
|
||||
reason: new Error("ConfigInvalidError", {
|
||||
cause: {
|
||||
body: configError,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json")
|
||||
expect(err!.message).toContain("Expected object provider.anthropic.options")
|
||||
})
|
||||
|
||||
test("deduplicates identical failure messages across startup requests", () => {
|
||||
const reason = new Error("same config problem")
|
||||
const err = aggregateFailures([
|
||||
{ name: "config.providers", result: { status: "rejected", reason } },
|
||||
{ name: "provider.list", result: { status: "rejected", reason } },
|
||||
{ name: "app.agents", result: { status: "rejected", reason } },
|
||||
{ name: "config.get", result: { status: "rejected", reason } },
|
||||
{ name: "project.sync", result: { status: "fulfilled", value: undefined } },
|
||||
])
|
||||
|
||||
expect(err!.message).toContain("4 of 5 requests failed: same config problem")
|
||||
expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get")
|
||||
expect(err!.message.match(/same config problem/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
test("attaches structured failure list under .cause", () => {
|
||||
const reason = new Error("nope")
|
||||
const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
|
||||
expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] })
|
||||
})
|
||||
|
||||
test("falls back to String() for opaque reasons", () => {
|
||||
const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }])
|
||||
expect(err!.message).toContain("x: 42")
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,7 @@ export function createFetch(override?: FetchHandler) {
|
||||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/project/current") return json({ id: "proj_test" })
|
||||
if (url.pathname === "/api/reference") return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/session") return json([])
|
||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||
|
||||
@@ -29,8 +29,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test src",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test": "bun test src --only-failures",
|
||||
"dev": "vite",
|
||||
"generate:tailwind": "bun run script/tailwind.ts",
|
||||
"generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts"
|
||||
|
||||
-19
@@ -13,32 +13,13 @@
|
||||
"outputs": [],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"test:ci": {
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"opencode#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"@opencode-ai/ui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/ui#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user