mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbdc9075b5 | |||
| ccba1c0df9 | |||
| 934935963d | |||
| f3912a2a8a | |||
| 45d58717a4 | |||
| 74e3155ef0 | |||
| 0af6c82563 | |||
| 686127f809 | |||
| 5256655c4d |
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
|||||||
return IS_MAC ? parts.join("") : parts.join("+")
|
return IS_MAC ? parts.join("") : parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeybindV2 takes an array instead of a string
|
|
||||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
|
||||||
return formatKeybindParts(config, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null) {
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof HTMLElement)) return false
|
if (!(target instanceof HTMLElement)) return false
|
||||||
if (target.isContentEditable) return true
|
if (target.isContentEditable) return true
|
||||||
|
|||||||
@@ -286,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
children: tree.children,
|
children: tree.children,
|
||||||
expand: tree.expandDir,
|
expand: tree.expandDir,
|
||||||
collapse: tree.collapseDir,
|
collapse: tree.collapseDir,
|
||||||
toggle(input: string) {
|
|
||||||
if (tree.dirState(input)?.expanded) {
|
|
||||||
tree.collapseDir(input)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tree.expandDir(input)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
get,
|
get,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -153,18 +153,6 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeProject(project: Project) {
|
|
||||||
if (!project.icon?.url && !project.icon?.override) return project
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
icon: {
|
|
||||||
...project.icon,
|
|
||||||
url: undefined,
|
|
||||||
override: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
|
|||||||
@@ -753,9 +753,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||||
show() {
|
|
||||||
setStore("mobileSidebar", "opened", true)
|
|
||||||
},
|
|
||||||
hide() {
|
hide() {
|
||||||
setStore("mobileSidebar", "opened", false)
|
setStore("mobileSidebar", "opened", false)
|
||||||
},
|
},
|
||||||
@@ -961,33 +958,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (current.reviewOpen.includes(path)) return
|
if (current.reviewOpen.includes(path)) return
|
||||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||||
},
|
},
|
||||||
closePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current) return
|
|
||||||
|
|
||||||
const index = current.indexOf(path)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"sessionView",
|
|
||||||
session,
|
|
||||||
"reviewOpen",
|
|
||||||
produce((draft) => {
|
|
||||||
if (!draft) return
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
togglePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current || !current.includes(path)) {
|
|
||||||
this.openPath(path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closePath(path)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ type TabsInput = {
|
|||||||
fileBrowser?: Accessor<boolean>
|
fileBrowser?: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
|
||||||
|
|
||||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||||
return input.opened && input.visible
|
return input.opened && input.visible
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PositiveInt, RelativePath } from "./schema"
|
import { PositiveInt, RelativePath } from "./schema"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
|
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
|
||||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
@@ -53,8 +53,6 @@ export interface Interface {
|
|||||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||||
@@ -76,8 +74,6 @@ const baseLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
find: search.find,
|
find: search.find,
|
||||||
glob: search.glob,
|
|
||||||
grep: search.grep,
|
|
||||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||||
const target = yield* resolve(input.path)
|
const target = yield* resolve(input.path)
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import {
|
|||||||
type DirItem,
|
type DirItem,
|
||||||
type DirSearchResult,
|
type DirSearchResult,
|
||||||
type FileItem,
|
type FileItem,
|
||||||
type GrepCursor,
|
|
||||||
type GrepMatch,
|
|
||||||
type GrepResult,
|
|
||||||
type InitOptions,
|
type InitOptions,
|
||||||
type MixedItem,
|
type MixedItem,
|
||||||
type MixedSearchResult,
|
type MixedSearchResult,
|
||||||
@@ -45,19 +42,6 @@ export interface MixedSearch {
|
|||||||
export type File = FileItem
|
export type File = FileItem
|
||||||
export type Directory = DirItem
|
export type Directory = DirItem
|
||||||
export type Mixed = MixedItem
|
export type Mixed = MixedItem
|
||||||
export type Cursor = GrepCursor | null
|
|
||||||
export type Hit = GrepMatch
|
|
||||||
|
|
||||||
export interface Grep {
|
|
||||||
items: GrepResult["items"]
|
|
||||||
totalMatched: number
|
|
||||||
totalFilesSearched: number
|
|
||||||
totalFiles: number
|
|
||||||
filteredFileCount: number
|
|
||||||
nextCursor: Cursor
|
|
||||||
regexFallbackError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Picker {
|
export interface Picker {
|
||||||
destroy(): void
|
destroy(): void
|
||||||
isScanning(): boolean
|
isScanning(): boolean
|
||||||
@@ -71,14 +55,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<Search>
|
): Result<Search>
|
||||||
glob(
|
|
||||||
pattern: string,
|
|
||||||
opts?: {
|
|
||||||
currentFile?: string
|
|
||||||
pageIndex?: number
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Search>
|
|
||||||
directorySearch(
|
directorySearch(
|
||||||
query: string,
|
query: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
@@ -95,18 +71,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<MixedSearch>
|
): Result<MixedSearch>
|
||||||
grep(
|
|
||||||
query: string,
|
|
||||||
opts?: {
|
|
||||||
mode?: "plain" | "regex" | "fuzzy"
|
|
||||||
maxMatchesPerFile?: number
|
|
||||||
timeBudgetMs?: number
|
|
||||||
beforeContext?: number
|
|
||||||
afterContext?: number
|
|
||||||
cursor?: Cursor
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Grep>
|
|
||||||
trackQuery(query: string, file: string): Result<boolean>
|
trackQuery(query: string, file: string): Result<boolean>
|
||||||
getHistoricalQuery(offset: number): Result<string | null>
|
getHistoricalQuery(offset: number): Result<string | null>
|
||||||
}
|
}
|
||||||
@@ -127,10 +91,8 @@ export function create(opts: Init): Result<Picker> {
|
|||||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||||
glob: (pattern, next) => pick.glob(pattern, next),
|
|
||||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||||
grep: (query, next) => pick.grep(query, next),
|
|
||||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import type {
|
|||||||
DirItem,
|
DirItem,
|
||||||
DirSearchResult,
|
DirSearchResult,
|
||||||
FileItem,
|
FileItem,
|
||||||
GrepCursor,
|
|
||||||
GrepMatch,
|
|
||||||
GrepResult,
|
|
||||||
InitOptions,
|
InitOptions,
|
||||||
MixedItem,
|
MixedItem,
|
||||||
MixedSearchResult,
|
MixedSearchResult,
|
||||||
@@ -42,19 +39,6 @@ export interface MixedSearch {
|
|||||||
export type File = FileItem
|
export type File = FileItem
|
||||||
export type Directory = DirItem
|
export type Directory = DirItem
|
||||||
export type Mixed = MixedItem
|
export type Mixed = MixedItem
|
||||||
export type Cursor = GrepCursor | null
|
|
||||||
export type Hit = GrepMatch
|
|
||||||
|
|
||||||
export interface Grep {
|
|
||||||
items: GrepResult["items"]
|
|
||||||
totalMatched: number
|
|
||||||
totalFilesSearched: number
|
|
||||||
totalFiles: number
|
|
||||||
filteredFileCount: number
|
|
||||||
nextCursor: Cursor
|
|
||||||
regexFallbackError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Picker {
|
export interface Picker {
|
||||||
destroy(): void
|
destroy(): void
|
||||||
isScanning(): boolean
|
isScanning(): boolean
|
||||||
@@ -68,14 +52,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<Search>
|
): Result<Search>
|
||||||
glob(
|
|
||||||
pattern: string,
|
|
||||||
opts?: {
|
|
||||||
currentFile?: string
|
|
||||||
pageIndex?: number
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Search>
|
|
||||||
directorySearch(
|
directorySearch(
|
||||||
query: string,
|
query: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
@@ -92,18 +68,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<MixedSearch>
|
): Result<MixedSearch>
|
||||||
grep(
|
|
||||||
query: string,
|
|
||||||
opts?: {
|
|
||||||
mode?: "plain" | "regex" | "fuzzy"
|
|
||||||
maxMatchesPerFile?: number
|
|
||||||
timeBudgetMs?: number
|
|
||||||
beforeContext?: number
|
|
||||||
afterContext?: number
|
|
||||||
cursor?: Cursor
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Grep>
|
|
||||||
trackQuery(query: string, file: string): Result<boolean>
|
trackQuery(query: string, file: string): Result<boolean>
|
||||||
getHistoricalQuery(offset: number): Result<string | null>
|
getHistoricalQuery(offset: number): Result<string | null>
|
||||||
}
|
}
|
||||||
@@ -125,10 +89,8 @@ export function create(opts: Init): Result<Picker> {
|
|||||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||||
glob: (pattern, next) => pick.glob(pattern, next),
|
|
||||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||||
grep: (query, next) => pick.grep(query, next),
|
|
||||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher"
|
|||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -44,7 +43,7 @@ const layer = Layer.effect(
|
|||||||
const config = (yield* configService.entries())
|
const config = (yield* configService.entries())
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||||
const home = path.resolve(location.directory) === path.resolve(os.homedir())
|
const home = Protected.isHome(location.directory)
|
||||||
|
|
||||||
if (!home && location.vcs) {
|
if (!home && location.vcs) {
|
||||||
const updates = yield* watcher.subscribe({
|
const updates = yield* watcher.subscribe({
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import path from "path"
|
|||||||
|
|
||||||
const home = os.homedir()
|
const home = os.homedir()
|
||||||
|
|
||||||
|
export function isHome(directory: string) {
|
||||||
|
return path.resolve(directory) === path.resolve(home)
|
||||||
|
}
|
||||||
|
|
||||||
const DARWIN_HOME = [
|
const DARWIN_HOME = [
|
||||||
"Music",
|
"Music",
|
||||||
"Pictures",
|
"Pictures",
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import { Context, Effect, Layer, Schema, Scope } from "effect"
|
|||||||
import { Fff } from "#fff"
|
import { Fff } from "#fff"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { Ripgrep } from "../ripgrep"
|
||||||
import { RelativePath } from "../schema"
|
import { RelativePath } from "../schema"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
|
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
|
||||||
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
|
|
||||||
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Options = Schema.Struct({
|
export const Options = Schema.Struct({
|
||||||
@@ -27,17 +25,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
|
|||||||
export const ripgrepLayer = Layer.effect(
|
export const ripgrepLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
const directories = new Set<string>()
|
const directories = new Set<string>()
|
||||||
|
const home = Protected.isHome(location.directory)
|
||||||
yield* ripgrep
|
yield* ripgrep
|
||||||
.find({
|
.find({
|
||||||
cwd: location.directory,
|
cwd: location.directory,
|
||||||
pattern: "*",
|
pattern: "*",
|
||||||
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||||
|
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||||
onEntry: (entry) =>
|
onEntry: (entry) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
files.push(entry.path)
|
files.push(entry.path)
|
||||||
@@ -47,57 +46,6 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||||
return Service.of({
|
return Service.of({
|
||||||
glob: (input) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = path.resolve(location.directory, input.path ?? ".")
|
|
||||||
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
|
||||||
const cwd = info.type === "File" ? path.dirname(target) : target
|
|
||||||
return yield* ripgrep
|
|
||||||
.glob({
|
|
||||||
cwd,
|
|
||||||
pattern: input.pattern,
|
|
||||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((entry) =>
|
|
||||||
FileSystem.Entry.make({
|
|
||||||
...entry,
|
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
grep: (input) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = path.resolve(location.directory, input.path ?? ".")
|
|
||||||
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
|
||||||
const cwd = info.type === "File" ? path.dirname(target) : target
|
|
||||||
return yield* ripgrep
|
|
||||||
.grep({
|
|
||||||
cwd,
|
|
||||||
pattern: input.pattern,
|
|
||||||
file: info.type === "File" ? path.basename(target) : undefined,
|
|
||||||
include: input.include,
|
|
||||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((match) =>
|
|
||||||
FileSystem.Match.make({
|
|
||||||
...match,
|
|
||||||
entry: FileSystem.Entry.make({
|
|
||||||
...match.entry,
|
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const items =
|
const items =
|
||||||
@@ -139,55 +87,10 @@ export const fffLayer = Layer.effect(
|
|||||||
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
|
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
|
||||||
return Service.of({
|
return Service.of({
|
||||||
find: () => Effect.succeed([]),
|
find: () => Effect.succeed([]),
|
||||||
glob: () => Effect.succeed([]),
|
|
||||||
grep: () => Effect.succeed([]),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||||
return Service.of({
|
return Service.of({
|
||||||
glob: (input) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
|
||||||
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
|
||||||
})
|
|
||||||
if (!found.ok) throw found.error
|
|
||||||
return found.value.items.map((item) =>
|
|
||||||
FileSystem.Entry.make({
|
|
||||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
|
||||||
type: "file",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
grep: (input) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
|
||||||
const found = result.value.grep(
|
|
||||||
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
|
|
||||||
.filter((value) => value !== undefined)
|
|
||||||
.join(" "),
|
|
||||||
{ mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 },
|
|
||||||
)
|
|
||||||
if (!found.ok) throw found.error
|
|
||||||
return found.value.items.map((match) => {
|
|
||||||
const bytes = Buffer.from(match.lineContent)
|
|
||||||
return FileSystem.Match.make({
|
|
||||||
entry: FileSystem.Entry.make({
|
|
||||||
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
|
||||||
type: "file",
|
|
||||||
}),
|
|
||||||
line: match.lineNumber,
|
|
||||||
offset: match.byteOffset,
|
|
||||||
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
|
|
||||||
submatches: match.matchRanges.map(([start, end]) => ({
|
|
||||||
text: bytes.subarray(start, end).toString("utf8"),
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
||||||
@@ -232,18 +135,19 @@ export const fffLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer = (options?: Options) => Layer.unwrap(
|
export const layer = (options?: Options) =>
|
||||||
Effect.gen(function* () {
|
Layer.unwrap(
|
||||||
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
Effect.gen(function* () {
|
||||||
return ripgrepLayer
|
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
||||||
const location = yield* Location.Service
|
return ripgrepLayer
|
||||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
const location = yield* Location.Service
|
||||||
return location.vcs ? fffLayer : ripgrepLayer
|
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
||||||
}),
|
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export function configured(options?: Options) {
|
export function configured(options?: Options) {
|
||||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = configured()
|
export const node = configured()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
|
|||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
|
||||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function sessionHook<Name extends keyof SessionHooks>(
|
|
||||||
name: Name,
|
|
||||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
|
||||||
): Promise<Registration>
|
|
||||||
function sessionHook(
|
|
||||||
...registration: {
|
|
||||||
[Name in keyof SessionHooks]: [
|
|
||||||
name: Name,
|
|
||||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
|
||||||
]
|
|
||||||
}[keyof SessionHooks]
|
|
||||||
) {
|
|
||||||
if (registration[0] !== "http")
|
|
||||||
return register(
|
|
||||||
host.session.hook(registration[0], (event) =>
|
|
||||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return register(
|
|
||||||
host.session.hook("http", (event) => {
|
|
||||||
const middlewares: SessionHttpMiddleware[] = []
|
|
||||||
const output: SessionHttp = {
|
|
||||||
...event,
|
|
||||||
use: (item) => {
|
|
||||||
middlewares.push(item)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
|
||||||
Effect.flatMap(() =>
|
|
||||||
Effect.forEach(
|
|
||||||
middlewares,
|
|
||||||
(item) =>
|
|
||||||
event.use((input, next) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: (signal) => {
|
|
||||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
|
||||||
return Promise.resolve(
|
|
||||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
|
||||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
|
||||||
return Effect.runPromiseWith(
|
|
||||||
context,
|
|
||||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ discard: true },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const context2: Context = {
|
const context2: Context = {
|
||||||
app: host.app,
|
app: host.app,
|
||||||
options: host.options,
|
options: host.options,
|
||||||
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: sessionHook,
|
hook: (name, callback) =>
|
||||||
|
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create(
|
host.session.create(
|
||||||
|
|||||||
@@ -225,14 +225,14 @@ export const OpenAIPlugin = define({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("http", (evt) =>
|
yield* ctx.session.hook("http.request", (evt) =>
|
||||||
evt.use((request, next) => {
|
Effect.sync(() => {
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||||
const url = new URL(request.url)
|
const url = new URL(evt.request.url)
|
||||||
request.headers.set("originator", "opencode")
|
evt.request.headers.set("originator", "opencode")
|
||||||
request.headers.set("session-id", evt.sessionID)
|
evt.request.headers.set("session-id", evt.sessionID)
|
||||||
if (url.origin !== "https://api.openai.com") return next(request)
|
if (url.origin !== "https://api.openai.com") return
|
||||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,16 +59,6 @@ export interface Interface {
|
|||||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||||
/**
|
|
||||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
|
||||||
*
|
|
||||||
* This exists while the old opencode project service and this core project
|
|
||||||
* service work together: core resolves the ID, while the old service still owns
|
|
||||||
* database migration and persistence. The old service should call this after it
|
|
||||||
* finishes migrating from `resolve().previous` to `resolve().id`; once project
|
|
||||||
* persistence moves into core, this separate bridge method can go away.
|
|
||||||
*/
|
|
||||||
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||||
@@ -268,11 +258,7 @@ const layer = Layer.effect(
|
|||||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||||
})
|
})
|
||||||
|
|
||||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
return Service.of({ list, directories, resolve })
|
||||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ list, directories, resolve, commit })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,6 @@ export type EnsureInput = {
|
|||||||
readonly branch?: string
|
readonly branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
|
|
||||||
"RepositoryCacheInvalidRepositoryError",
|
|
||||||
{
|
|
||||||
repository: Schema.String,
|
|
||||||
message: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
||||||
"RepositoryCacheInvalidBranchError",
|
"RepositoryCacheInvalidBranchError",
|
||||||
{
|
{
|
||||||
@@ -86,7 +78,6 @@ export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationE
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
export type Error =
|
export type Error =
|
||||||
| InvalidRepositoryError
|
|
||||||
| InvalidBranchError
|
| InvalidBranchError
|
||||||
| CloneFailedError
|
| CloneFailedError
|
||||||
| FetchFailedError
|
| FetchFailedError
|
||||||
@@ -103,7 +94,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Re
|
|||||||
|
|
||||||
export function isError(error: unknown): error is Error {
|
export function isError(error: unknown): error is Error {
|
||||||
return (
|
return (
|
||||||
error instanceof InvalidRepositoryError ||
|
|
||||||
error instanceof InvalidBranchError ||
|
error instanceof InvalidBranchError ||
|
||||||
error instanceof CloneFailedError ||
|
error instanceof CloneFailedError ||
|
||||||
error instanceof FetchFailedError ||
|
error instanceof FetchFailedError ||
|
||||||
@@ -114,13 +104,6 @@ export function isError(error: unknown): error is Error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
|
|
||||||
return yield* Effect.try({
|
|
||||||
try: () => Repository.parseRemote(repository),
|
|
||||||
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||||
return yield* Effect.try({
|
return yield* Effect.try({
|
||||||
try: () => Repository.validateBranch(branch),
|
try: () => Repository.validateBranch(branch),
|
||||||
|
|||||||
@@ -44,16 +44,6 @@ export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchErr
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
|
|
||||||
|
|
||||||
export function isError(error: unknown): error is Error {
|
|
||||||
return (
|
|
||||||
error instanceof InvalidReferenceError ||
|
|
||||||
error instanceof UnsupportedLocalRepositoryError ||
|
|
||||||
error instanceof InvalidBranchError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parse(input: string): Reference | undefined {
|
export function parse(input: string): Reference | undefined {
|
||||||
const cleaned = normalizeInput(input)
|
const cleaned = normalizeInput(input)
|
||||||
if (!cleaned) return
|
if (!cleaned) return
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface FindInput {
|
|||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly pattern: string
|
readonly pattern: string
|
||||||
readonly limit: number
|
readonly limit: number
|
||||||
|
readonly exclude?: readonly string[]
|
||||||
readonly hidden?: boolean
|
readonly hidden?: boolean
|
||||||
readonly follow?: boolean
|
readonly follow?: boolean
|
||||||
readonly signal?: AbortSignal
|
readonly signal?: AbortSignal
|
||||||
@@ -195,6 +196,7 @@ const layer = Layer.effect(
|
|||||||
...(input.hidden ? ["--hidden"] : []),
|
...(input.hidden ? ["--hidden"] : []),
|
||||||
...(input.follow ? ["--follow"] : []),
|
...(input.follow ? ["--follow"] : []),
|
||||||
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
|
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
|
||||||
|
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
|
||||||
"--glob=!**/.git/**",
|
"--glob=!**/.git/**",
|
||||||
".",
|
".",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { castDraft, produce, type WritableDraft } from "immer"
|
import { castDraft, produce, type WritableDraft } from "immer"
|
||||||
import { DateTime, Effect } from "effect"
|
import { DateTime, Effect, Match, pipe } from "effect"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
|
|
||||||
export type MemoryState = {
|
|
||||||
messages: SessionMessage.Info[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Adapter {
|
export interface Adapter {
|
||||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||||
@@ -23,89 +19,7 @@ export interface Adapter {
|
|||||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function memory(state: MemoryState): Adapter {
|
export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||||
const assistantIndex = (messageID: SessionMessage.ID) =>
|
|
||||||
state.messages.findLastIndex((message) => message.id === messageID)
|
|
||||||
const shellIndex = (messageID: SessionMessage.ID) =>
|
|
||||||
state.messages.findLastIndex((message) => message.id === messageID)
|
|
||||||
const compactionIndex = () =>
|
|
||||||
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
|
|
||||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
|
||||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
|
||||||
|
|
||||||
return {
|
|
||||||
getModel() {
|
|
||||||
return Effect.sync(
|
|
||||||
() =>
|
|
||||||
state.messages.findLast(
|
|
||||||
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
|
|
||||||
message.type === "model-switched" || message.type === "assistant",
|
|
||||||
)?.model,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCurrentAssistant() {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = latestAssistantIndex()
|
|
||||||
if (index < 0) return
|
|
||||||
const assistant = state.messages[index]
|
|
||||||
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getAssistant(messageID) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = assistantIndex(messageID)
|
|
||||||
if (index < 0) return
|
|
||||||
const assistant = state.messages[index]
|
|
||||||
return assistant?.type === "assistant" ? assistant : undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getShell(shellID) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
|
||||||
return message.type === "shell" && message.shellID === shellID
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getCompaction() {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = compactionIndex()
|
|
||||||
const message = state.messages[index]
|
|
||||||
return message?.type === "compaction" ? message : undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateAssistant(assistant) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = assistantIndex(assistant.id)
|
|
||||||
if (index < 0) return
|
|
||||||
const current = state.messages[index]
|
|
||||||
if (current?.type !== "assistant") return
|
|
||||||
state.messages[index] = assistant
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateShell(shell) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = shellIndex(shell.id)
|
|
||||||
if (index < 0) return
|
|
||||||
const current = state.messages[index]
|
|
||||||
if (current?.type !== "shell") return
|
|
||||||
state.messages[index] = shell
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateCompaction(compaction) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
|
|
||||||
if (index >= 0) state.messages[index] = compaction
|
|
||||||
})
|
|
||||||
},
|
|
||||||
appendMessage(message) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
state.messages.push(message)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|
||||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||||
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
|
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
|
||||||
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
||||||
@@ -139,9 +53,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
const project = pipe(
|
||||||
yield* SessionEvent.All.match(event, {
|
Match.type<SessionEvent.DurableEvent>(),
|
||||||
"session.usage.updated": () => Effect.void,
|
Match.discriminatorsExhaustive("type")({
|
||||||
"session.usage.recorded": () => Effect.void,
|
"session.usage.recorded": () => Effect.void,
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
@@ -321,12 +235,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.delta": (event) => {
|
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
|
||||||
const match = latestText(draft)
|
|
||||||
if (match) match.text += event.data.delta
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"session.text.ended": (event) => {
|
"session.text.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestText(draft)
|
const match = latestText(draft)
|
||||||
@@ -351,7 +259,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.tool.input.delta": () => Effect.void,
|
|
||||||
"session.tool.input.ended": (event) => {
|
"session.tool.input.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.id)
|
const match = latestTool(draft, event.data.id)
|
||||||
@@ -375,14 +282,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.tool.progress": (event) => {
|
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
|
||||||
const match = latestTool(draft, event.data.id)
|
|
||||||
if (match && match.state.status === "running") {
|
|
||||||
match.state.metadata = event.data.metadata
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// Terminal tool events are self-contained; projection is a direct copy and
|
// Terminal tool events are self-contained; projection is a direct copy and
|
||||||
// never reaches into ephemeral progress history.
|
// never reaches into ephemeral progress history.
|
||||||
"session.tool.success": (event) => {
|
"session.tool.success": (event) => {
|
||||||
@@ -436,12 +335,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.reasoning.delta": (event) => {
|
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
|
||||||
const match = latestReasoning(draft)
|
|
||||||
if (match) match.text += event.data.delta
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"session.reasoning.ended": (event) => {
|
"session.reasoning.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestReasoning(draft)
|
const match = latestReasoning(draft)
|
||||||
@@ -475,12 +368,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"session.compaction.delta": (event) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* adapter.getCompaction()
|
|
||||||
if (current?.status !== "running") return
|
|
||||||
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
|
|
||||||
}),
|
|
||||||
"session.compaction.ended": (event) => {
|
"session.compaction.ended": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const current = yield* adapter.getCompaction()
|
const current = yield* adapter.getCompaction()
|
||||||
@@ -526,8 +413,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
"session.revert.staged": () => Effect.void,
|
"session.revert.staged": () => Effect.void,
|
||||||
"session.revert.cleared": () => Effect.void,
|
"session.revert.cleared": () => Effect.void,
|
||||||
"session.revert.committed": () => Effect.void,
|
"session.revert.committed": () => Effect.void,
|
||||||
})
|
}),
|
||||||
})
|
)
|
||||||
|
return project(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as SessionMessageUpdater from "./message-updater"
|
export * as SessionMessageUpdater from "./message-updater"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as SessionModelRequest from "./model-request"
|
|||||||
|
|
||||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||||
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
|
|||||||
const options: StreamOptions = {
|
const options: StreamOptions = {
|
||||||
http: (request, handler) =>
|
http: (request, handler) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
let latest = request
|
const before = yield* hooks.trigger("session", "http.request", {
|
||||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
|
||||||
const middlewares: SessionHttpMiddleware[] = []
|
|
||||||
const web = yield* HttpClientRequest.toWeb(request)
|
|
||||||
yield* hooks.trigger("session", "http", {
|
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
use: (item) =>
|
request: yield* HttpClientRequest.toWeb(request),
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const send = (input: Request) =>
|
let sent = HttpClientRequest.fromWeb(before.request)
|
||||||
Effect.gen(function* () {
|
if (before.request.body)
|
||||||
let sent = HttpClientRequest.fromWeb(input)
|
sent = HttpClientRequest.bodyUint8Array(
|
||||||
if (input.body)
|
sent,
|
||||||
sent = HttpClientRequest.bodyUint8Array(
|
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||||
sent,
|
before.request.headers.get("content-type") ?? undefined,
|
||||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
)
|
||||||
input.headers.get("content-type") ?? undefined,
|
const response = yield* handler(sent)
|
||||||
)
|
const after = yield* hooks.trigger("session", "http.response", {
|
||||||
latest = sent
|
sessionID: session.id,
|
||||||
const response = yield* handler(sent)
|
agent: agent.id,
|
||||||
const body = [204, 205, 304].includes(response.status)
|
model: resolved.ref,
|
||||||
? null
|
request: before.request,
|
||||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
response: new Response(
|
||||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||||
origins.set(output, sent)
|
{ status: response.status, headers: response.headers },
|
||||||
return output
|
),
|
||||||
})
|
})
|
||||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
return HttpClientResponse.fromWeb(sent, after.response)
|
||||||
(next, item) => (input: Request) => item(input, next),
|
|
||||||
send,
|
|
||||||
)
|
|
||||||
const response = yield* dispatch(web)
|
|
||||||
const origin = origins.get(response) ?? latest
|
|
||||||
return HttpClientResponse.fromWeb(origin, response)
|
|
||||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ describe("node build", () => {
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
commit: () => Effect.void,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,44 +1,59 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||||
|
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { tmpdir } from "../fixture/tmpdir"
|
import { location } from "../fixture/location"
|
||||||
import { testEffect } from "../lib/effect"
|
|
||||||
|
|
||||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
describe("FileSystemSearch", () => {
|
||||||
|
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||||
|
let observed: Ripgrep.FindInput | undefined
|
||||||
|
const home = AbsolutePath.make(os.homedir())
|
||||||
|
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||||
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Ripgrep.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Ripgrep.Service,
|
||||||
|
Ripgrep.Service.of({
|
||||||
|
find: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
observed = input
|
||||||
|
if (input.onEntry)
|
||||||
|
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
glob: () => Effect.succeed([]),
|
||||||
|
grep: () => Effect.succeed([]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
|
await Effect.runPromise(
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
|
|
||||||
|
|
||||||
describe("Ripgrep", () => {
|
|
||||||
it.live("globs files as an array", () =>
|
|
||||||
withTmp((cwd) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
const search = yield* FileSystemSearch.Service
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
yield* Effect.sleep("10 millis")
|
||||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
expect(observed?.limit).toBe(100_000)
|
||||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
|
||||||
}),
|
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||||
),
|
RelativePath.make(`src${path.sep}`),
|
||||||
)
|
)
|
||||||
|
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||||
it.live("greps files with include filtering", () =>
|
)
|
||||||
withTmp((cwd) =>
|
})
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
|
||||||
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
|
||||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const projectLayer = Layer.succeed(
|
|||||||
canonical: AbsolutePath.make("/main/repo"),
|
canonical: AbsolutePath.make("/main/repo"),
|
||||||
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
||||||
}),
|
}),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
import { host as testHost } from "./host"
|
import { host as testHost } from "./host"
|
||||||
@@ -223,102 +223,45 @@ describe("fromPromise", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("adapts promise session HTTP hooks", () =>
|
it.effect("adapts promise session HTTP request and response hooks", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
const bodies: string[] = []
|
|
||||||
yield* PluginPromise.fromPromise(
|
yield* PluginPromise.fromPromise(
|
||||||
define({
|
define({
|
||||||
id: "promise-session-http",
|
id: "promise-session-http",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.session.hook("http", (event) => {
|
await ctx.session.hook("http.request", (event) => {
|
||||||
event.use(async (request, next) => {
|
event.request = new Request("https://provider.test/changed", event.request)
|
||||||
request.headers.set("x-hook", "promise")
|
event.request.headers.set("x-hook", "promise")
|
||||||
await next(request)
|
|
||||||
const response = await next(request)
|
|
||||||
return new Response(`${await response.text()}-response`)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
await ctx.session.hook("http", (event) => {
|
await ctx.session.hook("http.response", async (event) => {
|
||||||
event.use(async (request, next) => {
|
event.response = new Response(`${await event.response.text()}-response`, {
|
||||||
const response = await next(request)
|
status: event.response.status,
|
||||||
return new Response(`${await response.text()}-outer`)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).effect(host)
|
).effect(host)
|
||||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
const context = {
|
||||||
const event: PluginHooks.Domains["session"]["http"] = {
|
|
||||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
use: (item) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* hooks.trigger("session", "http", event)
|
const request = yield* hooks.trigger("session", "http.request", {
|
||||||
const request = middlewares.reduce<SessionHttpHandler>(
|
...context,
|
||||||
(next, item) => (input: Request) => item(input, next),
|
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||||
(input: Request) =>
|
})
|
||||||
Effect.promise(() => input.text()).pipe(
|
const response = yield* hooks.trigger("session", "http.response", {
|
||||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
...context,
|
||||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
request: request.request,
|
||||||
),
|
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
||||||
)
|
})
|
||||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
|
||||||
|
|
||||||
expect(bodies).toEqual(["payload", "payload"])
|
expect(request.request.url).toBe("https://provider.test/changed")
|
||||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
const host = yield* PluginHost.make(plugin)
|
|
||||||
yield* PluginPromise.fromPromise(
|
|
||||||
define({
|
|
||||||
id: "promise-session-http-interrupt",
|
|
||||||
setup: async (ctx) => {
|
|
||||||
await ctx.session.hook("http", (event) => {
|
|
||||||
event.use((request, next) => next(request))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).effect(host)
|
|
||||||
const started = yield* Deferred.make<void>()
|
|
||||||
const interrupted = yield* Deferred.make<void>()
|
|
||||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
|
||||||
const event: PluginHooks.Domains["session"]["http"] = {
|
|
||||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
|
||||||
use: (item) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* hooks.trigger("session", "http", event)
|
|
||||||
const request = middlewares.reduce<SessionHttpHandler>(
|
|
||||||
(next, item) => (input: Request) => item(input, next),
|
|
||||||
() =>
|
|
||||||
Deferred.succeed(started, undefined).pipe(
|
|
||||||
Effect.andThen(Effect.never),
|
|
||||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(started)
|
|
||||||
yield* Fiber.interrupt(fiber)
|
|
||||||
|
|
||||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
|||||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
@@ -31,26 +30,13 @@ function required<T>(value: T | undefined): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||||
use: (item) =>
|
request: new Request(url, { method: "POST", body: "{}" }),
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const request = middlewares.reduce<SessionHttpHandler>(
|
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||||
(next, item) => (input: Request) => item(input, next),
|
|
||||||
(input: Request) => {
|
|
||||||
const headers = new Headers(input.headers)
|
|
||||||
headers.set("x-seen-url", input.url)
|
|
||||||
return Effect.succeed(new Response(null, { headers }))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
|
||||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
|
|||||||
@@ -101,13 +101,10 @@ describe("RepositoryCache", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("returns typed validation and clone failures", () =>
|
it.live("returns typed branch validation and clone failures", () =>
|
||||||
withRemote((fixture) =>
|
withRemote((fixture) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* RepositoryCache.Service
|
const cache = yield* RepositoryCache.Service
|
||||||
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
|
|
||||||
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
|
|
||||||
|
|
||||||
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
||||||
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,44 @@ import { testEffect } from "./lib/effect"
|
|||||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||||
|
|
||||||
describe("Ripgrep", () => {
|
describe("Ripgrep", () => {
|
||||||
|
it.live("globs files as an array", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
|
||||||
|
|
||||||
|
const result = yield* (yield* Ripgrep.Service).glob({ cwd: tmp.path, pattern: "**/*.ts", limit: 10 })
|
||||||
|
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("greps files with include filtering", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "skip.txt"), "needle\n"))
|
||||||
|
|
||||||
|
const result = yield* (yield* Ripgrep.Service).grep({
|
||||||
|
cwd: tmp.path,
|
||||||
|
pattern: "needle",
|
||||||
|
include: "*.ts",
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
||||||
|
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("keeps ignored files out of catch-all find results", () =>
|
it.live("keeps ignored files out of catch-all find results", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -63,6 +101,29 @@ describe("Ripgrep", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("excludes protected directory trees from catch-all find results", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n"))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
|
||||||
|
|
||||||
|
const files = yield* (yield* Ripgrep.Service).find({
|
||||||
|
cwd: tmp.path,
|
||||||
|
pattern: "*",
|
||||||
|
limit: 10,
|
||||||
|
exclude: ["Pictures/**"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt"))
|
||||||
|
expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg"))
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("returns a bounded preview for matches on oversized lines", () =>
|
it.live("returns a bounded preview for matches on oversized lines", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
let requests: LLMRequest[] = []
|
let requests: LLMRequest[] = []
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Session } from "@opencode-ai/core/session"
|
|||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { fromRow } from "@opencode-ai/core/session/info"
|
import { fromRow } from "@opencode-ai/core/session/info"
|
||||||
@@ -126,34 +125,6 @@ describe("SessionProjector", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("folds live compaction deltas into running memory state", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = {
|
|
||||||
messages: [
|
|
||||||
SessionMessage.CompactionRunning.make({
|
|
||||||
id: SessionMessage.ID.make("msg_compaction"),
|
|
||||||
type: "compaction",
|
|
||||||
status: "running",
|
|
||||||
reason: "manual",
|
|
||||||
summary: "partial ",
|
|
||||||
recent: "recent",
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
yield* SessionMessageUpdater.update(
|
|
||||||
SessionMessageUpdater.memory(state),
|
|
||||||
SessionEvent.Compaction.Delta.make({
|
|
||||||
id: Event.ID.make("evt_delta"),
|
|
||||||
type: "session.compaction.delta",
|
|
||||||
created,
|
|
||||||
data: { sessionID, text: "summary" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
@@ -550,31 +521,6 @@ describe("SessionProjector", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const stale = SessionMessage.Assistant.make({
|
|
||||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
|
||||||
type: "assistant",
|
|
||||||
agent: build,
|
|
||||||
model,
|
|
||||||
content: [],
|
|
||||||
time: { created },
|
|
||||||
})
|
|
||||||
const completed = SessionMessage.Assistant.make({
|
|
||||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
|
||||||
type: "assistant",
|
|
||||||
agent: build,
|
|
||||||
model,
|
|
||||||
content: [],
|
|
||||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
|
|
||||||
).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||||||
describe("SessionModelRequest HTTP bridge", () => {
|
describe("SessionModelRequest HTTP bridge", () => {
|
||||||
const bodies: Uint8Array[] = []
|
const bodies: Uint8Array[] = []
|
||||||
const methods: string[] = []
|
const methods: string[] = []
|
||||||
|
const headers: Array<string | undefined> = []
|
||||||
const response = [
|
const response = [
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||||
@@ -267,6 +268,7 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||||
methods.push(request.method)
|
methods.push(request.method)
|
||||||
bodies.push(request.body.body.slice())
|
bodies.push(request.body.body.slice())
|
||||||
|
headers.push(request.headers["x-hook"])
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||||
@@ -274,14 +276,16 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const retryIt = testEffect(
|
const httpIt = testEffect(
|
||||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||||
)
|
)
|
||||||
|
|
||||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
bodies.length = 0
|
bodies.length = 0
|
||||||
methods.length = 0
|
methods.length = 0
|
||||||
|
headers.length = 0
|
||||||
|
const seen: string[] = []
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
@@ -296,13 +300,20 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
catalog: catalogHost(catalog),
|
catalog: catalogHost(catalog),
|
||||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||||
})
|
})
|
||||||
yield* pluginHost.session.hook("http", (event) =>
|
yield* pluginHost.session.hook("http.request", (event) =>
|
||||||
event.use((request, next) =>
|
Effect.sync(() => {
|
||||||
Effect.gen(function* () {
|
seen.push("request")
|
||||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
event.request.headers.set("x-hook", "effect")
|
||||||
return yield* next(request)
|
}),
|
||||||
}),
|
)
|
||||||
),
|
yield* pluginHost.session.hook("http.response", (event) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
||||||
|
event.response = new Response(
|
||||||
|
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
||||||
|
event.response,
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -330,10 +341,15 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
|
|
||||||
yield* session.resume(retrySessionID)
|
yield* session.resume(retrySessionID)
|
||||||
|
|
||||||
expect(methods).toEqual(["POST", "POST"])
|
expect(methods).toEqual(["POST"])
|
||||||
expect(bodies).toHaveLength(2)
|
expect(headers).toEqual(["effect"])
|
||||||
|
expect(seen).toEqual(["request", "response:200:effect"])
|
||||||
|
expect(bodies).toHaveLength(1)
|
||||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||||
expect(bodies[1]).toEqual(bodies[0])
|
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||||
|
type: "assistant",
|
||||||
|
content: [{ type: "text", text: "Hooked!" }],
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
|||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { Effect, JsonSchema } from "effect"
|
import type { JsonSchema } from "effect"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttp {
|
export interface SessionHttpRequest {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
request: Request
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
export interface SessionHttpResponse {
|
||||||
|
readonly sessionID: Session.ID
|
||||||
export type SessionHttpMiddleware = (
|
readonly agent: Agent.ID
|
||||||
request: Request,
|
readonly model: Model.Ref
|
||||||
next: SessionHttpHandler,
|
readonly request: Request
|
||||||
) => Effect.Effect<Response, Error>
|
response: Response
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly http: SessionHttp
|
readonly "http.request": SessionHttpRequest
|
||||||
|
readonly "http.response": SessionHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttp {
|
export interface SessionHttpRequest {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
request: Request
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
export interface SessionHttpResponse {
|
||||||
|
readonly sessionID: Session.ID
|
||||||
export type SessionHttpMiddleware = (
|
readonly agent: Agent.ID
|
||||||
request: Request,
|
readonly model: Model.Ref
|
||||||
next: SessionHttpHandler,
|
readonly request: Request
|
||||||
) => Promise<Response> | Response
|
response: Response
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly http: SessionHttp
|
readonly "http.request": SessionHttpRequest
|
||||||
|
readonly "http.response": SessionHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ export const lineCommentStyles = `
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
|
||||||
background: var(--syntax-diff-add);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-component="icon"] {
|
[data-component="line-comment"] [data-component="icon"] {
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
|
|||||||
|
|
||||||
installLineCommentStyles()
|
installLineCommentStyles()
|
||||||
|
|
||||||
export type LineCommentVariant = "default" | "editor" | "add"
|
export type LineCommentVariant = "default" | "editor"
|
||||||
|
|
||||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||||
return (
|
return (
|
||||||
@@ -156,25 +156,6 @@ export const LineComment = (props: LineCommentProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
|
||||||
const [split, rest] = splitProps(props, ["label"])
|
|
||||||
const i18n = useI18n()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LineCommentAnchor
|
|
||||||
{...rest}
|
|
||||||
open={false}
|
|
||||||
variant="add"
|
|
||||||
icon="plus"
|
|
||||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||||
value: string
|
value: string
|
||||||
selection: JSX.Element
|
selection: JSX.Element
|
||||||
|
|||||||
@@ -948,10 +948,6 @@ function ExaOutput(props: { output?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerPartComponent(type: string, component: PartComponent) {
|
|
||||||
PART_MAPPING[type] = component
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Message(props: MessageProps) {
|
export function Message(props: MessageProps) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
+16
-8
@@ -246,19 +246,27 @@ Runtime hooks intercept live operations:
|
|||||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||||
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
||||||
|
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
||||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||||
|
|
||||||
HTTP hooks can modify requests, inspect responses, retry, or return a
|
HTTP hooks can modify requests and responses. They apply to native models; AI
|
||||||
response without calling the provider. It applies to native models; AI SDK
|
SDK models do not currently pass through these hooks. Request and response
|
||||||
models do not currently pass through this hook.
|
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
||||||
|
separate reader, but be aware that its slower branch may buffer data. To inspect
|
||||||
|
or modify chunks while preserving streaming, replace the body with one piped
|
||||||
|
through a `TransformStream`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await ctx.session.hook("http", (event) => {
|
await ctx.session.hook("http.request", (event) => {
|
||||||
event.use((request, next) => {
|
event.request.headers.set("x-session-id", event.sessionID)
|
||||||
request.headers.set("x-session-id", event.sessionID)
|
})
|
||||||
return next(request)
|
|
||||||
|
await ctx.session.hook("http.response", (event) => {
|
||||||
|
event.response = new Response(event.response.body, {
|
||||||
|
status: event.response.status,
|
||||||
|
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user